From 99fe1186ff164f91a3285e4ae80c13808e8ee3c5 Mon Sep 17 00:00:00 2001 From: zk Date: Sat, 13 Jun 2026 11:04:29 +0100 Subject: [PATCH] fix: sdvx goals maxxive + ensure this is easier to fix in future (#1661) * fix: goalfix * fix: goalfix 2 * fix: fmt * fix: script for fixing quests, too, ugh --- .just/db.just | 2 +- ...260612162243_goal_id_on_update_cascade.sql | 14 + db/seeds/goals.json | 308 +++++++++--------- db/seeds/quests.json | 16 +- docs/src/game-support/games/sdvx-Single.md | 2 +- typescript/seeds-scripts/lib/README.md | 4 + .../lib/goals/goal-title-from-seeds.ts | 234 +++++++++++++ .../seeds-scripts/rerunners/fix-goals.ts | 84 +++++ typescript/seeds-scripts/test/goal-id.test.ts | 3 - typescript/seeds-scripts/test/goals.test.ts | 79 +++++ .../src/lib/targets/goal-pb-queries.test.ts | 39 +++ typescript/server/src/lib/targets/goals.ts | 126 ++++--- .../src/scripts/reconcile-goal-ids.test.ts | 105 ++++++ .../server/src/scripts/reconcile-goal-ids.ts | 233 +++++++++++++ .../src/scripts/update-quest-subscriptions.ts | 159 +++++++++ 15 files changed, 1193 insertions(+), 215 deletions(-) create mode 100644 db/migrations/20260612162243_goal_id_on_update_cascade.sql create mode 100644 typescript/seeds-scripts/lib/README.md create mode 100644 typescript/seeds-scripts/lib/goals/goal-title-from-seeds.ts create mode 100644 typescript/seeds-scripts/rerunners/fix-goals.ts delete mode 100644 typescript/seeds-scripts/test/goal-id.test.ts create mode 100644 typescript/seeds-scripts/test/goals.test.ts create mode 100644 typescript/server/src/lib/targets/goal-pb-queries.test.ts create mode 100644 typescript/server/src/scripts/reconcile-goal-ids.test.ts create mode 100644 typescript/server/src/scripts/reconcile-goal-ids.ts create mode 100644 typescript/server/src/scripts/update-quest-subscriptions.ts diff --git a/.just/db.just b/.just/db.just index e2c19b7ce..6cd016e93 100644 --- a/.just/db.just +++ b/.just/db.just @@ -81,5 +81,5 @@ db-load-dataset: echo "Restoring $file into $db_name..." gunzip -c "$file" | psql -d "$db_url" - echo "Done. Change typescript/server/.env to use POSTGRES=\"$db_url\"" + echo "Done. Change typescript/server/.env to use POSTGRES_URL=\"$db_url\"" diff --git a/db/migrations/20260612162243_goal_id_on_update_cascade.sql b/db/migrations/20260612162243_goal_id_on_update_cascade.sql new file mode 100644 index 000000000..ad188efe2 --- /dev/null +++ b/db/migrations/20260612162243_goal_id_on_update_cascade.sql @@ -0,0 +1,14 @@ +-- When a goal's canonical id changes (content hash), child rows should follow. +-- Used by reconcile-goal-ids and other goal-id maintenance scripts. + +ALTER TABLE "goal_sub" DROP CONSTRAINT goal_sub_goal_id_fkey; + +ALTER TABLE "goal_sub" + ADD CONSTRAINT goal_sub_goal_id_fkey + FOREIGN KEY (goal_id) REFERENCES goal (id) ON UPDATE CASCADE; + +ALTER TABLE "import_goal" DROP CONSTRAINT import_goal_goal_id_fkey; + +ALTER TABLE "import_goal" + ADD CONSTRAINT import_goal_goal_id_fkey + FOREIGN KEY (goal_id) REFERENCES goal (id) ON UPDATE CASCADE; diff --git a/db/seeds/goals.json b/db/seeds/goals.json index 5e0b46bf6..19850cdc7 100644 --- a/db/seeds/goals.json +++ b/db/seeds/goals.json @@ -11,7 +11,7 @@ }, "game": "iidx-sp", "goalID": "G82b65d092a5da05bad2b1b116fe8f31e844e55340343b2a2b7a5def5ad1944da", - "name": "HARD CLEAR Feel The Beat (SP LEGGENDARIA 12)" + "name": "HARD CLEAR Feel The Beat (SPL 12)" }, { "charts": { @@ -25,7 +25,7 @@ }, "game": "iidx-sp", "goalID": "Gffc819a5f5189a4bd69ea8d59df58e90049d24d4c3abeda6cdc7093871187a15", - "name": "EX HARD CLEAR 子供の落書き帳 (SP ANOTHER 12)" + "name": "EX HARD CLEAR 子供の落書き帳 (SPA 12)" }, { "charts": { @@ -39,7 +39,7 @@ }, "game": "iidx-sp", "goalID": "Gaf4d6d407627845d3b99aecbebd0fda3604ad74fa6fc8ef93e095206f26a186e", - "name": "HARD CLEAR TITANS RETURN (SP LEGGENDARIA 12)" + "name": "HARD CLEAR TITANS RETURN (SPL 12)" }, { "charts": { @@ -56,7 +56,7 @@ }, "game": "iidx-sp", "goalID": "G4337f43d79436a40dd601662d216b0f1600c0dcd51335a01fb78a0f2fb973e87", - "name": "CLEAR either 灼熱Beach Side Bunny (SP ANOTHER 12) or 灼熱 Pt.2 Long Train Running (SP ANOTHER 12)" + "name": "CLEAR either 灼熱Beach Side Bunny (SPA 12) or 灼熱 Pt.2 Long Train Running (SPA 12)" }, { "charts": { @@ -70,7 +70,7 @@ }, "game": "iidx-sp", "goalID": "Gf28a793ee1494021c426601722beb8fb8834e566aa3511bfcaa929da3e0167fe", - "name": "HARD CLEAR Timepiece phase II (CN Ver.) (SP ANOTHER 12)" + "name": "HARD CLEAR Timepiece phase II (CN Ver.) (SPA 12)" }, { "charts": { @@ -84,7 +84,7 @@ }, "game": "iidx-sp", "goalID": "Gc138f8ddfb496e6f5f57029e88167b1a548595132099a98bd2d2458afb0b5fd8", - "name": "AAA AA (SP ANOTHER 12)" + "name": "AAA AA (SPA 12)" }, { "charts": { @@ -98,7 +98,7 @@ }, "game": "iidx-sp", "goalID": "G8b1b48d2605c924e26f634811772b860e9debae64dffc23117e884bd49da8c7a", - "name": "Get 98% on Fly Above (SP ANOTHER 8)" + "name": "Get 98.00% on Fly Above (SPA 8)" }, { "charts": { @@ -112,7 +112,7 @@ }, "game": "iidx-sp", "goalID": "Gcd63fc3026b2b9a29f6d9b52efdeaa9b91373ba341102f8688532bb57b54a921", - "name": "Get 98% on the trigger of innocence (SP ANOTHER 9)" + "name": "Get 98.00% on the trigger of innocence (SPA 9)" }, { "charts": { @@ -126,7 +126,7 @@ }, "game": "iidx-sp", "goalID": "G8989553b36ff0fec16e2455174405eb72fb2130b7aea60fcc568969584da9b11", - "name": "Get 98% on CROSSROAD ~Left Story~ (SP ANOTHER 10)" + "name": "Get 98.00% on CROSSROAD ~Left Story~ (SPA 10)" }, { "charts": { @@ -140,7 +140,7 @@ }, "game": "iidx-sp", "goalID": "G69ad881ea4b1f1dfa3b2658dde0f7f409428868a01f31661fcbfb4a05bd4d4b5", - "name": "MAX- mosaic (SP ANOTHER 12)" + "name": "MAX- mosaic (SPA 12)" }, { "charts": { @@ -154,7 +154,7 @@ }, "game": "iidx-sp", "goalID": "G457b84c2947ecd3d11fc70d3570ba24f68c329b6f52a007453799f11154333f1", - "name": "AAA Happy Wedding (SP ANOTHER 8)" + "name": "AAA Happy Wedding (SPA 8)" }, { "charts": { @@ -168,7 +168,7 @@ }, "game": "iidx-sp", "goalID": "G77daeb5ffaeb596e41b4d5091114181a5b108e8dacda70589119b46fb9444fec", - "name": "MAX- SHADE (SP ANOTHER 11)" + "name": "MAX- SHADE (SPA 11)" }, { "charts": { @@ -212,7 +212,7 @@ }, "game": "iidx-sp", "goalID": "G2e07aa4ba3d8c0d505ca48a88608ad4656cc420975eda49249078dda6b415210", - "name": "CLEAR Chrono Diver -PENDULUMs- (SP ANOTHER 12)" + "name": "CLEAR Chrono Diver -PENDULUMs- (SPA 12)" }, { "charts": { @@ -226,7 +226,7 @@ }, "game": "iidx-sp", "goalID": "Gb690b365597c2667e3a2329b770d99598a550fc38a79841b2777878c39867e46", - "name": "HARD CLEAR Verflucht (SP ANOTHER 12)" + "name": "HARD CLEAR Verflucht (SPA 12)" }, { "charts": { @@ -240,7 +240,7 @@ }, "game": "iidx-sp", "goalID": "Ga813b4d028e001d5b936120c08877b7423c1a10d97f8a0a35551555d0fea70ae", - "name": "CLEAR ECHIDNA (SP ANOTHER 12)" + "name": "CLEAR ECHIDNA (SPA 12)" }, { "charts": { @@ -254,7 +254,7 @@ }, "game": "iidx-sp", "goalID": "G3cf0e9566e9614005c3bd7739a07f9ebcda46dc5ee14550ea4eb4890f53d865f", - "name": "HARD CLEAR Ganymede (SP LEGGENDARIA 12)" + "name": "HARD CLEAR Ganymede (SPL 12)" }, { "charts": { @@ -268,7 +268,7 @@ }, "game": "iidx-sp", "goalID": "G9895e0a518ed111f6aa7a2fcd367b550018bcc3ac961d58f2531556e52dcc05a", - "name": "CLEAR Time to Air (SP LEGGENDARIA 12)" + "name": "CLEAR Time to Air (SPL 12)" }, { "charts": { @@ -282,7 +282,7 @@ }, "game": "iidx-sp", "goalID": "G5fefa83ff41ff91482f0d124ef68ac33be0e3abc2b47cc79f152534572d8978d", - "name": "CLEAR Lords Of The Roundtable (SP ANOTHER 12)" + "name": "CLEAR Lords Of The Roundtable (SPA 12)" }, { "charts": { @@ -296,7 +296,7 @@ }, "game": "iidx-sp", "goalID": "Ga3991ae70e8d936f441708ca4d92f88eb0da076eecb1957896a1903a1207eea0", - "name": "AAA snow storm (SP LEGGENDARIA 12)" + "name": "AAA snow storm (SPL 12)" }, { "charts": { @@ -310,7 +310,7 @@ }, "game": "iidx-sp", "goalID": "G30dc3966f3f92e58b8d71179104d36774d6168704d8bf6ceb40cf05ba8dcfc9d", - "name": "AAA カゴノトリ~弐式~ (SP ANOTHER 12)" + "name": "AAA カゴノトリ~弐式~ (SPA 12)" }, { "charts": { @@ -324,7 +324,7 @@ }, "game": "iidx-sp", "goalID": "G0001d8a4b52e87b69979e84c87fd672ad9610572380dc62e93f18943ceeee7d5", - "name": "AAA (This Is Not) The Angels (SP ANOTHER 11)" + "name": "AAA (This Is Not) The Angels (SPA 11)" }, { "charts": { @@ -338,7 +338,7 @@ }, "game": "iidx-sp", "goalID": "G09791df68d68999e0193352f4d0454a2608bfc0fa5413d3cfb7bee53c91ce77f", - "name": "AAA quasar (SP ANOTHER 11)" + "name": "AAA quasar (SPA 11)" }, { "charts": { @@ -352,7 +352,7 @@ }, "game": "iidx-sp", "goalID": "Gd0011d646ac07d043fae1a9cf99a3f8cd7678fcf76ff803d661043c7387d0eef", - "name": "MAX- Sakura Reflection (SP ANOTHER 11)" + "name": "MAX- Sakura Reflection (SPA 11)" }, { "charts": { @@ -366,7 +366,7 @@ }, "game": "iidx-sp", "goalID": "G92076fe16b0368b8f36c984034b61fd4b272b8c8332b11069107555d86209dd3", - "name": "Get 96% on Wanna Party? (SP ANOTHER 8)" + "name": "Get 96.00% on Wanna Party? (SPA 8)" }, { "charts": { @@ -425,7 +425,7 @@ }, "game": "iidx-sp", "goalID": "G44f826fbc17930f4b51fec6fd90b594fd1aa0009d2c09bcea05c69e9fd3b00e8", - "name": "EASY CLEAR Mare Nectaris (SP ANOTHER 12)" + "name": "EASY CLEAR Mare Nectaris (SPA 12)" }, { "charts": { @@ -439,7 +439,7 @@ }, "game": "iidx-sp", "goalID": "G4ece5c9ed1029cfcf8a357f2c14f2f6d629eb447a21a86c1492dc689843315e1", - "name": "CLEAR SAMURAI-Scramble (SP LEGGENDARIA 12)" + "name": "CLEAR SAMURAI-Scramble (SPL 12)" }, { "charts": { @@ -453,7 +453,7 @@ }, "game": "iidx-sp", "goalID": "G787a324f4925d77eb3e401d33a623b92c29d37a7b937260e596e38fa6ff47d17", - "name": "HARD CLEAR 疾風迅雷 (SP LEGGENDARIA 12)" + "name": "HARD CLEAR 疾風迅雷 (SPL 12)" }, { "charts": { @@ -467,7 +467,7 @@ }, "game": "iidx-sp", "goalID": "G47c32ab19cf7dcc67e13abcf16005175ca47511c143d7841f116eba91c808b5a", - "name": "HARD CLEAR Verflucht (SP LEGGENDARIA 12)" + "name": "HARD CLEAR Verflucht (SPL 12)" }, { "charts": { @@ -481,7 +481,7 @@ }, "game": "iidx-sp", "goalID": "G1f19eccb390e9aced430ab603b9d448ec25d11b8b99af01a8ae01bb7b28fb5bf", - "name": "EX HARD CLEAR お菓子の王国 (SP ANOTHER 12)" + "name": "EX HARD CLEAR お菓子の王国 (SPA 12)" }, { "charts": { @@ -495,7 +495,7 @@ }, "game": "iidx-sp", "goalID": "G260765aaaf4c568ea1b21ba6318b16bd3a1557fcd7a739218eb0ed726f26191d", - "name": "AAA BITTER CHOCOLATE STRIKER (SP ANOTHER 12)" + "name": "AAA BITTER CHOCOLATE STRIKER (SPA 12)" }, { "charts": { @@ -509,7 +509,7 @@ }, "game": "iidx-sp", "goalID": "G5d97f4fcc65dddcf9c8657c3c728eff4f97eda3e3d23a686e6822fe791ffaa65", - "name": "MAX- 千年ノ理 (SP ANOTHER 12)" + "name": "MAX- 千年ノ理 (SPA 12)" }, { "charts": { @@ -523,7 +523,7 @@ }, "game": "iidx-sp", "goalID": "Gcb65e2cae80f2ec8d44dee10abfe31497d0014740e94e239dd801df61e3400d9", - "name": "AAA Blue Rain (SP LEGGENDARIA 12)" + "name": "AAA Blue Rain (SPL 12)" }, { "charts": { @@ -537,7 +537,7 @@ }, "game": "iidx-sp", "goalID": "G5d71e6543ead302f2a8f958602e1e751364f6782aa5857bc384abb2f940b827b", - "name": "AAA Rave Cannon (SP ANOTHER 12)" + "name": "AAA Rave Cannon (SPA 12)" }, { "charts": { @@ -551,7 +551,7 @@ }, "game": "iidx-sp", "goalID": "G4517126e05627eabd8959d8e432989ed2566af16ffcef07b1033a2726f0efe3f", - "name": "AAA naughty girl@Queen's Palace (SP ANOTHER 11)" + "name": "AAA naughty girl@Queen's Palace (SPA 11)" }, { "charts": { @@ -565,7 +565,7 @@ }, "game": "iidx-sp", "goalID": "G78b0cf24fdc0c37d5e969ef11005fbbc2f7ddcecb6ba078fc54598c2919cb95e", - "name": "AAA L.F.O (SP ANOTHER 12)" + "name": "AAA L.F.O (SPA 12)" }, { "charts": { @@ -579,7 +579,7 @@ }, "game": "iidx-sp", "goalID": "Ga75d851f868d9cd34486e334da5ba50bb0d05af72dad7b523d80cf54d1cc5f99", - "name": "MAX- quasar (SP ANOTHER 11)" + "name": "MAX- quasar (SPA 11)" }, { "charts": { @@ -593,7 +593,7 @@ }, "game": "iidx-sp", "goalID": "G837a8106888785d41ae92be87d319ab2c5d6fe23145782e1d3335484c0f804ac", - "name": "Get 99% on Sleepless days (SP ANOTHER 9)" + "name": "Get 99.00% on Sleepless days (SPA 9)" }, { "charts": { @@ -607,7 +607,7 @@ }, "game": "iidx-sp", "goalID": "G4a3f1033b6149e3f0e0dfb32aa3dc222093d7bbecd8331907bb445ce3396edea", - "name": "AAA Ganymede (SP ANOTHER 11)" + "name": "AAA Ganymede (SPA 11)" }, { "charts": { @@ -651,7 +651,7 @@ }, "game": "iidx-sp", "goalID": "Ga0fd5743b2ca39e92f0cd910b6d3dbfe69f74c585c5ae50885245459ec0acb74", - "name": "EX HARD CLEAR VANESSA (SP LEGGENDARIA 12)" + "name": "EX HARD CLEAR VANESSA (SPL 12)" }, { "charts": { @@ -665,7 +665,7 @@ }, "game": "iidx-sp", "goalID": "G0798ba2866e0da23cfef4b5881fdb73905c0524b2f973d8d2b7cb222c550e0d3", - "name": "EX HARD CLEAR 共鳴遊戯の華 (SP ANOTHER 12)" + "name": "EX HARD CLEAR 共鳴遊戯の華 (SPA 12)" }, { "charts": { @@ -679,7 +679,7 @@ }, "game": "iidx-sp", "goalID": "Gc68383c682f911c6015c20174c0f7a728a91daa2b4d7156f17a46984af2b2830", - "name": "EX HARD CLEAR Sigmund (SP ANOTHER 12)" + "name": "EX HARD CLEAR Sigmund (SPA 12)" }, { "charts": { @@ -693,7 +693,7 @@ }, "game": "iidx-sp", "goalID": "G4419ba2e10d13bdb18a3102eb49afd0098508842665d4f8c4c790d2cd53b7671", - "name": "HARD CLEAR Mare Nectaris (SP ANOTHER 12)" + "name": "HARD CLEAR Mare Nectaris (SPA 12)" }, { "charts": { @@ -707,7 +707,7 @@ }, "game": "iidx-sp", "goalID": "G9456013e9a3123b974a1f94c143cecd38b7359aa9232010aa5026ba1a8798eaa", - "name": "HARD CLEAR 冥 (SP ANOTHER 12)" + "name": "HARD CLEAR 冥 (SPA 12)" }, { "charts": { @@ -721,7 +721,7 @@ }, "game": "iidx-sp", "goalID": "G032e2412e6129ee8ef9be70f6211969a5705bde97314564845f276b045837c68", - "name": "HARD CLEAR ICARUS (SP LEGGENDARIA 12)" + "name": "HARD CLEAR ICARUS (SPL 12)" }, { "charts": { @@ -735,7 +735,7 @@ }, "game": "iidx-sp", "goalID": "G419f8d2a07e5fcfaba25b14992bdb72069875d411bca0300d7308c8aab287b4e", - "name": "AAA Red. by Full Metal Jacket (SP ANOTHER 12)" + "name": "AAA Red. by Full Metal Jacket (SPA 12)" }, { "charts": { @@ -749,7 +749,7 @@ }, "game": "iidx-sp", "goalID": "G5ea9dec09ae25de05db9ff8e11f50972cb3daff56f2b93325e253295dbc6d7ae", - "name": "MAX- Summerlights(IIDX Edition) (SP ANOTHER 12)" + "name": "MAX- Summerlights(IIDX Edition) (SPA 12)" }, { "charts": { @@ -763,7 +763,7 @@ }, "game": "iidx-sp", "goalID": "Ga755b48c9be34f8afe84fb473f92b51b5c91ea7e83aef70374ba5942588adc59", - "name": "AAA Ancient Scapes (SP LEGGENDARIA 12)" + "name": "AAA Ancient Scapes (SPL 12)" }, { "charts": { @@ -777,7 +777,7 @@ }, "game": "iidx-sp", "goalID": "G323a4b95b82bbf8b20b5b2a16320c5583033dc7c3595a15e38ae394ff066a117", - "name": "AAA Elemental Creation (SP ANOTHER 12)" + "name": "AAA Elemental Creation (SPA 12)" }, { "charts": { @@ -791,7 +791,7 @@ }, "game": "iidx-sp", "goalID": "G32632bffe761472d2d8b437a9d443495f5ccc13d36543ead2f8168a52a612409", - "name": "MAX- spiral galaxy (SP LEGGENDARIA 12)" + "name": "MAX- spiral galaxy (SPL 12)" }, { "charts": { @@ -805,7 +805,7 @@ }, "game": "iidx-sp", "goalID": "G66e8607bfebd2933d6922687d5f994e0d0cf4f77eb37f9381f8065abb1edbfc2", - "name": "AAA Dances with Snow Fairies (SP ANOTHER 12)" + "name": "AAA Dances with Snow Fairies (SPA 12)" }, { "charts": { @@ -819,7 +819,7 @@ }, "game": "iidx-sp", "goalID": "Gceeeeb0f9396e3ca239cd24f4b175fa0fb489b5b615d0265dada0481357da780", - "name": "MAX- AA (SP ANOTHER 12)" + "name": "MAX- AA (SPA 12)" }, { "charts": { @@ -833,7 +833,7 @@ }, "game": "iidx-sp", "goalID": "G2783c5a2241db099ef674b64183bf6820fb0ae701eea37d50909663c010ff962", - "name": "Get 99% on CROSSROAD ~Left Story~ (SP ANOTHER 10)" + "name": "Get 99.00% on CROSSROAD ~Left Story~ (SPA 10)" }, { "charts": { @@ -847,7 +847,7 @@ }, "game": "iidx-sp", "goalID": "Ga0f4b85346aa496668a329b98503e960240bf184339e12f1a5ee8d37875a6c5d", - "name": "Get 97% on Wanna Party? (SP LEGGENDARIA 11)" + "name": "Get 97.00% on Wanna Party? (SPL 11)" }, { "charts": { @@ -906,7 +906,7 @@ }, "game": "iidx-sp", "goalID": "Gdbea7b2555584078739c7117d9daf9bccb21da4a985cbd11c8a6e6f6cec70eda", - "name": "EX HARD CLEAR X-DEN (SP ANOTHER 12)" + "name": "EX HARD CLEAR X-DEN (SPA 12)" }, { "charts": { @@ -920,7 +920,7 @@ }, "game": "iidx-sp", "goalID": "G08d2e71f1373a44e68a4e1571069340580e89c0c002e94127831a8d2d6465e8d", - "name": "EX HARD CLEAR Mare Nectaris (SP ANOTHER 12)" + "name": "EX HARD CLEAR Mare Nectaris (SPA 12)" }, { "charts": { @@ -934,7 +934,7 @@ }, "game": "iidx-sp", "goalID": "Ge629a781e5e16392371e08928166729aff3f78581b1103f5c9b18f17d6c95d49", - "name": "EX HARD CLEAR 冥 (SP ANOTHER 12)" + "name": "EX HARD CLEAR 冥 (SPA 12)" }, { "charts": { @@ -948,7 +948,7 @@ }, "game": "iidx-sp", "goalID": "Gd0011c9db52cfdc0033fc9f365769795f165b1be4eade7a3bb2543aeb7992716", - "name": "EX HARD CLEAR ICARUS (SP LEGGENDARIA 12)" + "name": "EX HARD CLEAR ICARUS (SPL 12)" }, { "charts": { @@ -962,7 +962,7 @@ }, "game": "iidx-sp", "goalID": "Gd80e986ac4a1048b0f06b07ece1872b43fe0e7f6a7f8061b8c92decd883100dd", - "name": "FULL COMBO KAMAITACHI (SP LEGGENDARIA 12)" + "name": "FULL COMBO KAMAITACHI (SPL 12)" }, { "charts": { @@ -976,7 +976,7 @@ }, "game": "iidx-sp", "goalID": "G120e962dd6696f83eb31f27fc3864dd95f4233aa77c17d2a464e0fa73cd39329", - "name": "Get a score of 3,700 on 冥 (SP ANOTHER 12)" + "name": "Get a score of 3700 on 冥 (SPA 12)" }, { "charts": { @@ -990,7 +990,7 @@ }, "game": "iidx-sp", "goalID": "Gee5e0abe9fa76bb73296a24596b83dfeb7060f5c37d522517d13cccb31b65ebe", - "name": "Get a score of 3,600 on AA (SP ANOTHER 12)" + "name": "Get a score of 3600 on AA (SPA 12)" }, { "charts": { @@ -1004,7 +1004,7 @@ }, "game": "iidx-sp", "goalID": "Gf641f985d3841511ae37d635e6a1978cfb1043061099d198ac1a77b4fe13b6e5", - "name": "AAA 灼熱Beach Side Bunny (SP ANOTHER 12)" + "name": "AAA 灼熱Beach Side Bunny (SPA 12)" }, { "charts": { @@ -1021,7 +1021,7 @@ }, "game": "iidx-sp", "goalID": "G570383afbf183d94119a3da4c5d0eda8b3eef9f202a7a6f17c444c183beb196f", - "name": "AAA either Thor's Hammer (SP ANOTHER 12) or DIAVOLO (SP ANOTHER 12)" + "name": "AAA either Thor's Hammer (SPA 12) or DIAVOLO (SPA 12)" }, { "charts": { @@ -1065,7 +1065,7 @@ }, "game": "iidx-sp", "goalID": "G8fc2ac9dfe18dc7fded66c38a209121334dab11f4c034d0a4a55e706c323aa92", - "name": "EX HARD CLEAR 卑弥呼 (SP ANOTHER 12)" + "name": "EX HARD CLEAR 卑弥呼 (SPA 12)" }, { "charts": { @@ -1079,7 +1079,7 @@ }, "game": "iidx-sp", "goalID": "Gbee83f6bc69ddc62930aa243758355f5a19af2f3ae9319c96c4c972b4c621474", - "name": "EX HARD CLEAR Verflucht (SP LEGGENDARIA 12)" + "name": "EX HARD CLEAR Verflucht (SPL 12)" }, { "charts": { @@ -1093,7 +1093,7 @@ }, "game": "iidx-sp", "goalID": "G52184357436154ebd5abdf2028e1d1f9a532acfa25386ff928e3abaf9b523ece", - "name": "EX HARD CLEAR 灼熱Beach Side Bunny (SP ANOTHER 12)" + "name": "EX HARD CLEAR 灼熱Beach Side Bunny (SPA 12)" }, { "charts": { @@ -1107,7 +1107,7 @@ }, "game": "iidx-sp", "goalID": "G42150286b5c1291d0088ee3500699b88550fcf5ed606f27564e0e1feee25e1fd", - "name": "EX HARD CLEAR 疾風迅雷 (SP LEGGENDARIA 12)" + "name": "EX HARD CLEAR 疾風迅雷 (SPL 12)" }, { "charts": { @@ -1121,7 +1121,7 @@ }, "game": "iidx-sp", "goalID": "Gcfe51d202acaf1d80abb33aa6fa27cb55cd6cb12d39d542de2d9ed76d2d72cce", - "name": "AAA 冥 (SP ANOTHER 12)" + "name": "AAA 冥 (SPA 12)" }, { "charts": { @@ -1135,7 +1135,7 @@ }, "game": "iidx-sp", "goalID": "Gdcc90e937dd8d3b70a65dc8c5dccb657804f1843100f9097e0ee28b19d52f342", - "name": "MAX- BLACK.by X-Cross Fade (SP ANOTHER 12)" + "name": "MAX- BLACK.by X-Cross Fade (SPA 12)" }, { "charts": { @@ -1149,7 +1149,7 @@ }, "game": "iidx-sp", "goalID": "Gf1de051ba70fcf820554659b4f18d88e4f92cd8c439007c0cc281725f3dd933d", - "name": "Get 98% on LIGHTNING STRIKES (SP ANOTHER 12)" + "name": "Get 98.00% on LIGHTNING STRIKES (SPA 12)" }, { "charts": { @@ -1163,7 +1163,7 @@ }, "game": "iidx-sp", "goalID": "G2e8ac571f5ee13b00e1022f977610035820de706ada6e27e0c962b3bfb82017c", - "name": "MAX- Initiation (SP ANOTHER 12)" + "name": "MAX- Initiation (SPA 12)" }, { "charts": { @@ -1207,7 +1207,7 @@ }, "game": "iidx-sp", "goalID": "G1191e051d665729dbe01849f3ecbfb97d5d0ab2a0f48f137c615e0bf7fa78918", - "name": "HARD CLEAR Bad Maniacs (SP ANOTHER 12)" + "name": "HARD CLEAR Bad Maniacs (SPA 12)" }, { "charts": { @@ -1221,7 +1221,7 @@ }, "game": "iidx-sp", "goalID": "G984ea353ab4625321bbc6da20fbfc2e687904453e13612a7e9a8d63a193c6827", - "name": "HARD CLEAR MENDES (SP ANOTHER 12)" + "name": "HARD CLEAR MENDES (SPA 12)" }, { "charts": { @@ -1235,7 +1235,7 @@ }, "game": "iidx-sp", "goalID": "G3e1768f0dd814c3e0c0901cdd9d7ba68aaaeac25813a26bdab829dc28f80d20a", - "name": "HARD CLEAR quell~the seventh slave~ (SP ANOTHER 12)" + "name": "HARD CLEAR quell~the seventh slave~ (SPA 12)" }, { "charts": { @@ -1249,7 +1249,7 @@ }, "game": "iidx-sp", "goalID": "G1196a29a0eaa645331e5a8ed87850ea47928b79b9e0770d0c1f4315beeefb4d2", - "name": "HARD CLEAR 天空の夜明け (SP ANOTHER 12)" + "name": "HARD CLEAR 天空の夜明け (SPA 12)" }, { "charts": { @@ -1263,7 +1263,7 @@ }, "game": "iidx-sp", "goalID": "G8df75407b85c6c8c91df76c180f6adc3e92a1ad6baf5dacbb1e5a8f3514ca332", - "name": "HARD CLEAR HAERETICUS (SP ANOTHER 12)" + "name": "HARD CLEAR HAERETICUS (SPA 12)" }, { "charts": { @@ -1277,7 +1277,7 @@ }, "game": "iidx-sp", "goalID": "G1f238c90d6cae13e1a9a92fa30bbe927c5371555c8a8352f5e64b5b877f42e90", - "name": "CLEAR BLACK.by X-Cross Fade (SP ANOTHER 12)" + "name": "CLEAR BLACK.by X-Cross Fade (SPA 12)" }, { "charts": { @@ -1291,7 +1291,7 @@ }, "game": "iidx-sp", "goalID": "G660215b59a7e90e383646ae5fdfa23119f039eb6ed1cee6fdea02651d3432abe", - "name": "HARD CLEAR Watch Out Pt.2 (SP ANOTHER 12)" + "name": "HARD CLEAR Watch Out Pt.2 (SPA 12)" }, { "charts": { @@ -1305,7 +1305,7 @@ }, "game": "iidx-sp", "goalID": "G4f46706d7db85677602a90e65b65d911b8a17e35914c0e516f2b761860f7b76d", - "name": "EX HARD CLEAR era (nostalmix) (SP ANOTHER 9)" + "name": "EX HARD CLEAR era (nostalmix) (SPA 9)" }, { "charts": { @@ -1319,7 +1319,7 @@ }, "game": "iidx-sp", "goalID": "G5b71931a7e90e1eb95d6b511081a7a49bd75afadeab3c41950a6c4d2334d1f27", - "name": "AAA mosaic (SP ANOTHER 12)" + "name": "AAA mosaic (SPA 12)" }, { "charts": { @@ -1333,7 +1333,7 @@ }, "game": "iidx-sp", "goalID": "G16604fc0f09ecd18992627d223975b27053621dc6f22b3031bb2908f06981123", - "name": "AAA Hypersonik (SP ANOTHER 10)" + "name": "AAA Hypersonik (SPA 10)" }, { "charts": { @@ -1347,7 +1347,7 @@ }, "game": "iidx-sp", "goalID": "G868c59acfc4566b20f693faeffb225961d849ddab11d0e3e56636efa4394ed3e", - "name": "AAA PENDUAL TALISMAN (SP ANOTHER 11)" + "name": "AAA PENDUAL TALISMAN (SPA 11)" }, { "charts": { @@ -1361,7 +1361,7 @@ }, "game": "iidx-sp", "goalID": "G9a8b0a741cbee109b56ced7699cc19d2f9fc06f1786627069df742713e284c02", - "name": "MAX- Dazzlin' Darlin (SP ANOTHER 10)" + "name": "MAX- Dazzlin' Darlin (SPA 10)" }, { "charts": { @@ -1375,7 +1375,7 @@ }, "game": "iidx-sp", "goalID": "G23b15f843994c0273aff04c7c914e699599cb446e68a093804cc9e0469be296d", - "name": "AAA Make Me Your Own (SP ANOTHER 10)" + "name": "AAA Make Me Your Own (SPA 10)" }, { "charts": { @@ -1389,7 +1389,7 @@ }, "game": "iidx-sp", "goalID": "G5b6fb76ed8ab1daabcf78dfe2d886b4751cdb246daab0eea3054c66a47116336", - "name": "AAA Line 4 Ruin (SP ANOTHER 11)" + "name": "AAA Line 4 Ruin (SPA 11)" }, { "charts": { @@ -1448,7 +1448,7 @@ }, "game": "iidx-sp", "goalID": "G0186e3c1d3a95cde307ab2ed77477b2071b4d3bfc9cf47c6261f0de9219f2bec", - "name": "HARD CLEAR AA (SP ANOTHER 12)" + "name": "HARD CLEAR AA (SPA 12)" }, { "charts": { @@ -1462,7 +1462,7 @@ }, "game": "iidx-sp", "goalID": "G92c4e2e8b0b6456ea4f5d2ad46c00112dbec7011e2efda8f775554a8fd3049c0", - "name": "HARD CLEAR Scripted Connection⇒ A mix (SP ANOTHER 12)" + "name": "HARD CLEAR Scripted Connection⇒ A mix (SPA 12)" }, { "charts": { @@ -1476,7 +1476,7 @@ }, "game": "iidx-sp", "goalID": "Gc473e58f7e585d79c937f2883964366ffa5ca15d0d388e49872192ec654887be", - "name": "CLEAR Dr. Chemical & Killing Machine (SP ANOTHER 12)" + "name": "CLEAR Dr. Chemical & Killing Machine (SPA 12)" }, { "charts": { @@ -1495,7 +1495,7 @@ }, "game": "iidx-sp", "goalID": "Gccc8f3d4644181d1d9cc6784d108de5687259b40fd5ff9c776ced3383cc04087", - "name": "CLEAR any one of Red. by Full Metal Jacket (SP ANOTHER 12), Watch Out Pt.2 (SP ANOTHER 12), 199024club -Re:BounceKiller- (SP ANOTHER 12) or Snake Stick (SP ANOTHER 12)" + "name": "CLEAR any one of Red. by Full Metal Jacket (SPA 12), Watch Out Pt.2 (SPA 12), 199024club -Re:BounceKiller- (SPA 12) or Snake Stick (SPA 12)" }, { "charts": { @@ -1509,7 +1509,7 @@ }, "game": "iidx-sp", "goalID": "G2c7b93f4b9b9c4e5a94d055f30e0a030db05ff764737c00caf91f43949b5e1fa", - "name": "HARD CLEAR 雪月花 (SP ANOTHER 12)" + "name": "HARD CLEAR 雪月花 (SPA 12)" }, { "charts": { @@ -1523,7 +1523,7 @@ }, "game": "iidx-sp", "goalID": "G12af9d75eb470b805b44d2b1fee56836987cea310394da63abf2fa2e8ae04c32", - "name": "AAA Sakura Reflection (SP ANOTHER 11)" + "name": "AAA Sakura Reflection (SPA 11)" }, { "charts": { @@ -1537,7 +1537,7 @@ }, "game": "iidx-sp", "goalID": "G57bd838593c38d2a40cf7fa1286706570b28fee87138c8e67bbf8443619bf771", - "name": "AAA Daisuke (SP ANOTHER 10)" + "name": "AAA Daisuke (SPA 10)" }, { "charts": { @@ -1551,7 +1551,7 @@ }, "game": "iidx-sp", "goalID": "G5918dd18f5d557d6170b7f341e3cafa1b963f6f7986b6e2ec38624c5bd4f8971", - "name": "AAA NO LIMIT -オレ達に限界は無い- (SP ANOTHER 10)" + "name": "AAA NO LIMIT -オレ達に限界は無い- (SPA 10)" }, { "charts": { @@ -1565,7 +1565,7 @@ }, "game": "iidx-sp", "goalID": "G16bbe6de199b6b6999fa1075968a0222bb99b7ddd34e76badd17138f4d4cf836", - "name": "MAX- Sleepless days (SP ANOTHER 9)" + "name": "MAX- Sleepless days (SPA 9)" }, { "charts": { @@ -1624,7 +1624,7 @@ }, "game": "iidx-sp", "goalID": "Ga2c821d2bc0218c63c1de7556d79f914c8d296b6ef5632328c7df24edd80e919", - "name": "CLEAR The Sampling Paradise (SP ANOTHER 12)" + "name": "CLEAR The Sampling Paradise (SPA 12)" }, { "charts": { @@ -1638,7 +1638,7 @@ }, "game": "iidx-sp", "goalID": "G45715c8c7e532e64ec41f7ceaaf42a0bd276a64e13745d6eded8591945c8f59e", - "name": "CLEAR SOLID STATE SQUAD (SP ANOTHER 12)" + "name": "CLEAR SOLID STATE SQUAD (SPA 12)" }, { "charts": { @@ -1652,7 +1652,7 @@ }, "game": "iidx-sp", "goalID": "Gc5e4c977eec716e6f2cf92bb208929502eb605188fb9adc13b478ef8bef799d5", - "name": "CLEAR Rave Cannon (SP ANOTHER 12)" + "name": "CLEAR Rave Cannon (SPA 12)" }, { "charts": { @@ -1666,7 +1666,7 @@ }, "game": "iidx-sp", "goalID": "G71a51343fb25a4b60077469a1b8b1939d593d24275347e93ee35e55e0b1fa19e", - "name": "CLEAR Highcharge Divolt (SP ANOTHER 12)" + "name": "CLEAR Highcharge Divolt (SPA 12)" }, { "charts": { @@ -1680,7 +1680,7 @@ }, "game": "iidx-sp", "goalID": "G671c125820e6246d1515b829e0bd506276875b2302881d160cfee1cbc4aa1535", - "name": "HARD CLEAR Venom (SP ANOTHER 12)" + "name": "HARD CLEAR Venom (SPA 12)" }, { "charts": { @@ -1694,7 +1694,7 @@ }, "game": "iidx-sp", "goalID": "G5f0d127f77b874b52aa7128a4145cdcf60848060d9e34c4e53d5817be1fbac34", - "name": "CLEAR Broken (SP ANOTHER 12)" + "name": "CLEAR Broken (SPA 12)" }, { "charts": { @@ -1708,7 +1708,7 @@ }, "game": "iidx-sp", "goalID": "G0c9d9ddc9151a7a2de6ee9d4791b726e7e07c6335aa6b221361154e2d32232f2", - "name": "AAA 5.1.1. (SP ANOTHER 10)" + "name": "AAA 5.1.1. (SPA 10)" }, { "charts": { @@ -1722,7 +1722,7 @@ }, "game": "iidx-sp", "goalID": "G588a03c73a817d81d12f1bd9c821db297307a7023b0d26e82fcb50c3f6267268", - "name": "AAA chrono diver -fragment- (SP LEGGENDARIA 10)" + "name": "AAA chrono diver -fragment- (SPL 10)" }, { "charts": { @@ -1736,7 +1736,7 @@ }, "game": "iidx-sp", "goalID": "Gd0fe5e27e4b6b8dd21fe0ad0c7cbad8e8ab85cfe2cf56afdce097b6c8e55fa45", - "name": "AAA ¡Viva! (SP ANOTHER 10)" + "name": "AAA ¡Viva! (SPA 10)" }, { "charts": { @@ -1750,7 +1750,7 @@ }, "game": "iidx-sp", "goalID": "Ge2afddf4961e026768f837f709b6ff8abf3f486f413bbf453a953a2d10c6c32a", - "name": "AAA BRIDAL FESTIVAL !!! (SP ANOTHER 9)" + "name": "AAA BRIDAL FESTIVAL !!! (SPA 9)" }, { "charts": { @@ -1809,7 +1809,7 @@ }, "game": "iidx-sp", "goalID": "G68d50e70b43d15c50bed956b9f2eee654ffc56539fde0e78a449cf0e97c2d82a", - "name": "HARD CLEAR quasar (SP ANOTHER 11)" + "name": "HARD CLEAR quasar (SPA 11)" }, { "charts": { @@ -1823,7 +1823,7 @@ }, "game": "iidx-sp", "goalID": "G85390599746e8a28b01377bc0218da8d4d09182e75f9038a3c81d8a4dfc51825", - "name": "HARD CLEAR Digitank System (SP ANOTHER 11)" + "name": "HARD CLEAR Digitank System (SPA 11)" }, { "charts": { @@ -1837,7 +1837,7 @@ }, "game": "iidx-sp", "goalID": "G6a197069391c1a5a293368ac99851922cfd81e547a06543a0b495d048fc34c6c", - "name": "CLEAR SPECIAL SUMMER CAMPAIGN! (SP ANOTHER 12)" + "name": "CLEAR SPECIAL SUMMER CAMPAIGN! (SPA 12)" }, { "charts": { @@ -1851,7 +1851,7 @@ }, "game": "iidx-sp", "goalID": "G81c5f64e3a3975a22e245bf5a6e6ea8c3f6768b7968ed5fe9cb306f404e2179f", - "name": "CLEAR sakura storm (SP ANOTHER 12)" + "name": "CLEAR sakura storm (SPA 12)" }, { "charts": { @@ -1865,7 +1865,7 @@ }, "game": "iidx-sp", "goalID": "G1352ec19850dd23e87a53748a4e2d7997aa3af97ebc88a069f0f537967dba1b3", - "name": "CLEAR 千年ノ理 (SP ANOTHER 12)" + "name": "CLEAR 千年ノ理 (SPA 12)" }, { "charts": { @@ -1883,7 +1883,7 @@ }, "game": "iidx-sp", "goalID": "G53cbfef13ce80f8aeb82f7b109b8b19af99d3716dbabdd599a29e07a685308ba", - "name": "CLEAR any one of examination leave (SP ANOTHER 11), Macuilxochitl (SP ANOTHER 11) or 532nm (SP ANOTHER 11)" + "name": "CLEAR any one of examination leave (SPA 11), Macuilxochitl (SPA 11) or 532nm (SPA 11)" }, { "charts": { @@ -1897,7 +1897,7 @@ }, "game": "iidx-sp", "goalID": "G6b1529dcb0ab9e7aa26caaad5465f71e1c7431b9b6a79829992cf631b7a240c7", - "name": "HARD CLEAR SHADE (SP ANOTHER 11)" + "name": "HARD CLEAR SHADE (SPA 11)" }, { "charts": { @@ -1911,7 +1911,7 @@ }, "game": "iidx-sp", "goalID": "G21e5a271899be8db9bb6fafa1880e6e5a38f36f89896995bf08d25eb90f39da5", - "name": "AAA Element of SPADA (SP ANOTHER 9)" + "name": "AAA Element of SPADA (SPA 9)" }, { "charts": { @@ -1925,7 +1925,7 @@ }, "game": "iidx-sp", "goalID": "Gef307886c7b12f58c5e6c7ea3271cf84e1bf228b9372a94ba2250f62bcf4b729", - "name": "AAA the trigger of innocence (SP ANOTHER 9)" + "name": "AAA the trigger of innocence (SPA 9)" }, { "charts": { @@ -1939,7 +1939,7 @@ }, "game": "iidx-sp", "goalID": "G24d5634170274ac12e77d63609c26b4f5e002bd637594d3b678f64228541e082", - "name": "AAA Sweet Clapper (SP ANOTHER 10)" + "name": "AAA Sweet Clapper (SPA 10)" }, { "charts": { @@ -2139,10 +2139,10 @@ "countNum": 3, "key": "lamp", "mode": "absolute", - "value": 3 + "value": 4 }, "game": "sdvx", - "goalID": "G6f51d83a87ea6468d36357d2fbebad8248a1db01503d409f7bced22a17d21199", + "goalID": "G7e7b6ba4f55a4523c7b81435cd3230c651d9e7d4b9cd56dba261ff5188334dbe", "name": "ULTIMATE CHAIN 3 charts in the Level 13 (EXCEED GEAR) folder" }, { @@ -2191,7 +2191,7 @@ }, "game": "sdvx", "goalID": "Gcfebd2e376a3480b9e6c033f117469b765582902c36339422ad9a9898402ee1f", - "name": "AAA any one of ARROW RAIN (EXH 16), GAMBOL (dfk SLC rmx) (INF 16) or Juggler's Maddness (EXH 16)" + "name": "AAA any one of ARROW RAIN feat. ayame (EXH 16), GAMBOL (dfk SLC rmx) (INF 16) or Juggler's Maddness (EXH 16)" }, { "charts": { @@ -2288,7 +2288,7 @@ }, "game": "sdvx", "goalID": "G93ee06f15bec2deed7fb4c59221da49da778147972ebc070d3602f8beb78c13c", - "name": "CLEAR any one of D1g1t1ze b0dy (MXM 17), 逆さま♥シンデレラパレード (MXM 17), AXION (MXM 17), The Sampling Paradise (P*Light Remix) (EXH 17) or 黎明スケッチブック (MXM 17)" + "name": "CLEAR any one of D1g1t1ze b0dy (MXM 17), 逆さま♥シンデレラパレード (MXM 17.5), AXION (MXM 17), The Sampling Paradise (P*Light Remix) (EXH 17.5) or 黎明スケッチブック (MXM 17.5)" }, { "charts": { @@ -2299,10 +2299,10 @@ "countNum": 4, "key": "lamp", "mode": "absolute", - "value": 3 + "value": 4 }, "game": "sdvx", - "goalID": "G2753016668b125fa4c054f8c5044e59ebee545a1cb4aa50f9a95af5e2afd600d", + "goalID": "G5635cab6c361a3ae6445fb5838142ddda6f30317cded62dfdc658a70664b9f7a", "name": "ULTIMATE CHAIN 4 charts in the Level 14 (EXCEED GEAR) folder" }, { @@ -2352,7 +2352,7 @@ }, "game": "sdvx", "goalID": "G9f3ef95935d9c7f41602019787938467d1fce5294f7c91850fe3d5840ee13fce", - "name": "AAA any one of Glorious Journey (EXH 16), Jack-the-Ripper◆ (EXH 16), 最終鬼畜妹フランドール・S (EXH 16) or KHAMEN BREAK (EXH 16)" + "name": "AAA any one of Glorious Journey (EXH 16), Jack-the-Ripper◆ (EXH 17), 最終鬼畜妹フランドール・S (EXH 16) or KHAMEN BREAK (EXH 16)" }, { "charts": { @@ -2385,7 +2385,7 @@ }, "game": "sdvx", "goalID": "G9c1ef5e70be5684ec6d0267e0fc0c526ed9d095338d7d9832ba4fa225daf12d1", - "name": "AA+ any one of GERBERA-For Finalists- (EXH 17), JOMANDA (EXH 17) or Destroy (GRV 17)" + "name": "AA+ any one of GERBERA-For Finalists- (EXH 17.5), JOMANDA (EXH 17.5) or Destroy (GRV 17.5)" }, { "charts": { @@ -2449,7 +2449,7 @@ }, "game": "sdvx", "goalID": "G6888d8c1566ce138427dfdd51ff1e7ebe5a7272b1ebf5ef88c119e0565f2d69c", - "name": "CLEAR any one of Booths of Fighters (EXH 17), Nofram (EXH 17), ULTRA B+K (MXM 17), Innocent Azure (MXM 18) or 狂水一華 (MXM 18)" + "name": "CLEAR any one of Booths of Fighters (EXH 17.5), Nofram (EXH 17.5), ULTRA B+K (MXM 17.5), Innocent Azure (MXM 18) or 狂水一華 (MXM 18.2)" }, { "charts": { @@ -2460,10 +2460,10 @@ "countNum": 5, "key": "lamp", "mode": "absolute", - "value": 3 + "value": 4 }, "game": "sdvx", - "goalID": "G943e04101cb28a01426725ec45387a3a9de9bdbb999acaa0478f2e482c4da76b", + "goalID": "G2ea6029d229d5ef580d24830c6fd2fcc9707f1e964d2b581a8afa18ff79efd14", "name": "ULTIMATE CHAIN 5 charts in the Level 15 (EXCEED GEAR) folder" }, { @@ -2512,7 +2512,7 @@ }, "game": "sdvx", "goalID": "G247303e9e1b7a1c4863c6024ec6e9bba3e8da377cf8ea44b925b296c98594d7e", - "name": "AAA any one of CENSORED!! (EXH 17), UROBØROS (EXH 17) or Into The Madness (MXM 17)" + "name": "AAA any one of CENSORED!! (EXH 17.5), UROBØROS (EXH 17) or Into The Madness (MXM 17.5)" }, { "charts": { @@ -2545,7 +2545,7 @@ }, "game": "sdvx", "goalID": "G56caf3712a9a9805e1ee8eb7fe86673b64314baad10bbfc0e8c86b679051b444", - "name": "AA+ any one of She Turns Me On (GRV 18), Enchanté (MXM 18) or KIMIDORI Streak!! (MXM 18)" + "name": "AA+ any one of She Turns Me On (GRV 17.5), Enchanté (MXM 18.1) or KIMIDORI Streak!! (MXM 18)" }, { "charts": { @@ -2616,10 +2616,10 @@ "countNum": 5, "key": "lamp", "mode": "absolute", - "value": 3 + "value": 4 }, "game": "sdvx", - "goalID": "G780126a57153930857d9bb1e3a8aa071f60fe58bf3dbf11a49033b0f249fb483", + "goalID": "G8cd12b810909fcfd23c92f24fec287b6c6d6a9a863d8e7ab55879f28ba128f88", "name": "ULTIMATE CHAIN 5 charts in the Level 16 (EXCEED GEAR) folder" }, { @@ -2653,7 +2653,7 @@ }, "game": "sdvx", "goalID": "G130f6090577b551ba3a65bb2da44e5807d407c41f9dac35aa4ed259e0eb21d8f", - "name": "AAA any one of HE4VEN ~天国へようこそ~ (EXH 17), TOXIC VIBRATION (EXH 17) or False Cross (EXH 17)" + "name": "AAA any one of HE4VEN ~天国へようこそ~ (EXH 17.5), TOXIC VIBRATION (EXH 17.5) or False Cross (EXH 17.5)" }, { "charts": { @@ -2686,7 +2686,7 @@ }, "game": "sdvx", "goalID": "Gf1dbcfa03d5e4496dea64d2093673b70762d6d66a3c5cc19cb329bd34d89841e", - "name": "AA+ any one of XROSS THE XOUL (GRV 18), Re:End of a Dream (MXM 18) or ΩVERSOUL (MXM 18)" + "name": "AA+ any one of XROSS THE XOUL (GRV 18.4), Re:End of a Dream (MXM 18.7) or ΩVERSOUL (MXM 18.8)" }, { "charts": { @@ -2768,7 +2768,7 @@ }, "game": "sdvx", "goalID": "Ge06603c39c07860b82c2d49cfe4303c386e8bd88cfafaf991932963112fbdf39", - "name": "CLEAR any one of НУМЛ (MXM 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18), MeteorA (MXM 18) or SuperMiracleEnsemble (MXM 18)" + "name": "CLEAR any one of НУМЛ (MXM 18.8), ムーニャポヨポヨスッポコニャーゴ (VVD 18.8), MeteorA (MXM 18.6) or SuperMiracleEnsemble (MXM 18.7)" }, { "charts": { @@ -2779,10 +2779,10 @@ "countNum": 15, "key": "lamp", "mode": "absolute", - "value": 3 + "value": 4 }, "game": "sdvx", - "goalID": "G3bf0295c50cf8431f0d2c6aa13595d7dc4dcdf0738ed4288453a9047c3f1fe86", + "goalID": "G0702e660dd7161ff60eddae0225767a80c2ac64e84807e499136629bd838b363", "name": "ULTIMATE CHAIN 15 charts in the Level 16 (EXCEED GEAR) folder" }, { @@ -2816,7 +2816,7 @@ }, "game": "sdvx", "goalID": "G044dccb6b25fc04451e9100bbfaa3b634dd3556c161f7b4a6c1da40d502913d6", - "name": "AAA+ any one of Poison AND÷OR Affection (MXM 17), DIABLOSIS::Nāga (EXH 17) or MilK (MXM 17)" + "name": "AAA+ any one of Poison AND÷OR Affection (MXM 17.5), DIABLOSIS::Nāga (EXH 17.5) or MilK (MXM 17.5)" }, { "charts": { @@ -2864,7 +2864,7 @@ }, "game": "sdvx", "goalID": "G1bd1da343629dd0fa15bcfe24cc5cc0e67f0d67b255c9528b0428e47be9813ad", - "name": "AAA any one of REVOLVER (EXH 18), Demise Quartet (EXH 18) or Wish upon Twin Stars (GRV 18)" + "name": "AAA any one of REVOLVER (EXH 18.5), Demise Quartet (EXH 18.5) or Wish upon Twin Stars (GRV 18.7)" }, { "charts": { @@ -2882,7 +2882,7 @@ }, "game": "sdvx", "goalID": "G0515f7a6c498cc4ff2712a4178010910de0454c135b1fd0ea9710a96b019966a", - "name": "AA+ any one of Idola (HVN 18), Xevel (MXM 18) or Venomous Firefly (MXM 18)" + "name": "AA+ any one of Idola (HVN 18.7), Xevel (MXM 18.7) or Venomous Firefly (MXM 18.9)" }, { "charts": { @@ -2951,10 +2951,10 @@ "countNum": 10, "key": "lamp", "mode": "absolute", - "value": 3 + "value": 4 }, "game": "sdvx", - "goalID": "G90bcbf690a855cc9be365f22c9fcfe47e1e73b158a2cc2ea228e14452825f354", + "goalID": "G0c1d3396aa5bfe920b3dd7b08be96be9a8681933d7ce6d7649c3e288735ba4ba", "name": "ULTIMATE CHAIN 10 charts in the Level 17 (EXCEED GEAR) folder" }, { @@ -3018,7 +3018,7 @@ }, "game": "sdvx", "goalID": "G259ab31e08b751f67b3fc496de2c4ae262274962ec5589e2e4a2880b70d96857", - "name": "AAA any one of ANGER of the GOD (MXM 19), A Lasting Promise (MXM 19) or Elemental Creation (MXM 19)" + "name": "AAA any one of ANGER of the GOD (MXM 19), A Lasting Promise (MXM 19.4) or Elemental Creation (MXM 19.1)" }, { "charts": { @@ -3037,7 +3037,7 @@ }, "game": "sdvx", "goalID": "G899d13fe5225f7fca4cc5a3ad77afe02ef7f03a874d3ee2ac5a1a177b9240acf", - "name": "AA+ any one of OUTERHEΛVEN (MXM 19), For UltraPlayers (HVN 19), Bangin' Burst (GRV 19) or Dyscontrolled Galaxy (MXM 19)" + "name": "AA+ any one of OUTERHEΛVEN (MXM 19.5), For UltraPlayers (HVN 19.7), Bangin' Burst (GRV 19.5) or Dyscontrolled Galaxy (MXM 19.8)" }, { "charts": { @@ -3073,7 +3073,7 @@ }, "game": "sdvx", "goalID": "G1f9940b18b32059b73851cfd2d813ed952f6ae55a917fba2f2318091d5323df9", - "name": "CLEAR KAC 2012 ULTIMATE MEDLEY -HISTORIA SOUND VOLTEX- (GRV 18), Unleashed Redness (MXM 18), Verflucht (MXM 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18) and Joyeuse (VVD 18)" + "name": "CLEAR KAC 2012 ULTIMATE MEDLEY -HISTORIA SOUND VOLTEX- (GRV 18.5), Unleashed Redness (MXM 18.6), Verflucht (MXM 18.9), ムーニャポヨポヨスッポコニャーゴ (VVD 18.8) and Joyeuse (VVD 19.3)" }, { "charts": { @@ -3120,7 +3120,7 @@ }, "game": "sdvx", "goalID": "G9957c837ce04f4494df641b057cfc3161fdc8a4ec9898dc136548f46f75bc41e", - "name": "CLEAR either Xronièr (MXM 20) or I (MXM 20)" + "name": "CLEAR either Xronièr (MXM 20.1) or I (MXM 20.2)" }, { "charts": { @@ -3131,10 +3131,10 @@ "countNum": 5, "key": "lamp", "mode": "absolute", - "value": 3 + "value": 4 }, "game": "sdvx", - "goalID": "G0f88e481ccc93429f62c7124103bb0d29c80eaf46265cb42ebf873fc1cded1ef", + "goalID": "Gd3911a7aeeba1bfef571d1113c1a01d046b6f95b42c6acd2e68d9f9e5a4483c2", "name": "ULTIMATE CHAIN 5 charts in the Level 18 (EXCEED GEAR) folder" }, { @@ -3168,7 +3168,7 @@ }, "game": "sdvx", "goalID": "G13a74d151e58d9e4edea8c247804cacc51d435c831e355408ecfe4aef8131942", - "name": "AAA+ any one of Juggler's Maddness (MXM 18), ARROW RAIN (MXM 18) or BEAST BASS BOMB (MXM 18)" + "name": "AAA+ any one of Juggler's Maddness (MXM 18.5), ARROW RAIN feat. ayame (MXM 18.6) or BEAST BASS BOMB (MXM 18.3)" }, { "charts": { @@ -3188,7 +3188,7 @@ }, "game": "sdvx", "goalID": "G6b92ca680c48f3df1ccf2772438f75abbcc1de394ec6c2ddd6e5fd7727cfef63", - "name": "AAA any 2 of V (MXM 18), Macuilxochitl (Latin Jazz Mix) (MXM 18), pique (MXM 18) or †渚の小悪魔ラヴリィ~レイディオ† (MXM 18)" + "name": "AAA any 2 of V (MXM 18.6), Macuilxochitl (Latin Jazz Mix) (MXM 18.7), pique (MXM 18.7) or †渚の小悪魔ラヴリィ~レイディオ† (MXM 18.4)" }, { "charts": { @@ -3239,7 +3239,7 @@ }, "game": "sdvx", "goalID": "G8bbc3e020dba18d3d294f2a51f722e1460b047324a80b15c44db4eb81de65729", - "name": "EXCESSIVE CLEAR any 4 of * Erm, could it be a Spatiotemporal ShockWAVE Syndrome...? (EXH 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18), croiX (INF 18), 神話に芽吹く (MXM 18) or 準備運動 (MXM 18)" + "name": "EXCESSIVE CLEAR any 4 of * Erm, could it be a Spatiotemporal ShockWAVE Syndrome...? (EXH 18.9), ムーニャポヨポヨスッポコニャーゴ (VVD 18.8), croiX (INF 18.6), 神話に芽吹く (MXM 18.9) or 準備運動 (MXM 18.8)" }, { "charts": { @@ -3257,7 +3257,7 @@ }, "game": "sdvx", "goalID": "G5882d96f0ef38eafb99037fdfc4e61a1fcc0038d5c19bef289f5ab055163f83d", - "name": "EXCESSIVE CLEAR any one of 色を喪った街 (MXM 19), Bangin' Burst (GRV 19) or Awakening (MXM 19)" + "name": "EXCESSIVE CLEAR any one of 色を喪った街 (MXM 19.7), Bangin' Burst (GRV 19.5) or Awakening (MXM 19.6)" }, { "charts": { @@ -3298,10 +3298,10 @@ "countNum": 25, "key": "lamp", "mode": "absolute", - "value": 3 + "value": 4 }, "game": "sdvx", - "goalID": "G5234d7e017a9d520b2714f84bd66ff9ac28e04573e4afff04249bcef6ed7c0ed", + "goalID": "G21ca042b1cbaf0c525a154e6595257338db110772dfc0c654abdd3f05a198673", "name": "ULTIMATE CHAIN 25 charts in the Level 18 (EXCEED GEAR) folder" }, { @@ -3350,7 +3350,7 @@ }, "game": "sdvx", "goalID": "G7d14143c6d71c5a9412d4ee87816eba1b926e1f9e282d482cf4c8f8c1bdaebb0", - "name": "AAA+ any one of VALKYRIE ASSAULT (MXM 19), セイレーン ~悲壮の竪琴~ (MXM 19) or TENKAICHI ULTIMATE BOSSRUSH MEDLEY (MXM 19)" + "name": "AAA+ any one of VALKYRIE ASSAULT (MXM 19.2), セイレーン ~悲壮の竪琴~ (MXM 19.4) or TENKAICHI ULTIMATE BOSSRUSH MEDLEY (MXM 19.5)" }, { "charts": { @@ -3368,7 +3368,7 @@ }, "game": "sdvx", "goalID": "G04fe718f0483fb71361bb3bd82d42674b25ca069823831c8907f52f7a68b88c0", - "name": "AAA any one of FLügeL《Λrp:ΣggyØ》 (GRV 19), Failnaught (MXM 19) or 飄える翼追い掛けて (MXM 19)" + "name": "AAA any one of FLügeL《Λrp:ΣggyØ》 (GRV 19.7), Failnaught (MXM 19.4) or 飄える翼追い掛けて (MXM 19.5)" }, { "charts": { @@ -3403,7 +3403,7 @@ }, "game": "sdvx", "goalID": "G54b92c7e85b819464c7bb21b9c1665fc745c13f289ebe2946ea36bc6f0e074ac", - "name": "EXCESSIVE CLEAR Joyeuse (VVD 18), ツマミ戦隊 タテレンジャー (MXM 18), 超越してしまった彼女と其を生み落した理由 (HVN 18) and Re:Elemental Creation (MXM 18)" + "name": "EXCESSIVE CLEAR Joyeuse (VVD 19.3), ツマミ戦隊 タテレンジャー (MXM 18.8), 超越してしまった彼女と其を生み落した理由 (HVN 18.8) and Re:Elemental Creation (MXM 18.6)" }, { "charts": { @@ -3436,7 +3436,7 @@ }, "game": "sdvx", "goalID": "G1fb1cc0ee9612ead59f8792d4fe28ba527af5fecdea0b2198f9c7ac2c4d2abd4", - "name": "EXCESSIVE CLEAR any one of Xroniàl Xéro (MXM 19), 777 (MXM 19) or Ghost Family Living In Graveyard (MXM 19)" + "name": "EXCESSIVE CLEAR any one of Xroniàl Xéro (MXM 19.8), 777 (MXM 19.3) or Ghost Family Living In Graveyard (MXM 19.5)" }, { "charts": { diff --git a/db/seeds/quests.json b/db/seeds/quests.json index c710f6a81..56040ce4a 100644 --- a/db/seeds/quests.json +++ b/db/seeds/quests.json @@ -878,7 +878,7 @@ "desc": "", "goals": [ { - "goalID": "G6f51d83a87ea6468d36357d2fbebad8248a1db01503d409f7bced22a17d21199", + "goalID": "G7e7b6ba4f55a4523c7b81435cd3230c651d9e7d4b9cd56dba261ff5188334dbe", "note": "The first UC goal. Don't hesitate to try new 13s - don't get stuck on the ones you can't do now." }, { @@ -934,7 +934,7 @@ "desc": "", "goals": [ { - "goalID": "G2753016668b125fa4c054f8c5044e59ebee545a1cb4aa50f9a95af5e2afd600d" + "goalID": "G5635cab6c361a3ae6445fb5838142ddda6f30317cded62dfdc658a70664b9f7a" }, { "goalID": "Ga70c91a1d0c0240cfb3c0b3628b4b00e4250208335e09d2efd04cd137325d789" @@ -989,7 +989,7 @@ "desc": "", "goals": [ { - "goalID": "G943e04101cb28a01426725ec45387a3a9de9bdbb999acaa0478f2e482c4da76b" + "goalID": "G2ea6029d229d5ef580d24830c6fd2fcc9707f1e964d2b581a8afa18ff79efd14" }, { "goalID": "Ge8bef7d934fe71987f467f45f1c7d335062cc66470775c67f85fde23e8eea88a" @@ -1045,7 +1045,7 @@ "desc": "", "goals": [ { - "goalID": "G780126a57153930857d9bb1e3a8aa071f60fe58bf3dbf11a49033b0f249fb483" + "goalID": "G8cd12b810909fcfd23c92f24fec287b6c6d6a9a863d8e7ab55879f28ba128f88" }, { "goalID": "G475ca03a09c5035de96d162dbae307e016cb16f14d7215817b5fbf613c88ae35" @@ -1105,7 +1105,7 @@ "desc": "", "goals": [ { - "goalID": "G3bf0295c50cf8431f0d2c6aa13595d7dc4dcdf0738ed4288453a9047c3f1fe86" + "goalID": "G0702e660dd7161ff60eddae0225767a80c2ac64e84807e499136629bd838b363" }, { "goalID": "G475ca03a09c5035de96d162dbae307e016cb16f14d7215817b5fbf613c88ae35", @@ -1165,7 +1165,7 @@ "desc": "", "goals": [ { - "goalID": "G90bcbf690a855cc9be365f22c9fcfe47e1e73b158a2cc2ea228e14452825f354" + "goalID": "G0c1d3396aa5bfe920b3dd7b08be96be9a8681933d7ce6d7649c3e288735ba4ba" }, { "goalID": "G6358c8004675bb53ae6bde30a8cd93735aa8fd123438cd93256d70fbd6f29cc6", @@ -1222,7 +1222,7 @@ "desc": "", "goals": [ { - "goalID": "G0f88e481ccc93429f62c7124103bb0d29c80eaf46265cb42ebf873fc1cded1ef" + "goalID": "Gd3911a7aeeba1bfef571d1113c1a01d046b6f95b42c6acd2e68d9f9e5a4483c2" }, { "goalID": "Gba598c9c26a325bb63a54c9fb6be633ef19f4c23e13f569204d3d586a4b6670e", @@ -1277,7 +1277,7 @@ "desc": "", "goals": [ { - "goalID": "G5234d7e017a9d520b2714f84bd66ff9ac28e04573e4afff04249bcef6ed7c0ed" + "goalID": "G21ca042b1cbaf0c525a154e6595257338db110772dfc0c654abdd3f05a198673" }, { "goalID": "G10b47f5c3084d79bc92d45e616dcfb7dc3a7040aa3155fd49397352be6f5b6fa", diff --git a/docs/src/game-support/games/sdvx-Single.md b/docs/src/game-support/games/sdvx-Single.md index 089ee03c3..8485d5182 100644 --- a/docs/src/game-support/games/sdvx-Single.md +++ b/docs/src/game-support/games/sdvx-Single.md @@ -14,7 +14,7 @@ For more information on what metrics are and how they work, see [TODO]! | Metric Name | Type | Description | | :: | :: | :: | | `score` | Integer | The score value. This is between 0 and 10 million. | -| `lamp` | "FAILED", "CLEAR", "EXCESSIVE CLEAR", "ULTIMATE CHAIN", "PERFECT ULTIMATE CHAIN" | The type of clear this was. | +| `lamp` | "FAILED", "CLEAR", "EXCESSIVE CLEAR", "MAXXIVE CLEAR", "ULTIMATE CHAIN", "PERFECT ULTIMATE CHAIN" | The type of clear this was. | ### Derived Metrics diff --git a/typescript/seeds-scripts/lib/README.md b/typescript/seeds-scripts/lib/README.md new file mode 100644 index 000000000..f465d8eb5 --- /dev/null +++ b/typescript/seeds-scripts/lib/README.md @@ -0,0 +1,4 @@ +Common seeds functionality will end up here at some point +like MutateCollection/ReadCollection whatever. + +Not done yet though \ No newline at end of file diff --git a/typescript/seeds-scripts/lib/goals/goal-title-from-seeds.ts b/typescript/seeds-scripts/lib/goals/goal-title-from-seeds.ts new file mode 100644 index 000000000..bc8aead3b --- /dev/null +++ b/typescript/seeds-scripts/lib/goals/goal-title-from-seeds.ts @@ -0,0 +1,234 @@ +import { + ALL_GAMES, + allSupportedGameGroups, + type ChartDocument, + FormatChart, + GetGameConfig, + GetScoreMetricConf, + type GoalDocument, + type V3Game, +} from "tachi-common"; + +import { ReadCollection } from "../../util"; + +// temporary bullshit copypasted from tachi-server +// todo: move to tachi common + +function humanisedJoinArray(arr: Array, lastJoiner = "or") { + if (arr.length === 1) { + return arr[0]!; + } + + return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`; +} + +function onlyFloatToDP(num: number, points = 2) { + if (Number.isInteger(num)) { + return num.toFixed(0); + } + + return num.toFixed(points); +} + +function goalFmtPercent(val: number, dp = 2) { + return `Get ${val.toFixed(dp)}% on`; +} + +function goalFmtScore(val: number) { + return `Get a score of ${val.toLocaleString("en-GB")} on`; +} + +const GOAL_CRITERIA_FORMATTERS: Partial< + Record string>>> +> = { + "iidx-sp": { + percent: goalFmtPercent, + score: (v) => `Get a score of ${v} on`, + }, + sdvx: { + score: goalFmtScore, + }, +}; + +export interface GoalTitleContext { + chartsById: Map; + foldersById: Map; +} + +export function buildGoalTitleContext(): GoalTitleContext { + const songsById = new Map>(); + + for (const gameGroup of allSupportedGameGroups) { + for (const song of ReadCollection(`songs-${gameGroup}.json`)) { + songsById.set(song.id, song); + } + } + + const chartsById = new Map(); + + for (const game of ALL_GAMES) { + for (const chart of ReadCollection(`charts-${game}.json`)) { + const song = songsById.get(chart.songID); + + if (!song) { + continue; + } + + chartsById.set(chart.id, { + ...chart, + chartID: chart.id, + game, + song, + } as ChartDocument); + } + } + + const foldersById = new Map(); + + for (const folder of ReadCollection("folders.json")) { + foldersById.set(folder.id, folder); + } + + return { chartsById, foldersById }; +} + +function formatCriteria(criteria: GoalDocument["criteria"], game: V3Game) { + const gameConfig = GetGameConfig(game); + const conf = GetScoreMetricConf(gameConfig, criteria.key); + + if (!conf) { + throw new Error(`Invalid goal criteria with key ${criteria.key}. No config exists?`); + } + + if (conf.type === "ENUM") { + const fmt = GOAL_CRITERIA_FORMATTERS[game]?.[criteria.key]; + const v = conf.values[criteria.value]; + + if (v === undefined) { + throw new Error(`Invalid criteria value '${criteria.value}'.`); + } + + return fmt ? fmt(criteria.value) : v; + } + + if (conf.type === "DECIMAL" || conf.type === "INTEGER") { + const fmt = GOAL_CRITERIA_FORMATTERS[game]?.[criteria.key]; + + if (!fmt) { + throw new Error(`No formatter defined for ${criteria.key}, yet one must exist?`); + } + + return fmt(criteria.value); + } + + throw new Error(`Cannot set a goal for ${criteria.key} as it is of type ${conf.type}.`); +} + +function humaniseChartID(chartID: string, ctx: GoalTitleContext) { + const chart = ctx.chartsById.get(chartID); + + if (!chart) { + throw new Error(`Chart ${chartID} not found in seeds.`); + } + + return FormatChart(chart); +} + +function formatCharts( + charts: GoalDocument["charts"], + criteria: GoalDocument["criteria"], + ctx: GoalTitleContext, +) { + switch (charts.type) { + case "single": + return humaniseChartID(charts.data, ctx); + case "multi": { + const formattedTitles = charts.data.map((chartID) => humaniseChartID(chartID, ctx)); + + if (criteria.mode === "absolute" && criteria.countNum === charts.data.length) { + return humanisedJoinArray(formattedTitles, "and"); + } + + return humanisedJoinArray(formattedTitles); + } + case "folder": { + const folder = ctx.foldersById.get(charts.data); + + if (!folder) { + throw new Error(`Folder ${charts.data} not found in seeds.`); + } + + return `the ${folder.title} folder`; + } + default: + throw new Error( + `Invalid goal charts.type -- got ${(charts as GoalDocument["charts"]).type}, which we don't support?`, + ); + } +} + +/** + * Mirrors server `CreateGoalTitle`, but resolves chart and folder names from seed JSON. + */ +export function createGoalTitleFromSeeds( + charts: GoalDocument["charts"], + criteria: GoalDocument["criteria"], + game: V3Game, + ctx: GoalTitleContext, +) { + const formattedCriteria = formatCriteria(criteria, game); + const datasetName = formatCharts(charts, criteria, ctx); + + switch (criteria.mode) { + case "single": + switch (charts.type) { + case "single": + return `${formattedCriteria} ${datasetName}`; + case "multi": { + if (charts.data.length === 2) { + return `${formattedCriteria} either ${datasetName}`; + } + + return `${formattedCriteria} any one of ${datasetName}`; + } + case "folder": + return `${formattedCriteria} any chart in ${datasetName}`; + } + break; + + case "absolute": + switch (charts.type) { + case "multi": { + if (criteria.countNum === charts.data.length) { + return `${formattedCriteria} ${datasetName}`; + } + + return `${formattedCriteria} any ${criteria.countNum} of ${datasetName}`; + } + case "folder": + return `${formattedCriteria} ${criteria.countNum} charts in ${datasetName}`; + case "single": + throw new Error( + `Invalid goal -- absolute mode cannot be paired with a charts.type of 'single'.`, + ); + } + break; + + case "proportion": { + const propFormat = onlyFloatToDP(criteria.countNum * 100); + + switch (charts.type) { + case "multi": + return `${formattedCriteria} ${propFormat}% of ${datasetName}`; + case "folder": + return `${formattedCriteria} ${propFormat}% of the charts in ${datasetName}`; + case "single": + throw new Error( + `Invalid goal -- proportion mode cannot be paired with a charts.type of 'single'.`, + ); + } + } + } + + throw new Error(`Unable to format goal title for mode ${criteria.mode}.`); +} diff --git a/typescript/seeds-scripts/rerunners/fix-goals.ts b/typescript/seeds-scripts/rerunners/fix-goals.ts new file mode 100644 index 000000000..5532968bb --- /dev/null +++ b/typescript/seeds-scripts/rerunners/fix-goals.ts @@ -0,0 +1,84 @@ +/** + * Re-derive goalID and name for every row in goals.json from charts, criteria, and game. + * + * Run: bun typescript/seeds-scripts/rerunners/fix-goals.ts + */ + +import { type V3Game } from "tachi-common"; + +import { + buildGoalTitleContext, + createGoalTitleFromSeeds, +} from "../lib/goals/goal-title-from-seeds.ts"; +import { log as logger } from "../log.ts"; +import { CreateGoalID, MutateCollection, ReadCollection, WriteCollection } from "../util.js"; + +const translateMap = new Map(); +const origGoals = ReadCollection("goals.json", true); +const ctx = buildGoalTitleContext(); + +MutateCollection("goals.json", (goals) => { + logger.info("Re-deriving goalID and name for all goals."); + + let idUpdates = 0; + let nameUpdates = 0; + + for (const goal of goals) { + const oldGoalID = goal.goalID; + const oldName = goal.name; + + const newGoalID = CreateGoalID(goal.charts, goal.criteria, goal.game); + const newName = createGoalTitleFromSeeds( + goal.charts, + goal.criteria, + goal.game as V3Game, + ctx, + ); + + if (newGoalID !== oldGoalID) { + translateMap.set(oldGoalID, newGoalID); + goal.goalID = newGoalID; + idUpdates++; + logger.info(` goalID ${oldGoalID} -> ${newGoalID}`); + } + + if (newName !== oldName) { + goal.name = newName; + nameUpdates++; + logger.info(` name "${oldName}" -> "${newName}"`); + } + } + + logger.info(`Updated ${idUpdates} goalID(s) and ${nameUpdates} name(s).`); + + return goals; +}); + +if (translateMap.size > 0) { + try { + MutateCollection("quests.json", (quests) => { + logger.info("Updating quest goalID references."); + + let patched = 0; + + for (const quest of quests) { + for (const qd of quest.questData) { + for (const goal of qd.goals) { + if (translateMap.has(goal.goalID)) { + goal.goalID = translateMap.get(goal.goalID)!; + patched++; + } + } + } + } + + logger.info(`Patched ${patched} quest goal reference(s).`); + + return quests; + }); + } catch (err) { + logger.error("Failed to update quests.json, reverting goals.json.", { err }); + WriteCollection("goals.json", origGoals); + throw err; + } +} diff --git a/typescript/seeds-scripts/test/goal-id.test.ts b/typescript/seeds-scripts/test/goal-id.test.ts deleted file mode 100644 index dd927cf5c..000000000 --- a/typescript/seeds-scripts/test/goal-id.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -// disabled for now -console.log("Disabled for now"); -process.exit(0); diff --git a/typescript/seeds-scripts/test/goals.test.ts b/typescript/seeds-scripts/test/goals.test.ts new file mode 100644 index 000000000..e47b2b12e --- /dev/null +++ b/typescript/seeds-scripts/test/goals.test.ts @@ -0,0 +1,79 @@ +import type { GoalDocument, V3Game } from "tachi-common"; + +import chalk from "chalk"; + +import { + buildGoalTitleContext, + createGoalTitleFromSeeds, +} from "../lib/goals/goal-title-from-seeds"; +import { CreateGoalID, ReadCollection } from "../util"; + +type SeedGoal = { goalID: string; name: string } & GoalDocument; + +let exitCode = 0; +let idMismatches = 0; +let nameMismatches = 0; +let checked = 0; + +const ctx = buildGoalTitleContext(); +const goals = ReadCollection("goals.json") as Array; + +for (const goal of goals) { + checked++; + + const expectedGoalID = CreateGoalID(goal.charts, goal.criteria, goal.game); + + if (goal.goalID !== expectedGoalID) { + idMismatches++; + console.error( + chalk.red(`[ERR] goals.json | ${goal.goalID} | goalID mismatch for "${goal.name}".`), + ); + console.error(chalk.red(` expected: ${expectedGoalID}`)); + console.error(chalk.red(` actual: ${goal.goalID}`)); + } + + let expectedName: string; + + try { + expectedName = createGoalTitleFromSeeds( + goal.charts, + goal.criteria, + goal.game as V3Game, + ctx, + ); + } catch (err) { + nameMismatches++; + console.error( + chalk.red( + `[ERR] goals.json | ${goal.goalID} | failed to derive name for "${goal.name}".`, + ), + ); + console.error(chalk.red(` ${err instanceof Error ? err.message : String(err)}`)); + continue; + } + + if (goal.name !== expectedName) { + nameMismatches++; + console.error(chalk.red(`[ERR] goals.json | ${goal.goalID} | name mismatch.`)); + console.error(chalk.red(` expected: ${expectedName}`)); + console.error(chalk.red(` actual: ${goal.name}`)); + } +} + +const failures = idMismatches + nameMismatches; + +if (failures > 0) { + console.error( + chalk.red( + `[FAILED] goals.json. Checked ${checked} goals. ` + + `${idMismatches} goalID mismatch(es), ${nameMismatches} name mismatch(es).`, + ), + ); + exitCode = 1; +} else { + console.log( + chalk.green(`[GOOD] goals.json. All ${checked} goals have correct goalID and name.`), + ); +} + +process.exit(exitCode); diff --git a/typescript/server/src/lib/targets/goal-pb-queries.test.ts b/typescript/server/src/lib/targets/goal-pb-queries.test.ts new file mode 100644 index 000000000..77d54bde4 --- /dev/null +++ b/typescript/server/src/lib/targets/goal-pb-queries.test.ts @@ -0,0 +1,39 @@ +import { GetGameConfig, GetScoreMetricConf, type PBScoreDocument, SDVX_LAMPS } from "tachi-common"; +import { describe, expect, it } from "vitest"; + +import { pbMeetsGoalThreshold } from "./goal-pb-queries"; + +const sdvxLampConf = GetScoreMetricConf(GetGameConfig("sdvx"), "lamp")!; + +function sdvxPb(lamp: number): PBScoreDocument { + return { + scoreData: { + enumIndexes: { lamp }, + }, + } as unknown as PBScoreDocument; +} + +describe("pbMeetsGoalThreshold (SDVX lamp / MAXXIVE index shift)", () => { + it("maps MAXXIVE to index 3 and UC to index 4", () => { + expect(SDVX_LAMPS.MAXXIVE_CLEAR).toBe(3); + expect(SDVX_LAMPS.ULTIMATE_CHAIN).toBe(4); + }); + + it("threshold 3 accepts MAXXIVE but not EXCESSIVE CLEAR", () => { + expect( + pbMeetsGoalThreshold(sdvxPb(SDVX_LAMPS.MAXXIVE_CLEAR), "lamp", 3, sdvxLampConf), + ).toBe(true); + expect( + pbMeetsGoalThreshold(sdvxPb(SDVX_LAMPS.EXCESSIVE_CLEAR), "lamp", 3, sdvxLampConf), + ).toBe(false); + }); + + it("threshold 4 accepts UC but not MAXXIVE (fixed UC quest goals)", () => { + expect( + pbMeetsGoalThreshold(sdvxPb(SDVX_LAMPS.ULTIMATE_CHAIN), "lamp", 4, sdvxLampConf), + ).toBe(true); + expect( + pbMeetsGoalThreshold(sdvxPb(SDVX_LAMPS.MAXXIVE_CLEAR), "lamp", 4, sdvxLampConf), + ).toBe(false); + }); +}); diff --git a/typescript/server/src/lib/targets/goals.ts b/typescript/server/src/lib/targets/goals.ts index 7187e6157..c70d67531 100644 --- a/typescript/server/src/lib/targets/goals.ts +++ b/typescript/server/src/lib/targets/goals.ts @@ -452,6 +452,63 @@ export async function GetQuestsThatContainGoal(goalID: string): Promise { + it("renames drifted goal ids and cascades goal_sub references", async () => { + const chartId = await seedMinimalIidxSpChart(); + const { id: userId } = await seedUser(); + + const charts = { type: "single" as const, data: chartId }; + const criteria = { mode: "single" as const, value: 5, key: "lamp" as const }; + const game = "iidx-sp" as const; + const canonicalId = CreateGoalID(charts, criteria, game); + const driftedId = "G_wrong_goal_id_drifted_for_reconcile_test"; + + expect(canonicalId).not.toBe(driftedId); + + await DB.insertInto("goal") + .values({ + id: driftedId, + game, + name: "HARD CLEAR test chart", + charts, + criteria, + }) + .execute(); + + await DB.insertInto("goal_sub") + .values({ + goal_id: driftedId, + user_id: userId, + last_interaction: null, + progress: 0, + progress_human: "0", + out_of: 5, + out_of_human: "HARD CLEAR", + achieved: false, + time_achieved: null, + was_instantly_achieved: false, + was_assigned_standalone: true, + }) + .execute(); + + const { renamed } = await reconcileGoalIds({ dryRun: false }); + + expect(renamed).toBe(1); + + const goalRow = await DB.selectFrom("goal") + .select(["goal.id", "goal.name"]) + .where("goal.id", "=", canonicalId) + .executeTakeFirst(); + + expect(goalRow).toBeDefined(); + expect(goalRow?.name).toBe(await CreateGoalTitle(charts, criteria, game)); + + const staleGoal = await DB.selectFrom("goal") + .select("goal.id") + .where("goal.id", "=", driftedId) + .executeTakeFirst(); + + expect(staleGoal).toBeUndefined(); + + const subRow = await DB.selectFrom("goal_sub") + .select("goal_sub.goal_id") + .where("goal_sub.user_id", "=", userId) + .executeTakeFirst(); + + expect(subRow?.goal_id).toBe(canonicalId); + }); + + it("updates stale goal names when the id is already canonical", async () => { + const chartId = await seedMinimalIidxSpChart(); + + const charts = { type: "single" as const, data: chartId }; + const criteria = { mode: "single" as const, value: 5, key: "lamp" as const }; + const game = "iidx-sp" as const; + const goalID = CreateGoalID(charts, criteria, game); + const expectedName = await CreateGoalTitle(charts, criteria, game); + + await DB.insertInto("goal") + .values({ + id: goalID, + game, + name: "stale human-readable title", + charts, + criteria, + }) + .execute(); + + const { namesUpdated, renamed } = await reconcileGoalIds({ dryRun: false }); + + expect(renamed).toBe(0); + expect(namesUpdated).toBe(1); + + const goalRow = await DB.selectFrom("goal") + .select("goal.name") + .where("goal.id", "=", goalID) + .executeTakeFirst(); + + expect(goalRow?.name).toBe(expectedName); + }); +}); diff --git a/typescript/server/src/scripts/reconcile-goal-ids.ts b/typescript/server/src/scripts/reconcile-goal-ids.ts new file mode 100644 index 000000000..2f9e4ceee --- /dev/null +++ b/typescript/server/src/scripts/reconcile-goal-ids.ts @@ -0,0 +1,233 @@ +/** + * Reconcile goal rows with their canonical id and display name. + * + * Goal ids are `CreateGoalID(charts, criteria, game)`. Names come from + * `CreateGoalTitle(charts, criteria, game)`. When either drifts (e.g. after a + * criteria fix without rehashing), this script repairs rows. `goal_sub` and + * `import_goal` follow id renames via ON UPDATE CASCADE (see migration + * goal_id_on_update_cascade). + * + * Quest goal references in `quest.quest_data` are updated separately (JSON, not FK). + * + * Goals are scanned in primary-key order, one batch at a time, so the full table + * is never loaded into memory. + * + * Run: + * bun run src/scripts/reconcile-goal-ids.ts + * bun run src/scripts/reconcile-goal-ids.ts --dry-run + * bun run src/scripts/reconcile-goal-ids.ts --batch-size 1000 + */ + +import { SELECT_GOAL } from "#lib/db-formats/goal"; +import { ToGoalDocument } from "#lib/db-formats/target-documents"; +import { log } from "#lib/log/log"; +import { CreateGoalTitle } from "#lib/targets/goal-utils"; +import { CreateGoalID, mergeGoalSubscriptions, remapGoalIdInQuests } from "#lib/targets/goals"; +import DB from "#services/pg/db"; +import { parseArgs } from "node:util"; +import { type GoalDocument } from "tachi-common"; + +const DEFAULT_BATCH_SIZE = 500; + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + "batch-size": { type: "string" }, + "dry-run": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, +}); + +if (values.help) { + console.log(` +reconcile-goal-ids — align goal.id and goal.name with charts + criteria + + --batch-size Goals per DB fetch (default ${DEFAULT_BATCH_SIZE}) + --dry-run Log changes without writing + -h, --help Show this help +`); + process.exit(0); +} + +const cliDryRun = values["dry-run"] ?? false; +const batchSize = Math.max( + 1, + Number.parseInt(values["batch-size"] ?? "", 10) || DEFAULT_BATCH_SIZE, +); + +function canonicalGoalId(goal: GoalDocument): string { + return CreateGoalID(goal.charts, goal.criteria, goal.game); +} + +async function canonicalGoalName(goal: GoalDocument): Promise { + try { + return await CreateGoalTitle(goal.charts, goal.criteria, goal.game); + } catch (err) { + log.warn( + { err, goalID: goal.goalID, game: goal.game }, + `Could not derive goal name for ${goal.goalID}; skipping name update.`, + ); + return null; + } +} + +async function loadGoalBatch(afterId: string, limit: number): Promise> { + const rows = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", ">", afterId) + .orderBy("goal.id", "asc") + .limit(limit) + .execute(); + + return rows.map(ToGoalDocument); +} + +async function loadExistingGoalIds(goalIds: Array): Promise> { + if (goalIds.length === 0) { + return new Set(); + } + + const rows = await DB.selectFrom("goal") + .select("goal.id") + .where("goal.id", "in", goalIds) + .execute(); + + return new Set(rows.map((r) => r.id)); +} + +async function reconcileGoalId( + goal: GoalDocument, + expectedId: string, + expectedName: string | null, + targetExists: boolean, + isDryRun: boolean, +): Promise<"merged" | "renamed"> { + if (isDryRun) { + log.info( + targetExists + ? `[dry-run] Would merge ${goal.goalID} -> ${expectedId} (${goal.name})` + : `[dry-run] Would rename ${goal.goalID} -> ${expectedId} (${goal.name})`, + ); + return targetExists ? "merged" : "renamed"; + } + + await remapGoalIdInQuests(goal.goalID, expectedId); + + if (targetExists) { + await mergeGoalSubscriptions(goal.goalID, expectedId); + await DB.deleteFrom("goal").where("goal.id", "=", goal.goalID).execute(); + log.info(`Merged ${goal.goalID} -> ${expectedId} (${goal.name})`); + return "merged"; + } + + const update: { id: string; name?: string } = { id: expectedId }; + + if (expectedName !== null) { + update.name = expectedName; + } + + await DB.updateTable("goal").set(update).where("goal.id", "=", goal.goalID).execute(); + + log.info(`Renamed ${goal.goalID} -> ${expectedId} (${goal.name})`); + return "renamed"; +} + +async function reconcileGoalName( + goal: GoalDocument, + expectedName: string, + isDryRun: boolean, +): Promise { + if (goal.name === expectedName) { + return false; + } + + if (isDryRun) { + log.info(`[dry-run] Would rename goal ${goal.goalID}: "${goal.name}" -> "${expectedName}"`); + return true; + } + + await DB.updateTable("goal") + .set({ name: expectedName }) + .where("goal.id", "=", goal.goalID) + .execute(); + + log.info(`Updated goal name ${goal.goalID}: "${goal.name}" -> "${expectedName}"`); + return true; +} + +export async function reconcileGoalIds(options?: { batchSize?: number; dryRun?: boolean }) { + const isDryRun = options?.dryRun ?? cliDryRun; + const limit = options?.batchSize ?? batchSize; + + let renamed = 0; + let merged = 0; + let namesUpdated = 0; + let skipped = 0; + let scanned = 0; + let afterId = ""; + + for (;;) { + const batch = await loadGoalBatch(afterId, limit); + + if (batch.length === 0) { + break; + } + + scanned += batch.length; + + const drifted = batch.filter((goal) => canonicalGoalId(goal) !== goal.goalID); + const expectedIds = [...new Set(drifted.map((goal) => canonicalGoalId(goal)))]; + const existingIds = await loadExistingGoalIds(expectedIds); + + for (const goal of batch) { + const expectedId = canonicalGoalId(goal); + const expectedName = await canonicalGoalName(goal); + + if (expectedId !== goal.goalID) { + const targetExists = existingIds.has(expectedId); + const result = await reconcileGoalId( + goal, + expectedId, + expectedName, + targetExists, + isDryRun, + ); + + if (result === "renamed") { + renamed++; + } else { + merged++; + } + + continue; + } + + if (expectedName !== null && goal.name !== expectedName) { + if (await reconcileGoalName(goal, expectedName, isDryRun)) { + namesUpdated++; + } + } else { + skipped++; + } + } + + afterId = batch[batch.length - 1]!.goalID; + + log.info(`reconcile-goal-ids: scanned ${scanned} goals (batch through ${afterId})`); + + if (batch.length < limit) { + break; + } + } + + log.info( + `reconcile-goal-ids complete: ${scanned} scanned, ${renamed} id-renamed, ${merged} merged, ${namesUpdated} names updated, ${skipped} unchanged.`, + ); + + return { renamed, merged, namesUpdated, skipped, scanned }; +} + +if (require.main === module) { + await reconcileGoalIds(); + process.exit(0); +} diff --git a/typescript/server/src/scripts/update-quest-subscriptions.ts b/typescript/server/src/scripts/update-quest-subscriptions.ts new file mode 100644 index 000000000..119d62ab7 --- /dev/null +++ b/typescript/server/src/scripts/update-quest-subscriptions.ts @@ -0,0 +1,159 @@ +/** + * Re-sync quest subscriptions after quest definitions change. + * + * For each quest, calls `UpdateQuestSubscriptions`: unsubscribes every + * subscriber and re-subscribes them against the current quest (refreshing goal + * subs and progress), then sends a QUEST_CHANGED notification. + * + * Run after seed loads or goal-id reconciles when you want subscribers fully + * migrated rather than relying on lazy repair on score import. + * + * Run: + * bun run src/scripts/update-quest-subscriptions.ts + * bun run src/scripts/update-quest-subscriptions.ts --dry-run + * bun run src/scripts/update-quest-subscriptions.ts --only-subscribed + * bun run src/scripts/update-quest-subscriptions.ts --game sdvx + */ + +import { log } from "#lib/log/log"; +import { UpdateQuestSubscriptions } from "#lib/targets/quests"; +import DB from "#services/pg/db"; +import { sql } from "kysely"; +import { parseArgs } from "node:util"; + +const DEFAULT_BATCH_SIZE = 100; + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + "batch-size": { type: "string" }, + "dry-run": { type: "boolean" }, + game: { type: "string" }, + "only-subscribed": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, +}); + +if (values.help) { + console.log(` +update-quest-subscriptions — run UpdateQuestSubscriptions for all quests + + --batch-size Quest ids per DB fetch (default ${DEFAULT_BATCH_SIZE}) + --only-subscribed Only process quests with at least one quest_sub row + --game Limit to a single game (v3 id, e.g. sdvx or iidx-sp) + --dry-run Log quests that would be updated without writing + -h, --help Show this help +`); + process.exit(0); +} + +const cliDryRun = values["dry-run"] ?? false; +const onlySubscribed = values["only-subscribed"] ?? false; +const gameFilter = values.game; +const batchSize = Math.max( + 1, + Number.parseInt(values["batch-size"] ?? "", 10) || DEFAULT_BATCH_SIZE, +); + +export async function updateAllQuestSubscriptions(options?: { + batchSize?: number; + dryRun?: boolean; + game?: string; + onlySubscribed?: boolean; +}) { + const isDryRun = options?.dryRun ?? cliDryRun; + const limit = options?.batchSize ?? batchSize; + const game = options?.game ?? gameFilter; + const subscribedOnly = options?.onlySubscribed ?? onlySubscribed; + + let processed = 0; + let updated = 0; + let skipped = 0; + let subscribersNotified = 0; + let afterId = ""; + + log.info( + `update-quest-subscriptions: starting (dry-run=${isDryRun}, only-subscribed=${subscribedOnly}${game ? `, game=${game}` : ""})`, + ); + + for (;;) { + let query = DB.selectFrom("quest") + .select(["quest.id", "quest.name"]) + .select((eb) => + eb + .selectFrom("quest_sub") + .select((eb2) => eb2.fn.countAll().as("count")) + .whereRef("quest_sub.quest_id", "=", "quest.id") + .as("subscriber_count"), + ) + .where("quest.id", ">", afterId) + .orderBy("quest.id", "asc") + .limit(limit); + + if (subscribedOnly) { + query = query.where((eb) => + eb.exists( + eb + .selectFrom("quest_sub") + .select(sql`1`.as("one")) + .whereRef("quest_sub.quest_id", "=", "quest.id"), + ), + ); + } + + if (game) { + query = query.where("quest.game", "=", game as never); + } + + const batch = await query.execute(); + + if (batch.length === 0) { + break; + } + + for (const row of batch) { + processed++; + const subscriberCount = Number(row.subscriber_count ?? 0); + + if (subscriberCount === 0) { + skipped++; + continue; + } + + if (isDryRun) { + log.info( + `[dry-run] Would update quest ${row.id} (${row.name}) — ${subscriberCount} subscriber(s)`, + ); + updated++; + subscribersNotified += subscriberCount; + continue; + } + + await UpdateQuestSubscriptions(row.id); + updated++; + subscribersNotified += subscriberCount; + log.info(`Updated quest ${row.id} (${row.name}) — ${subscriberCount} subscriber(s)`); + } + + afterId = batch[batch.length - 1]!.id; + + log.info( + `update-quest-subscriptions: processed ${processed} quests (batch through ${afterId})`, + ); + + if (batch.length < limit) { + break; + } + } + + log.info( + `update-quest-subscriptions complete: ${processed} quest(s) scanned, ${updated} updated, ${skipped} skipped (no subscribers), ${subscribersNotified} subscriber notification(s).`, + ); + + return { processed, updated, skipped, subscribersNotified }; +} + +if (require.main === module) { + await updateAllQuestSubscriptions(); + process.exit(0); +}