From 2bc4c3a628e56992e70ecc1ff2ffb808dbb93f44 Mon Sep 17 00:00:00 2001 From: zk Date: Mon, 20 Apr 2026 23:11:16 +0100 Subject: [PATCH] more (#53) * checkpoint * server compiles again * fmt * checkpoint * last one out, get the lights * euston station * like, mostly good. I'm under the ocean right now. * fix: some nonsense AI stuff * fix: new eslint rules, and cleanup imports across the board * feat: folder slug rewrite, email logic change, too * fix: register must use txn not db * checkpoint --- .cursor/skills/no-oldtest-edits/SKILL.md | 17 +- .devcontainer/devcontainer.json | 7 +- .vscode/settings.json | 3 +- Justfile-apps | 2 +- Justfile-misc | 2 +- Justfile-test | 2 +- README.md | 2 +- bun.lock | 25 +- db/migrations/20260301154256_genesis.sql | 66 +- db/migrations/20260405120000_dirty_queues.sql | 58 - db/seeds/bms-course-lookup.json | 272 +- db/seeds/folders.json | 15586 ++++++++++------ db/seeds/tables.json | 7810 ++++---- dev/aliases.fish | 1 - dev/functions.fish | 5 - .../dashboards/nodejs-application.json | 1759 +- .../grafana/dashboards/tachi-api.json | 3795 ++-- dev/setup.fish | 1 - docker-compose-dev.yml | 10 + docs/docs/api/routes/gpt-targets.md | 22 +- docs/docs/api/routes/gpt.md | 36 +- docs/docs/api/routes/ugpt-showcase.md | 6 +- docs/docs/api/routes/ugpt-targets.md | 12 +- docs/docs/api/routes/user-gamept.md | 34 +- docs/docs/codebase/setup/config.md | 25 +- homepage/README.md | 15 - package.json | 1 + typescript/bliss/src/actions.ts | 7 +- typescript/bot/package.json | 4 +- typescript/bot/src/actions.ts | 2 +- typescript/bot/src/actions/sync.test.ts | 19 +- typescript/bot/src/actions/sync.ts | 2 +- typescript/bot/src/config.ts | 6 +- typescript/bot/src/server/server.ts | 6 +- .../bot/src/slash-commands/commands/sync.ts | 2 +- typescript/bot/src/utils/api-requests.ts | 65 +- typescript/bot/src/utils/arg-parsers.ts | 18 +- typescript/bot/src/utils/embeds.ts | 8 +- typescript/bot/src/utils/misc.ts | 53 +- typescript/bot/src/utils/options.ts | 18 +- typescript/bot/src/utils/return-types.ts | 28 +- .../bot/src/webhook-handlers/class-update.ts | 53 +- .../bot/src/webhook-handlers/goal-achieved.ts | 14 +- .../src/webhook-handlers/quest-achieved.ts | 11 +- typescript/bot/vitest.config.ts | 4 + typescript/bot/vitest.setup.ts | 9 +- .../src/app/pages/ClientFileFlowPage.tsx | 6 +- typescript/client/src/app/pages/LoginPage.tsx | 4 +- .../src/app/pages/OAuthRequestAuthPage.tsx | 6 +- .../client/src/app/pages/RegisterPage.tsx | 4 +- .../src/app/pages/admin/AdminActionsPage.tsx | 153 +- .../app/pages/admin/AdminDestructivePage.tsx | 10 +- .../app/pages/admin/AdminOperationsPage.tsx | 16 +- .../src/app/pages/dashboard/DashboardPage.tsx | 8 +- .../games/_game/_playtype/GPTChartPage.tsx | 107 +- .../games/_game/_playtype/GPTChartsPage.tsx | 29 +- .../games/_game/_playtype/GPTDevInfo.tsx | 30 +- .../_game/_playtype/GPTLeaderboardsPage.tsx | 54 +- .../games/_game/_playtype/GPTMainPage.tsx | 12 +- .../dashboard/import/BatchManualPage.tsx | 24 +- .../app/pages/dashboard/import/ImportPage.tsx | 8 +- .../pages/dashboard/misc/SupportBanner.tsx | 4 +- .../app/pages/dashboard/search/SearchPage.tsx | 207 +- .../users/FervidexIntegrationPage.tsx | 14 +- .../users/KsHookSV6CIntegrationPage.tsx | 16 +- .../dashboard/users/NotificationsPage.tsx | 4 +- .../pages/dashboard/users/UserGamesPage.tsx | 4 +- .../pages/dashboard/users/UserImportsPage.tsx | 14 +- .../dashboard/users/UserIntegrationsPage.tsx | 60 +- .../pages/dashboard/users/UserInvitesPage.tsx | 12 +- .../app/pages/dashboard/users/UserPage.tsx | 4 +- .../dashboard/users/UserSettingsPage.tsx | 34 +- .../_game/_playtype/LeaderboardsPage.tsx | 67 +- .../games/_game/_playtype/OverviewPage.tsx | 103 +- .../games/_game/_playtype/ScoresPage.tsx | 139 +- .../games/_game/_playtype/SessionsPage.tsx | 44 +- .../_game/_playtype/SpecificSessionPage.tsx | 23 +- .../_game/_playtype/UGPTSettingsPage.tsx | 118 +- .../_playtype/folders/FolderComparePage.tsx | 56 +- .../_playtype/folders/FolderQuestsPage.tsx | 30 +- .../_playtype/folders/FolderSelectPage.tsx | 35 +- .../_playtype/folders/FolderTablePage.tsx | 86 +- .../_playtype/folders/FoldersMainPage.tsx | 39 +- .../_playtype/folders/RecentFoldersPage.tsx | 34 +- .../_playtype/folders/SpecificFolderPage.tsx | 159 +- .../_playtype/rivals/RivalsActivityPage.tsx | 8 +- .../_game/_playtype/rivals/RivalsMainPage.tsx | 54 +- .../_playtype/rivals/RivalsManagePage.tsx | 58 +- .../_game/_playtype/targets/TargetsPage.tsx | 28 +- .../_game/_playtype/targets/UGPTGoalsPage.tsx | 18 +- .../_playtype/targets/UGPTQuestsPage.tsx | 25 +- .../_game/_playtype/utils/UGPTUtilsPage.tsx | 31 +- .../app/pages/dashboard/utils/QuestEditor.tsx | 2 +- .../client/src/app/routes/GameRoutes.tsx | 265 +- .../client/src/app/routes/UserRoutes.tsx | 141 +- .../src/components/activity/Activity.tsx | 157 +- .../charts/GekichumaiScoreChart.tsx | 23 +- .../dashboard/DashboardActivity.tsx | 4 +- .../dashboard/DashboardLoggedInNoScores.tsx | 4 +- .../client/src/components/game/ClassBadge.tsx | 17 +- .../src/components/game/ScoreLeaderboard.tsx | 17 +- .../game/charts/ChartInfoFormat.tsx | 49 +- .../game/folder/FolderInfoHeader.tsx | 27 +- .../components/game/folder/FolderMinimap.tsx | 40 +- .../game/folder/FolderScoreAverages.tsx | 9 +- .../folder/FolderScoreDistributionChart.tsx | 15 +- .../game/songs/DefaultSongChartInfoFormat.tsx | 15 +- .../songs/IIDXStyleSongChartInfoFormat.tsx | 19 +- .../game/songs/SongChartInfoFormat.tsx | 18 +- .../src/components/game/targets/QuestPage.tsx | 20 +- .../components/game/targets/QuestlinePage.tsx | 18 +- .../components/game/targets/QuestsPage.tsx | 29 +- .../src/components/gpt-utils/GPTUtils.tsx | 65 +- .../insights/JubilityBreakdownInsight.tsx | 20 +- .../insights/ONGEKIBreakdownInsight.tsx | 93 +- .../gpt-utils/tools/BMSCustomTablesTool.tsx | 6 +- .../gpt-utils/tools/BMSSieglindeInfoTool.tsx | 51 +- .../tools/IIDXEamusementExportTool.tsx | 4 +- .../gpt-utils/tools/IIDXPlaylistsTool.tsx | 6 +- .../components/imports/CGIntegrationPage.tsx | 4 +- .../src/components/imports/ImportInfo.tsx | 83 +- .../components/imports/MYTIntegrationPage.tsx | 4 +- .../layout/header/GlobalInfoDropdown.tsx | 8 +- .../components/layout/header/HeaderMenu.tsx | 4 +- .../layout/header/ImportScoresDropdown.tsx | 2 +- .../components/layout/header/UGPTDropdown.tsx | 51 +- .../src/components/layout/header/UserArea.tsx | 4 +- .../layout/header/UserProfileDropdown.tsx | 6 +- .../layout/screens/LoadingScreen.tsx | 4 +- .../components/layout/subheader/SubHeader.tsx | 2 +- .../notifications/NotificationRow.tsx | 12 +- .../src/components/seeds/SeedsPicker.tsx | 2 - .../components/sessions/SessionCalendar.tsx | 30 +- .../src/components/sessions/SessionCard.tsx | 13 +- .../sessions/SessionFolderRaiseBreakdown.tsx | 67 +- .../components/sessions/SessionOverview.tsx | 76 +- .../sessions/SessionRaiseBreakdown.tsx | 88 +- .../tables/cells/ArcaeaJudgementCell.tsx | 4 +- .../tables/cells/BMSOrPMSDifficultyCell.tsx | 46 +- .../tables/cells/BMSOrPMSLampCell.tsx | 8 +- .../src/components/tables/cells/BPICell.tsx | 27 +- .../tables/cells/CHUNITHMJudgementCell.tsx | 4 +- .../tables/cells/CHUNITHMLampCell.tsx | 4 +- .../tables/cells/DifficultyCell.tsx | 74 +- .../tables/cells/GitadoraJudgementCell.tsx | 6 +- .../components/tables/cells/IIDXLampCell.tsx | 11 +- .../tables/cells/ITGDifficultyCell.tsx | 8 +- .../tables/cells/ITGJudgementCell.tsx | 4 +- .../tables/cells/JubeatJudgementCell.tsx | 4 +- .../tables/cells/JubeatScoreCell.tsx | 8 +- .../components/tables/cells/JubilityCell.tsx | 8 +- .../tables/cells/MaimaiDXJudgementCell.tsx | 4 +- .../tables/cells/MaimaiJudgementCell.tsx | 4 +- .../tables/cells/MusecaJudgementCell.tsx | 4 +- .../tables/cells/OngekiJudgementCell.tsx | 4 +- .../tables/cells/OngekiScoreRatingCell.tsx | 18 +- .../components/tables/cells/PBCompareCell.tsx | 17 +- .../tables/cells/PopnJudgementCell.tsx | 4 +- .../components/tables/cells/PopnLampCell.tsx | 6 +- .../components/tables/cells/RankingCell.tsx | 4 +- .../components/tables/cells/RatingCell.tsx | 9 +- .../tables/cells/RatingSystemPart.tsx | 12 +- .../tables/cells/SDVXJudgementCell.tsx | 6 +- .../components/tables/cells/SDVXLampCell.tsx | 6 +- .../src/components/tables/cells/TitleCell.tsx | 20 +- .../tables/cells/USCDifficultyCell.tsx | 19 +- .../src/components/tables/cells/UserCell.tsx | 9 +- .../src/components/tables/cells/VF6Cell.tsx | 24 +- .../tables/cells/WACCAJudgementCell.tsx | 4 +- .../components/SelectableCompareType.tsx | 8 +- .../tables/components/SelectableRating.tsx | 25 +- .../tables/components/UseScoreState.tsx | 4 +- .../tables/dropdowns/GPTDropdownSettings.tsx | 20 +- .../tables/dropdowns/PBDropdown.tsx | 42 +- .../tables/dropdowns/ScoreDropdown.tsx | 46 +- .../components/BMSScoreDropdownParts.tsx | 12 +- .../components/ChunithmScoreDropdownParts.tsx | 26 +- .../dropdowns/components/DeleteScoreBtn.tsx | 4 +- .../components/DocumentComponent.tsx | 43 +- .../components/IIDXScoreDropdownParts.tsx | 12 +- .../components/ITGScoreDropdownParts.tsx | 10 +- .../components/JubeatScoreDropdownParts.tsx | 10 +- .../dropdowns/components/JudgementTable.tsx | 17 +- .../components/MaimaiDXScoreDropdownParts.tsx | 26 +- .../dropdowns/components/ManageImport.tsx | 4 +- .../components/OngekiScoreDropdownParts.tsx | 26 +- .../tables/dropdowns/components/PBCompare.tsx | 10 +- .../dropdowns/components/PlayHistory.tsx | 9 +- .../dropdowns/components/RivalCompare.tsx | 36 +- .../dropdowns/components/ScoreEditButtons.tsx | 6 +- .../dropdowns/components/TargetInfo.tsx | 12 +- .../components/tables/folders/FolderTable.tsx | 41 +- .../tables/game-core-cells/ScoreCoreCells.tsx | 19 +- .../components/tables/headers/ChartHeader.tsx | 14 +- .../components/tables/headers/GameHeaders.tsx | 41 +- .../tables/headers/IndicatorHeader.tsx | 4 +- .../tables/headers/RankingHeader.tsx | 4 +- .../history-scores/HistoryScoreTable.tsx | 30 +- .../components/tables/pbs/PBLeadingRows.tsx | 4 +- .../src/components/tables/pbs/PBTable.tsx | 26 +- .../tables/rivals/ComparePBsTable.tsx | 44 +- .../tables/rivals/RivalChartTable.tsx | 28 +- .../components/tables/scores/ScoreTable.tsx | 28 +- .../seeds/SeedsBMSCourseLookupTable.tsx | 14 +- .../tables/seeds/SeedsChartsTable.tsx | 6 +- .../tables/seeds/SeedsFolderTable.tsx | 19 +- .../tables/seeds/SeedsGoalsTable.tsx | 19 +- .../tables/seeds/SeedsQuestTable.tsx | 9 +- .../tables/seeds/SeedsQuestlineTable.tsx | 11 +- .../tables/seeds/SeedsSongsTable.tsx | 14 +- .../components/tables/seeds/SeedsTable.tsx | 4 + .../tables/seeds/SeedsTableTable.tsx | 11 +- .../tables/sessions/GenericSessionTable.tsx | 50 +- .../targets/AddNewGoalForQuestModal.tsx | 66 +- .../components/targets/AddNewQuestModal.tsx | 23 +- .../src/components/targets/Questline.tsx | 18 +- .../components/targets/SetNewGoalModal.tsx | 77 +- .../src/components/targets/quests/Quest.tsx | 33 +- .../targets/quests/editor/EditableQuest.tsx | 12 +- .../src/components/user/ProfileBadges.tsx | 4 +- .../src/components/user/ProfilePicture.tsx | 6 +- .../client/src/components/user/UGPTHeader.tsx | 25 +- .../src/components/user/UGPTProfiles.tsx | 52 +- .../src/components/user/UGPTRankingData.tsx | 19 +- .../src/components/user/UGPTStatContainer.tsx | 20 +- .../src/components/user/UGPTStatCreator.tsx | 158 +- .../src/components/user/UGPTStatShowcase.tsx | 422 +- .../src/components/user/UGPTStatsOverview.tsx | 37 +- .../client/src/components/user/UserHeader.tsx | 16 +- .../src/components/util/FollowUserButton.tsx | 4 +- .../client/src/components/util/GoalLink.tsx | 16 +- .../src/components/util/OAuthMoreInfo.tsx | 6 +- .../src/components/util/ReferToUser.tsx | 4 +- .../components/util/UpdateUserGameStats.tsx | 6 +- .../client/src/components/util/UserIcon.tsx | 15 +- .../src/components/util/import/useImport.ts | 6 +- .../components/util/modal/UserSelectModal.tsx | 8 +- .../components/util/query/fetchUGPTData.ts | 40 +- .../src/components/util/query/useApiQuery.tsx | 10 + .../components/util/search/useTachiSearch.ts | 36 +- .../client/src/components/util/useBucket.tsx | 8 +- .../src/components/util/useLUGPTSettings.tsx | 8 +- .../components/util/usePreferredRanking.tsx | 4 +- .../src/components/util/useScoreRatingAlg.tsx | 33 +- .../src/components/util/useUGPTBase.tsx | 7 +- .../src/context/AllLUGPTStatsContext.tsx | 8 +- .../src/context/NotificationsContext.tsx | 8 +- .../client/src/context/TargetsContext.tsx | 23 +- typescript/client/src/context/UGPTContext.tsx | 21 +- typescript/client/src/context/UserContext.tsx | 8 +- .../src/context/UserSettingsContext.tsx | 10 +- typescript/client/src/lib/config.ts | 38 +- .../client/src/lib/game-implementations.tsx | 64 +- typescript/client/src/lib/games/_util.ts | 4 +- typescript/client/src/lib/games/arcaea.tsx | 29 +- typescript/client/src/lib/games/bms-pms.tsx | 10 +- typescript/client/src/lib/games/ddr.tsx | 24 +- typescript/client/src/lib/games/gitadora.tsx | 16 +- typescript/client/src/lib/games/iidx.tsx | 18 +- typescript/client/src/lib/games/sdvx-usc.tsx | 12 +- typescript/client/src/lib/types.ts | 42 +- .../styles/legacy/tachi-custom/_custom.scss | 76 +- typescript/client/src/types/api-returns.ts | 291 +- typescript/client/src/types/import.ts | 4 +- typescript/client/src/types/react.ts | 10 +- typescript/client/src/types/seeds.ts | 53 +- typescript/client/src/types/tables.ts | 94 +- typescript/client/src/types/tachi.ts | 36 +- typescript/client/src/util/asserts.ts | 29 +- typescript/client/src/util/data.ts | 46 +- typescript/client/src/util/misc.ts | 80 +- typescript/client/src/util/seeds.ts | 96 +- typescript/client/src/util/site.ts | 2 +- typescript/client/src/util/str-to-num.ts | 12 +- typescript/client/src/util/submit-file.ts | 4 +- .../client/src/util/tables/create-search.ts | 102 +- typescript/client/src/util/ztable/search.ts | 1 - typescript/common/package.json | 2 +- typescript/common/src/config/config.test.ts | 13 +- typescript/common/src/config/config.ts | 233 +- .../common/src/config/game-support/arcaea.ts | 13 +- .../common/src/config/game-support/bms.ts | 19 +- .../src/config/game-support/chunithm.ts | 13 +- .../common/src/config/game-support/ddr.ts | 19 +- .../src/config/game-support/gitadora.ts | 21 +- .../common/src/config/game-support/iidx.ts | 92 +- .../common/src/config/game-support/itg.ts | 13 +- .../common/src/config/game-support/jubeat.ts | 13 +- .../src/config/game-support/maimai-dx.ts | 14 +- .../common/src/config/game-support/maimai.ts | 13 +- .../common/src/config/game-support/museca.ts | 13 +- .../common/src/config/game-support/ongeki.ts | 13 +- .../common/src/config/game-support/pms.ts | 19 +- .../common/src/config/game-support/popn.ts | 13 +- .../common/src/config/game-support/sdvx.ts | 13 +- .../common/src/config/game-support/usc.ts | 17 +- .../common/src/config/game-support/wacca.ts | 13 +- .../common/src/constants/bms-tables.test.ts | 4 +- typescript/common/src/constants/bms-tables.ts | 113 +- .../common/src/constants/grade-boundaries.ts | 30 +- typescript/common/src/index.test.ts | 2 +- typescript/common/src/index.ts | 1 + typescript/common/src/lib/folder-slug.ts | 475 + typescript/common/src/lib/schemas.ts | 182 +- typescript/common/src/seeds/types.ts | 34 +- typescript/common/src/types.ts | 2 +- typescript/common/src/types/batch-manual.ts | 37 +- typescript/common/src/types/documents.ts | 331 +- .../common/src/types/game-config-utils.ts | 2 +- typescript/common/src/types/game-config.ts | 235 +- typescript/common/src/types/internals.ts | 20 +- typescript/common/src/types/metrics.ts | 48 +- typescript/common/src/types/notifications.ts | 8 +- typescript/common/src/utils/util.ts | 271 +- typescript/common/src/webhooks.ts | 16 +- typescript/common/vitest.config.ts | 4 + typescript/coverage-tools/src/manifest.ts | 12 +- typescript/db/package.json | 3 +- .../src/generated/public/BmsCourseLookup.ts | 3 +- typescript/db/src/generated/public/Folder.ts | 2 +- .../db/src/generated/public/TableFolder.ts | 2 + typescript/db/src/placeholder.test.ts | 8 - typescript/db/vitest.config.ts | 15 + typescript/docs-autogen-scripts/src/main.ts | 55 +- typescript/eslint-config/index.js | 36 +- typescript/eslint-config/package.json | 1 + .../rules/tachi-imports-plugin.js | 303 + typescript/rg-stats/package.json | 2 +- typescript/rg-stats/vitest.config.ts | 4 + typescript/seeds-scripts/finders.js | 2 +- .../rerunners/arcaea/merge-songlist.ts | 47 +- .../bms-pms/add-beatoraja-courses.js | 2 +- .../rerunners/bms-pms/add-lr2crs-file.js | 2 +- .../bms-pms/sync-bms-tablefolders.ts | 30 +- .../rerunners/chunithm/merge-options.ts | 29 +- .../chunithm/parse-chunithm-dataset.ts | 19 +- .../rerunners/ddr/parse-charts.ts | 11 +- .../rerunners/ddr/parse-gameData.ts | 38 +- .../folder-slugs/add-folder-slugs.ts | 71 + .../folder-slugs/add-iidx-folder-slugs.ts | 50 + .../folder-slugs/apply-folder-slugs.ts | 44 + .../rerunners/folder-slugs/folder-slug.ts | 6 + .../rerunners/gitadora/parse-mdb.ts | 37 +- .../iidx/aix-output/parse-aix-json.ts | 36 +- .../iidx/iidx-mdb-parse/merge-mdb.ts | 37 +- .../rerunners/iidx/update-bpi-poyashi.js | 2 +- .../rerunners/import-raw-quests.ts | 18 +- .../rerunners/jubeat/add-jubeat-folders.ts | 14 +- .../maimaidx/add-maimaidx-internal-levels.ts | 12 +- .../rerunners/maimaidx/merge-options.ts | 51 +- .../maimaidx/parse-maimaidx-dataset.ts | 43 +- .../rerunners/make-questline-from-quests.ts | 6 +- .../ongeki/add-tables-and-folders.ts | 12 +- .../rerunners/ongeki/parse-music-data.ts | 16 +- .../rerunners/ongeki/parse-song-duration.ts | 8 +- .../rerunners/ongeki/scrape-sdvx-in.ts | 8 +- .../rerunners/sdvx/add-sdvx-clear-tierlist.js | 6 +- .../rerunners/sdvx/add-sdvx-konaste.js | 2 +- .../seeds-scripts/rerunners/sdvx/merge-mdb.ts | 19 +- .../rerunners/sdvx/scrape-maya2s.ts | 8 +- .../rerunners/toggle-table-inactive.js | 7 +- .../v3/1-split-charts-by-playtype.ts | 6 +- .../rerunners/v3/2-migrate-to-pg-style.ts | 7 +- .../rerunners/v3/3-migrate-folders-tables.ts | 21 +- .../rerunners/v3/5-folders-to-sql-queries.ts | 28 +- .../6-bms-course-lookup-playtype-to-game.ts | 21 + .../v3/6-tables-folder-refs-to-slugs.ts | 96 + .../rerunners/wacca/parse-wplus.js | 2 +- typescript/seeds-scripts/sort-seeds.js | 4 +- .../seeds-scripts/test/collections.test.ts | 4 +- typescript/seeds-scripts/test/custom.test.ts | 10 +- typescript/seeds-scripts/test/goal-id.test.ts | 39 - typescript/seeds-scripts/test/id.test.ts | 10 +- .../seeds-scripts/test/match-type.test.ts | 31 +- .../seeds-scripts/test/references.test.ts | 29 +- typescript/seeds-scripts/test/schemas.ts | 13 +- .../seeds-scripts/test/table-default.test.ts | 8 +- typescript/seeds-scripts/test/test-utils.ts | 4 +- typescript/seeds-scripts/util.js | 7 + typescript/server/.env | 9 +- typescript/server/.env.test | 8 +- typescript/server/package.json | 5 +- .../server/src/@types/express/index.d.ts | 9 +- .../server/src/@types/mongodb/index.d.ts | 1 - .../server/src/actions/add-goal.test.ts | 22 +- typescript/server/src/actions/add-goal.ts | 41 +- .../src/actions/backsync-bms-pms-seeds.ts | 86 +- .../server/src/actions/bms-table-sync.test.ts | 2 +- .../server/src/actions/bms-table-sync.ts | 43 +- .../server/src/actions/change-banner.ts | 10 +- .../server/src/actions/change-email.test.ts | 163 +- typescript/server/src/actions/change-email.ts | 48 +- .../server/src/actions/change-password.ts | 6 +- typescript/server/src/actions/change-pfp.ts | 10 +- .../server/src/actions/change-username.ts | 10 +- .../server/src/actions/create-api-client.ts | 12 +- .../server/src/actions/create-api-token.ts | 6 +- .../server/src/actions/create-invite.ts | 12 +- .../src/actions/create-oauth2-auth-code.ts | 6 +- .../src/actions/customise-score.test.ts | 12 +- .../server/src/actions/customise-score.ts | 4 +- .../src/actions/delete-all-notifications.ts | 6 +- .../server/src/actions/delete-api-client.ts | 4 +- .../server/src/actions/delete-api-token.ts | 4 +- .../server/src/actions/delete-banner.ts | 8 +- .../server/src/actions/delete-cg-card-info.ts | 4 +- .../server/src/actions/delete-import.ts | 8 +- .../src/actions/delete-myt-card-info.ts | 4 +- typescript/server/src/actions/delete-pfp.ts | 8 +- .../server/src/actions/delete-score.test.ts | 6 +- typescript/server/src/actions/delete-score.ts | 8 +- .../server/src/actions/delete-session.test.ts | 6 +- .../server/src/actions/delete-session.ts | 10 +- .../server/src/actions/follow-user.test.ts | 6 +- typescript/server/src/actions/follow-user.ts | 8 +- .../actions/mark-all-notifications-read.ts | 6 +- .../src/actions/patch-ugpt-settings.test.ts | 13 +- .../server/src/actions/patch-ugpt-settings.ts | 47 +- .../actions/rebuild-folder-chart-lookup.ts | 8 +- .../actions/remove-goal-subscription.test.ts | 21 +- .../src/actions/remove-goal-subscription.ts | 21 +- .../src/actions/reset-api-client-secret.ts | 8 +- .../src/actions/revoke-kai-auth-token.ts | 4 +- .../server/src/actions/set-rivals.test.ts | 15 +- typescript/server/src/actions/set-rivals.ts | 31 +- .../actions/set-user-supporter-status.test.ts | 8 +- .../src/actions/set-user-supporter-status.ts | 6 +- .../src/actions/test-utils/api-tokens.ts | 3 + typescript/server/src/actions/ugs-snapshot.ts | 18 +- .../server/src/actions/unfollow-user.test.ts | 8 +- .../server/src/actions/unfollow-user.ts | 6 +- .../server/src/actions/update-api-client.ts | 8 +- .../server/src/actions/update-bpi-data.ts | 54 +- .../server/src/actions/update-cg-card-info.ts | 4 +- .../server/src/actions/update-dp-tiers.ts | 21 +- .../src/actions/update-fervidex-settings.ts | 4 +- .../actions/update-kshook-sv6c-settings.ts | 4 +- .../src/actions/update-myt-card-info.ts | 4 +- .../server/src/actions/update-session.ts | 4 +- .../server/src/actions/update-sp12-data.ts | 18 +- .../src/actions/update-ugpt-showcase.ts | 22 +- .../src/actions/update-user-settings.ts | 4 +- typescript/server/src/actions/update-user.ts | 6 +- .../src/actions/upsert-kai-auth-token.ts | 6 +- .../src/anon-actions/forgot-password.ts | 5 - .../server/src/anon-actions/register.ts | 35 +- .../game-implementations.ts | 52 +- .../src/game-implementations/games/_common.ts | 70 +- .../games/arcaea.oldtest.ts | 257 - .../game-implementations/games/arcaea.test.ts | 325 + .../src/game-implementations/games/arcaea.ts | 13 +- .../games/bms-pms.impl.test.ts | 408 + .../games/bms-pms.oldtest.ts | 355 - .../src/game-implementations/games/bms-pms.ts | 18 +- .../games/chunithm.oldtest.ts | 246 - .../games/chunithm.test.ts | 333 + .../game-implementations/games/chunithm.ts | 10 +- .../game-implementations/games/ddr.oldtest.ts | 205 - .../games/ddr.profile.test.ts | 20 +- .../game-implementations/games/ddr.test.ts | 164 + .../src/game-implementations/games/ddr.ts | 47 +- .../games/gitadora.oldtest.ts | 220 - .../games/gitadora.test.ts | 251 + .../game-implementations/games/gitadora.ts | 15 +- .../games/iidx.impl.extra.test.ts | 172 + .../games/iidx.impl.test.ts | 322 + .../games/iidx.oldtest.ts | 469 - .../src/game-implementations/games/iidx.ts | 37 +- .../src/game-implementations/games/itg.ts | 12 +- .../games/jubeat.oldtest.ts | 274 - .../games/jubeat.profile.test.ts | 104 +- .../game-implementations/games/jubeat.test.ts | 316 + .../src/game-implementations/games/jubeat.ts | 59 +- .../games/maimai.oldtest.ts | 206 - .../game-implementations/games/maimai.test.ts | 251 + .../src/game-implementations/games/maimai.ts | 10 +- .../games/maimaidx.oldtest.ts | 171 - .../games/maimaidx.test.ts | 226 + .../game-implementations/games/maimaidx.ts | 10 +- .../games/museca.oldtest.ts | 132 - .../game-implementations/games/museca.test.ts | 185 + .../src/game-implementations/games/museca.ts | 10 +- .../games/ongeki.oldtest.ts | 449 - .../game-implementations/games/ongeki.test.ts | 408 + .../src/game-implementations/games/ongeki.ts | 18 +- .../games/popn.oldtest.ts | 198 - .../game-implementations/games/popn.test.ts | 245 + .../src/game-implementations/games/popn.ts | 18 +- .../games/sdvx.oldtest.ts | 235 - .../game-implementations/games/sdvx.test.ts | 311 + .../src/game-implementations/games/sdvx.ts | 6 +- .../game-implementations/games/usc.oldtest.ts | 205 - .../game-implementations/games/usc.test.ts | 258 + .../src/game-implementations/games/usc.ts | 11 +- .../games/wacca.oldtest.ts | 216 - .../game-implementations/games/wacca.test.ts | 289 + .../src/game-implementations/games/wacca.ts | 10 +- .../server/src/game-implementations/types.ts | 147 +- .../utils/derivation-checksum.ts | 84 +- .../utils/pb-merge.test.ts | 63 +- .../game-implementations/utils/pb-merge.ts | 30 +- .../utils/profile-calc.test.ts | 10 +- .../utils/profile-calc.ts | 38 +- .../utils/session-calc.ts | 14 +- typescript/server/src/lib/actions/actions.ts | 22 +- .../server/src/lib/activity/activity.test.ts | 34 +- .../server/src/lib/activity/activity.ts | 85 +- typescript/server/src/lib/auth/auth.ts | 27 +- .../lib/builtin-clients/builtin-clients.ts | 4 +- .../server/src/lib/db-formats/api-client.ts | 4 +- .../server/src/lib/db-formats/api-token.ts | 4 +- .../server/src/lib/db-formats/cg-card-info.ts | 6 +- .../server/src/lib/db-formats/chart.test.ts | 18 +- typescript/server/src/lib/db-formats/chart.ts | 72 +- .../src/lib/db-formats/class-achievement.ts | 15 +- .../src/lib/db-formats/fervidex-settings.ts | 4 +- .../server/src/lib/db-formats/folders.ts | 59 +- .../src/lib/db-formats/game-profiles.ts | 14 +- .../lib/db-formats/import-document.test.ts | 8 +- .../src/lib/db-formats/import-document.ts | 65 +- .../server/src/lib/db-formats/invite.ts | 4 +- .../src/lib/db-formats/kai-auth-token.ts | 6 +- .../lib/db-formats/kshook-sv6c-settings.ts | 4 +- .../src/lib/db-formats/myt-card-info.ts | 4 +- .../server/src/lib/db-formats/notification.ts | 4 +- typescript/server/src/lib/db-formats/pb.ts | 117 +- .../server/src/lib/db-formats/questline.ts | 9 +- typescript/server/src/lib/db-formats/score.ts | 36 +- .../src/lib/db-formats/session.full.test.ts | 3 +- .../server/src/lib/db-formats/session.ts | 24 +- typescript/server/src/lib/db-formats/song.ts | 62 +- typescript/server/src/lib/db-formats/table.ts | 61 +- .../src/lib/db-formats/target-documents.ts | 81 +- .../src/lib/db-formats/ugpt-settings.ts | 50 +- .../src/lib/db-formats/user-settings.ts | 4 +- typescript/server/src/lib/db-formats/user.ts | 4 +- typescript/server/src/lib/email/client.ts | 42 +- .../server/src/lib/folders/folder-query.ts | 2 +- .../server/src/lib/folders/folders.test.ts | 28 +- typescript/server/src/lib/folders/folders.ts | 135 +- .../folders/rebuild-folder-chart-lookup.ts | 2 +- .../create-game-settings.oldtest.ts | 33 - .../create-game-settings.test.ts | 37 + .../lib/game-settings/create-game-settings.ts | 22 +- .../lib/game-specific/custom-bms-tables.ts | 107 +- .../src/lib/game-specific/iidx-playlists.ts | 35 +- .../server/src/lib/imports/imports.oldtest.ts | 60 - .../server/src/lib/imports/imports.test.ts | 137 + typescript/server/src/lib/imports/imports.ts | 10 +- .../server/src/lib/invites/invites.oldtest.ts | 43 - .../server/src/lib/invites/invites.test.ts | 28 + typescript/server/src/lib/invites/invites.ts | 4 +- .../server/src/lib/jobs/bms-ai-table-sync.ts | 4 +- .../server/src/lib/jobs/drain-dirty-queues.ts | 16 +- .../lib/jobs/rebuild-folder-chart-lookup.ts | 4 +- .../notifications/notification-wrappers.ts | 30 +- .../notifications/notifications.oldtest.ts | 77 - .../lib/notifications/notifications.test.ts | 4 +- .../lib/orphan-queue/deorphan-bms-pg.test.ts | 2 +- .../src/lib/orphan-queue/deorphan-bms-pg.ts | 44 +- .../src/lib/orphan-queue/orphan-queue.ts | 94 +- typescript/server/src/lib/rivals/rivals.ts | 122 +- .../server/src/lib/router/middleware.ts | 613 + .../server/src/lib/router/typed-router.ts | 264 + .../calculated-data/profile-classes.ts | 14 +- .../framework/calculated-data/profile.ts | 29 +- .../framework/calculated-data/score.ts | 30 +- .../framework/calculated-data/session.ts | 8 +- .../framework/calculated-data/types.ts | 11 +- .../framework/common/converter-failures.ts | 6 +- .../framework/common/get-input-parser.ts | 4 +- .../framework/common/import-logger.ts | 4 +- .../framework/common/score-utils.ts | 10 +- .../common/string-asserts.oldtest.ts | 187 - .../framework/common/string-asserts.test.ts | 73 + .../framework/common/string-asserts.ts | 18 +- .../score-import/framework/common/types.ts | 23 +- .../score-import/framework/express-wrapper.ts | 4 +- .../framework/goals/goals.oldtest.ts | 323 - .../framework/goals/goals.test.ts | 509 + .../lib/score-import/framework/goals/goals.ts | 34 +- .../framework/orphans/orphans.oldtest.ts | 262 - .../framework/orphans/orphans.test.ts | 8 +- .../score-import/framework/orphans/orphans.ts | 23 +- .../framework/pb/create-pb-doc.oldtest.ts | 267 - .../framework/pb/create-pb-doc.test.ts | 51 + .../framework/pb/create-pb-doc.ts | 136 +- .../framework/pb/process-pbs.oldtest.ts | 84 - .../framework/pb/process-pbs.test.ts | 185 + .../score-import/framework/pb/process-pbs.ts | 43 +- .../framework/pb/rederive-scores.ts | 9 +- .../score-import/framework/pb/upsert-pb-pg.ts | 28 +- .../framework/pg/finalize-import-pg.ts | 145 +- .../framework/pg/mongo-score-to-pg.ts | 10 +- .../classes.oldtest.ts | 164 - .../profile-calculated-data/classes.test.ts | 138 + .../profile-calculated-data/classes.ts | 53 +- .../framework/quests/quests.oldtest.ts | 5 - .../framework/quests/quests.test.ts | 5 + .../score-import/framework/quests/quests.ts | 31 +- .../framework/score-import.oldtest.ts | 74 - .../framework/score-import.test.ts | 112 + .../score-import/framework/score-import.ts | 12 +- .../framework/score-importing/derivers.ts | 60 +- .../score-importing/hydrate-score.oldtest.ts | 5 - .../score-importing/hydrate-score.ts | 25 +- .../score-importing/insert-score.oldtest.ts | 157 - .../score-importing/insert-score.test.ts | 168 + .../framework/score-importing/insert-score.ts | 8 +- .../score-importing/score-id.oldtest.ts | 177 - .../score-importing/score-id.test.ts | 163 + .../framework/score-importing/score-id.ts | 16 +- .../score-import-error.oldtest.ts | 12 - .../score-import-error.test.ts | 12 + .../score-importing/score-import-main.ts | 97 +- .../score-importing.oldtest.ts | 9 - .../score-importing/score-importing.test.ts | 6 + .../score-importing/score-importing.ts | 29 +- .../score-importing/validate-score.oldtest.ts | 25 - .../score-importing/validate-score.test.ts | 24 + .../score-importing/validate-score.ts | 46 +- .../sessions/name-generation.oldtest.ts | 11 - .../sessions/name-generation.test.ts | 11 + .../framework/sessions/sessions.oldtest.ts | 368 - .../framework/sessions/sessions.test.ts | 137 + .../framework/sessions/sessions.ts | 101 +- .../ugpt-stats/update-ugpt-stats.oldtest.ts | 209 - .../ugpt-stats/update-ugpt-stats.test.ts | 269 + .../framework/ugpt-stats/update-ugpt-stats.ts | 36 +- .../import-types/api/eag-iidx/parser.ts | 3 +- .../import-types/api/eag-sdvx/parser.ts | 3 +- .../import-types/api/flo-iidx/parser.ts | 3 +- .../import-types/api/flo-sdvx/parser.ts | 3 +- .../import-types/api/min-sdvx/parser.ts | 3 +- .../api/myt-chunithm/converter.oldtest.ts | 254 - .../api/myt-chunithm/converter.test.ts | 273 + .../api/myt-chunithm/converter.ts | 12 +- .../import-types/api/myt-chunithm/parser.ts | 20 +- .../api/myt-maimaidx/converter.oldtest.ts | 213 - .../api/myt-maimaidx/converter.test.ts | 236 + .../api/myt-maimaidx/converter.ts | 19 +- .../import-types/api/myt-maimaidx/parser.ts | 14 +- .../api/myt-ongeki/converter.oldtest.ts | 189 - .../api/myt-ongeki/converter.test.ts | 213 + .../import-types/api/myt-ongeki/converter.ts | 16 +- .../import-types/api/myt-ongeki/parser.ts | 14 +- .../api/myt-wacca/class-handler.ts | 2 +- .../api/myt-wacca/converter.oldtest.ts | 115 - .../api/myt-wacca/converter.test.ts | 140 + .../import-types/api/myt-wacca/converter.ts | 18 +- .../import-types/api/myt-wacca/parser.ts | 13 +- .../common/api-cg/jb/converter.oldtest.ts | 271 - .../common/api-cg/jb/converter.test.ts | 301 + .../common/api-cg/jb/converter.ts | 18 +- .../common/api-cg/museca/converter.oldtest.ts | 106 - .../common/api-cg/museca/converter.test.ts | 133 + .../common/api-cg/museca/converter.ts | 22 +- .../import-types/common/api-cg/parsers.ts | 9 +- .../common/api-cg/popn/converter.oldtest.ts | 86 - .../common/api-cg/popn/converter.test.ts | 114 + .../common/api-cg/popn/converter.ts | 24 +- .../common/api-cg/sdvx/converter.oldtest.ts | 107 - .../common/api-cg/sdvx/converter.test.ts | 141 + .../common/api-cg/sdvx/converter.ts | 14 +- .../common/api-cg/traverse-api.ts | 6 +- ...ndler.oldtest.ts => class-handler.test.ts} | 131 +- .../common/api-kai/iidx/class-handler.ts | 26 +- .../common/api-kai/iidx/converter.oldtest.ts | 149 - .../common/api-kai/iidx/converter.test.ts | 183 + .../common/api-kai/iidx/converter.ts | 48 +- .../{parser.oldtest.ts => parser.test.ts} | 39 +- .../common/api-kai/iidx/parser.ts | 10 +- .../common/api-kai/reauth.test.ts | 8 +- .../import-types/common/api-kai/reauth.ts | 4 +- ...ndler.oldtest.ts => class-handler.test.ts} | 119 +- .../common/api-kai/sdvx/class-handler.ts | 4 +- .../common/api-kai/sdvx/converter.oldtest.ts | 152 - .../common/api-kai/sdvx/converter.test.ts | 161 + .../common/api-kai/sdvx/converter.ts | 33 +- .../{parser.oldtest.ts => parser.test.ts} | 39 +- .../common/api-kai/sdvx/parser.ts | 10 +- ...se-api.oldtest.ts => traverse-api.test.ts} | 83 +- .../common/api-kai/traverse-api.ts | 3 +- .../common/batch-manual/class-handler.ts | 25 +- .../common/batch-manual/converter.oldtest.ts | 619 - .../common/batch-manual/converter.test.ts | 26 +- .../common/batch-manual/converter.ts | 184 +- .../{parser.oldtest.ts => parser.test.ts} | 311 +- .../common/batch-manual/parser.ts | 89 +- .../import-types/common/batch-manual/types.ts | 7 +- .../eamusement-iidx-csv/converter.oldtest.ts | 31 - .../common/eamusement-iidx-csv/converter.ts | 40 +- .../{parser.oldtest.ts => parser.test.ts} | 125 +- .../common/eamusement-iidx-csv/parser.ts | 35 +- .../common/eamusement-iidx-csv/types.ts | 2 +- .../score-import/import-types/common/types.ts | 28 +- .../import-types/file/batch-manual/parser.ts | 13 +- .../file/eamusement-iidx-csv/parser.ts | 10 +- .../eamusement-sdvx-csv/converter.oldtest.ts | 132 - .../eamusement-sdvx-csv/converter.test.ts | 146 + .../file/eamusement-sdvx-csv/converter.ts | 12 +- .../eamusement-sdvx-csv/parser.oldtest.ts | 57 - .../file/eamusement-sdvx-csv/parser.test.ts | 45 + .../file/eamusement-sdvx-csv/parser.ts | 10 +- .../import-types/file/pli-iidx-csv/parser.ts | 10 +- .../solid-state-squad/converter.oldtest.ts | 214 - .../file/solid-state-squad/converter.test.ts | 283 + .../file/solid-state-squad/converter.ts | 53 +- .../file/solid-state-squad/parser.oldtest.ts | 260 - .../file/solid-state-squad/parser.test.ts | 195 + .../file/solid-state-squad/parser.ts | 10 +- .../wacca-mypage-scraper/class-handler.ts | 8 +- .../wacca-mypage-scraper/converter.oldtest.ts | 83 - .../wacca-mypage-scraper/converter.test.ts | 112 + .../file/wacca-mypage-scraper/converter.ts | 12 +- .../{parser.oldtest.ts => parser.test.ts} | 133 +- .../file/wacca-mypage-scraper/parser.ts | 13 +- .../ir/barbatos/converter.oldtest.ts | 194 - .../ir/barbatos/converter.test.ts | 156 + .../import-types/ir/barbatos/converter.ts | 22 +- .../ir/barbatos/parser.oldtest.ts | 35 - .../import-types/ir/barbatos/parser.test.ts | 26 + .../import-types/ir/barbatos/parser.ts | 14 +- .../import-types/ir/barbatos/types.ts | 4 +- .../import-types/ir/beatoraja/converter.ts | 134 +- .../import-types/ir/beatoraja/parser.ts | 11 +- .../import-types/ir/direct-manual/parser.ts | 11 +- .../fervidex-static/class-handler.oldtest.ts | 63 - .../ir/fervidex-static/class-handler.test.ts | 46 + .../ir/fervidex-static/class-handler.ts | 29 +- .../ir/fervidex-static/converter.oldtest.ts | 5 - .../ir/fervidex-static/converter.ts | 37 +- .../ir/fervidex-static/parser.oldtest.ts | 212 - .../ir/fervidex-static/parser.test.ts | 169 + .../import-types/ir/fervidex-static/parser.ts | 13 +- .../import-types/ir/fervidex-static/types.ts | 4 +- .../ir/fervidex/converter.oldtest.ts | 326 - .../ir/fervidex/converter.test.ts | 345 + .../import-types/ir/fervidex/converter.ts | 63 +- .../ir/fervidex/parser.oldtest.ts | 32 - .../import-types/ir/fervidex/parser.test.ts | 34 + .../import-types/ir/fervidex/parser.ts | 13 +- .../import-types/ir/fervidex/types.ts | 4 +- .../kshook-sv6c-static/converter.oldtest.ts | 65 - .../ir/kshook-sv6c-static/converter.test.ts | 88 + .../ir/kshook-sv6c-static/converter.ts | 10 +- .../ir/kshook-sv6c-static/parser.oldtest.ts | 56 - .../ir/kshook-sv6c-static/parser.test.ts | 46 + .../ir/kshook-sv6c-static/parser.ts | 7 +- .../ir/kshook-sv6c/converter.oldtest.ts | 68 - .../ir/kshook-sv6c/converter.test.ts | 94 + .../import-types/ir/kshook-sv6c/converter.ts | 14 +- .../ir/kshook-sv6c/parser.oldtest.ts | 61 - .../ir/kshook-sv6c/parser.test.ts | 52 + .../import-types/ir/kshook-sv6c/parser.ts | 7 +- .../ir/lr2hook/converter.oldtest.ts | 244 - .../import-types/ir/lr2hook/converter.test.ts | 193 + .../import-types/ir/lr2hook/converter.ts | 22 +- .../import-types/ir/lr2hook/parser.oldtest.ts | 178 - .../import-types/ir/lr2hook/parser.test.ts | 141 + .../import-types/ir/lr2hook/parser.ts | 7 +- .../import-types/ir/usc/converter.oldtest.ts | 135 - .../import-types/ir/usc/converter.test.ts | 195 + .../import-types/ir/usc/converter.ts | 34 +- .../import-types/ir/usc/parser.oldtest.ts | 37 - .../import-types/ir/usc/parser.test.ts | 37 + .../import-types/ir/usc/parser.ts | 13 +- .../score-import/import-types/ir/usc/types.ts | 5 +- .../src/lib/score-import/worker/types.ts | 4 +- .../src/lib/score-import/worker/worker.ts | 4 +- .../src/lib/score-mutation/delete-scores.ts | 44 +- typescript/server/src/lib/search/folders.ts | 64 +- typescript/server/src/lib/search/search.ts | 97 +- .../src/lib/search/session-search.test.ts | 2 +- .../server/src/lib/search/session-search.ts | 8 +- .../server/src/lib/search/song-charts.ts | 109 +- .../server/src/lib/search/songs.bench.ts | 2 +- .../server/src/lib/search/songs.test.ts | 32 +- typescript/server/src/lib/search/songs.ts | 31 +- .../src/lib/setup/build-email-config.test.ts | 117 + typescript/server/src/lib/setup/config.ts | 169 +- .../src/lib/showcase/evaluator.oldtest.ts | 81 - .../server/src/lib/showcase/evaluator.test.ts | 151 + .../server/src/lib/showcase/evaluator.ts | 100 +- .../server/src/lib/showcase/get-related.ts | 20 +- .../src/lib/showcase/get-stats.oldtest.ts | 132 - .../server/src/lib/showcase/get-stats.test.ts | 151 + .../server/src/lib/showcase/get-stats.ts | 27 +- .../server/src/lib/targets/goal-pb-queries.ts | 20 +- .../server/src/lib/targets/goal-utils.ts | 93 +- .../server/src/lib/targets/goals.oldtest.ts | 473 - .../server/src/lib/targets/goals.test.ts | 676 + typescript/server/src/lib/targets/goals.ts | 195 +- typescript/server/src/lib/targets/quests.ts | 62 +- .../server/src/lib/v3/migration-tools.test.ts | 16 +- .../server/src/lib/v3/migration-tools.ts | 50 +- typescript/server/src/scripts/anonymise-db.ts | 1 - .../server/src/scripts/load-seeds-pg.ts | 4 +- .../server/src/scripts/migrate-to-postgres.ts | 195 +- .../poc-folder-where-sql-raw.test.ts | 4 +- .../src/server/middleware/auth.oldtest.ts | 151 - .../server/src/server/middleware/auth.test.ts | 157 + .../server/src/server/middleware/auth.ts | 12 +- .../middleware/prudence-validate.oldtest.ts | 152 - .../middleware/prudence-validate.test.ts | 108 + .../src/server/middleware/request-logger.ts | 5 +- ...ng.oldtest.ts => sanitise-qstring.test.ts} | 18 +- .../src/server/middleware/update-last-seen.ts | 2 +- .../server/router/api/v1/activity/router.ts | 43 +- .../router/api/v1/admin/router.oldtest.ts | 132 - .../server/router/api/v1/admin/router.test.ts | 129 + .../src/server/router/api/v1/admin/router.ts | 486 +- .../server/router/api/v1/auth/router.test.ts | 5 +- .../src/server/router/api/v1/auth/router.ts | 400 +- .../api/v1/clients/middleware.oldtest.ts | 124 - .../router/api/v1/clients/middleware.test.ts | 62 + .../router/api/v1/clients/middleware.ts | 4 +- .../router/api/v1/clients/router.oldtest.ts | 470 - .../server/router/api/v1/clients/router.ts | 204 +- .../router/api/v1/config/router.oldtest.ts | 31 - .../router/api/v1/config/router.test.ts | 31 + .../src/server/router/api/v1/config/router.ts | 37 +- .../@gameSpecificRoutes/bms/router.oldtest.ts | 88 - .../@gameSpecificRoutes/bms/router.test.ts | 34 +- .../games/@gameSpecificRoutes/bms/router.ts | 177 +- .../games/@gameSpecificRoutes/iidx/router.ts | 87 +- .../v1/games/@gameSpecificRoutes/router.ts | 16 +- .../_playtype/charts/_chartID/middleware.ts | 22 - .../charts/_chartID/router.oldtest.ts | 98 - .../_game/_playtype/charts/_chartID/router.ts | 170 - .../_game/_playtype/charts/router.oldtest.ts | 220 - .../v1/games/_game/_playtype/charts/router.ts | 180 - .../_game/_playtype/folders/middleware.ts | 21 - .../_game/_playtype/folders/router.oldtest.ts | 80 - .../games/_game/_playtype/folders/router.ts | 65 - .../v1/games/_game/_playtype/middleware.ts | 38 - .../games/_game/_playtype/router.oldtest.ts | 165 - .../api/v1/games/_game/_playtype/router.ts | 277 - .../_playtype/songs/_songID/middleware.ts | 31 - .../_playtype/songs/_songID/router.oldtest.ts | 41 - .../_game/_playtype/songs/_songID/router.ts | 34 - .../_game/_playtype/tables/middleware.ts | 26 - .../_game/_playtype/tables/router.oldtest.ts | 51 - .../v1/games/_game/_playtype/tables/router.ts | 66 - .../_playtype/targets/goals/router.oldtest.ts | 94 - .../_game/_playtype/targets/goals/router.ts | 186 - .../targets/questlines/router.oldtest.ts | 96 - .../_playtype/targets/questlines/router.ts | 70 - .../targets/quests/router.oldtest.ts | 124 - .../_game/_playtype/targets/quests/router.ts | 129 - .../_game/_playtype/targets/router.oldtest.ts | 102 - .../games/_game/_playtype/targets/router.ts | 74 - .../api/v1/games/_game/router.oldtest.ts | 23 - .../router/api/v1/games/_game/router.ts | 29 - .../_playtype/charts/_chartID/middleware.ts | 21 + .../_playtype/charts/_chartID/router.test.ts | 8 +- .../_playtype/charts/_chartID/router.ts | 1 + .../_playtype/charts/router.test.ts | 95 + .../_gameGroup/_playtype/charts/router.ts | 1 + .../_playtype/folders/middleware.ts | 23 + .../_playtype/folders/router.test.ts | 135 + .../_gameGroup/_playtype/folders/router.ts | 1 + .../games/_gameGroup/_playtype/middleware.ts | 79 + .../games/_gameGroup/_playtype/router.test.ts | 210 + .../v1/games/_gameGroup/_playtype/router.ts | 1 + .../_playtype/songs/_songID/middleware.ts | 21 + .../_playtype/songs/_songID/router.test.ts | 40 +- .../_playtype/songs/_songID/router.ts | 1 + .../_gameGroup/_playtype/tables/middleware.ts | 21 + .../_playtype/tables/router.test.ts | 110 + .../_gameGroup/_playtype/tables/router.ts | 1 + .../_playtype/targets/goals/router.test.ts | 5 + .../_playtype/targets/goals/router.ts | 1 + .../targets/questlines/router.test.ts | 8 +- .../_playtype/targets/questlines/router.ts | 1 + .../_playtype/targets/quests/router.test.ts | 5 + .../_playtype/targets/quests/router.ts | 1 + .../_playtype/targets/router.test.ts | 5 + .../_gameGroup/_playtype/targets/router.ts | 1 + .../games/{_game => _gameGroup}/middleware.ts | 10 +- .../api/v1/games/_gameGroup/router.test.ts | 26 + .../router/api/v1/games/_gameGroup/router.ts | 1 + .../router/api/v1/games/router.oldtest.ts | 27 - .../server/router/api/v1/games/router.test.ts | 24 + .../src/server/router/api/v1/games/router.ts | 864 +- .../router/api/v1/import/router.oldtest.ts | 393 - .../router/api/v1/import/router.test.ts | 10 +- .../src/server/router/api/v1/import/router.ts | 137 +- .../router/api/v1/imports/middleware.ts | 6 +- .../router/api/v1/imports/router.oldtest.ts | 166 - .../router/api/v1/imports/router.test.ts | 224 + .../server/router/api/v1/imports/router.ts | 272 +- .../server/router/api/v1/localdev/router.ts | 46 +- .../src/server/router/api/v1/oauth/router.ts | 72 +- .../server/router/api/v1/router.oldtest.ts | 39 - .../src/server/router/api/v1/router.test.ts | 35 + .../server/src/server/router/api/v1/router.ts | 98 +- .../api/v1/scores/_scoreID/middleware.ts | 6 +- .../api/v1/scores/_scoreID/router.oldtest.ts | 292 - .../api/v1/scores/_scoreID/router.test.ts | 311 + .../router/api/v1/scores/_scoreID/router.ts | 297 +- .../src/server/router/api/v1/scores/router.ts | 10 - .../router/api/v1/search/router.oldtest.ts | 55 - .../router/api/v1/search/router.test.ts | 40 +- .../src/server/router/api/v1/search/router.ts | 94 +- .../src/server/router/api/v1/seeds/router.ts | 283 +- .../api/v1/sessions/_sessionID/middleware.ts | 7 +- .../v1/sessions/_sessionID/router.oldtest.ts | 204 - .../api/v1/sessions/_sessionID/router.test.ts | 8 +- .../api/v1/sessions/_sessionID/router.ts | 379 +- .../server/router/api/v1/sessions/router.ts | 11 +- .../server/src/server/router/api/v1/spec.ts | 1945 ++ .../router/api/v1/status/router.oldtest.ts | 84 - .../router/api/v1/status/router.test.ts | 91 + .../src/server/router/api/v1/status/router.ts | 27 +- .../_userID/api-tokens/router.oldtest.ts | 289 - .../users/_userID/api-tokens/router.test.ts | 238 + .../api/v1/users/_userID/api-tokens/router.ts | 118 +- .../v1/users/_userID/banner/router.oldtest.ts | 88 - .../v1/users/_userID/banner/router.test.ts | 83 + .../api/v1/users/_userID/banner/router.ts | 50 +- .../api/v1/users/_userID/following/router.ts | 65 +- .../@gameSpecificRoutes/bms/router.test.ts | 32 +- .../games/@gameSpecificRoutes/bms/router.ts | 172 +- .../games/@gameSpecificRoutes/iidx/router.ts | 367 +- .../@gameSpecificRoutes/jubeat/router.ts | 46 +- .../games/@gameSpecificRoutes/router.ts | 14 +- .../folders/_folderID/router.oldtest.ts | 204 - .../_playtype/folders/_folderID/router.ts | 171 - .../{_folderID => _folderSlug}/router.test.ts | 20 +- .../_playtype/folders/_folderSlug/router.ts | 168 + .../games/_game/_playtype/folders/router.ts | 96 +- .../games/_game/_playtype/middleware.ts | 65 - .../_game/_playtype/pbs/router.oldtest.ts | 297 - .../games/_game/_playtype/pbs/router.test.ts | 32 +- .../games/_game/_playtype/pbs/router.ts | 459 +- .../_game/_playtype/rivals/router.oldtest.ts | 190 - .../_game/_playtype/rivals/router.test.ts | 151 + .../games/_game/_playtype/rivals/router.ts | 185 +- .../games/_game/_playtype/router.oldtest.ts | 300 - .../games/_game/_playtype/router.test.ts | 105 + .../_userID/games/_game/_playtype/router.ts | 495 +- .../_game/_playtype/scores/router.oldtest.ts | 239 - .../_game/_playtype/scores/router.test.ts | 10 +- .../games/_game/_playtype/scores/router.ts | 167 +- .../_playtype/sessions/router.oldtest.ts | 244 - .../_game/_playtype/sessions/router.test.ts | 34 +- .../games/_game/_playtype/sessions/router.ts | 328 +- .../_playtype/settings/router.oldtest.ts | 440 - .../_game/_playtype/settings/router.test.ts | 93 + .../games/_game/_playtype/settings/router.ts | 138 +- .../_playtype/showcase/router.oldtest.ts | 434 - .../_game/_playtype/showcase/router.test.ts | 12 +- .../games/_game/_playtype/showcase/router.ts | 432 +- .../_game/_playtype/tables/router.oldtest.ts | 47 - .../_game/_playtype/tables/router.test.ts | 127 + .../games/_game/_playtype/tables/router.ts | 48 +- .../_playtype/targets/goals/router.oldtest.ts | 708 - .../_playtype/targets/goals/router.test.ts | 7 + .../_game/_playtype/targets/goals/router.ts | 339 +- .../targets/quests/router.oldtest.ts | 248 - .../_playtype/targets/quests/router.test.ts | 5 + .../_game/_playtype/targets/quests/router.ts | 322 +- .../_game/_playtype/targets/router.oldtest.ts | 163 - .../_game/_playtype/targets/router.test.ts | 7 + .../games/_game/_playtype/targets/router.ts | 410 +- .../users/_userID/imports/router.oldtest.ts | 4 - .../v1/users/_userID/imports/router.test.ts | 9 + .../api/v1/users/_userID/imports/router.ts | 83 +- .../_userID/integrations/cg/_cgType/router.ts | 139 +- .../integrations/fervidex/router.oldtest.ts | 138 - .../integrations/fervidex/router.test.ts | 14 +- .../_userID/integrations/fervidex/router.ts | 123 +- .../kai/_kaiType/middleware.oldtest.ts | 33 - .../kai/_kaiType/middleware.test.ts | 32 + .../integrations/kai/_kaiType/router.ts | 211 +- .../kshook-sv6c/router.oldtest.ts | 111 - .../integrations/kshook-sv6c/router.ts | 74 +- .../users/_userID/integrations/myt/router.ts | 97 +- .../v1/users/_userID/integrations/router.ts | 18 +- .../users/_userID/invites/router.oldtest.ts | 169 - .../v1/users/_userID/invites/router.test.ts | 215 + .../api/v1/users/_userID/invites/router.ts | 108 +- .../router/api/v1/users/_userID/middleware.ts | 10 +- .../_userID/notifications/router.oldtest.ts | 197 - .../_userID/notifications/router.test.ts | 270 + .../v1/users/_userID/notifications/router.ts | 110 +- .../v1/users/_userID/pfp/router.oldtest.ts | 87 - .../api/v1/users/_userID/pfp/router.test.ts | 82 + .../router/api/v1/users/_userID/pfp/router.ts | 50 +- .../api/v1/users/_userID/router.oldtest.ts | 481 - .../router/api/v1/users/_userID/router.ts | 590 +- .../api/v1/users/_userID/sessions/router.ts | 23 +- .../users/_userID/settings/router.oldtest.ts | 205 - .../api/v1/users/_userID/settings/router.ts | 66 +- .../router/api/v1/users/router.oldtest.ts | 77 - .../server/router/api/v1/users/router.test.ts | 6 +- .../src/server/router/api/v1/users/router.ts | 40 +- .../router/ir/barbatos/router.oldtest.ts | 63 - .../server/router/ir/barbatos/router.test.ts | 104 + .../router/ir/beatoraja/auth.oldtest.ts | 48 - .../server/router/ir/beatoraja/auth.test.ts | 40 + .../_chartSHA256/convert-scores.oldtest.ts | 158 - .../_chartSHA256/convert-scores.test.ts | 111 + .../charts/_chartSHA256/convert-scores.ts | 4 +- .../charts/_chartSHA256/router.oldtest.ts | 82 - .../charts/_chartSHA256/router.test.ts | 125 + .../beatoraja/charts/_chartSHA256/router.ts | 12 +- .../router/ir/beatoraja/router.oldtest.ts | 609 - .../server/router/ir/beatoraja/router.test.ts | 191 + .../src/server/router/ir/beatoraja/router.ts | 25 +- .../router/ir/direct-manual/router.oldtest.ts | 270 - .../router/ir/direct-manual/router.test.ts | 317 + .../router/ir/fervidex/router.oldtest.ts | 652 - .../server/router/ir/fervidex/router.test.ts | 438 + .../src/server/router/ir/fervidex/router.ts | 23 +- .../router/ir/lr2hook/router.oldtest.ts | 150 - .../server/router/ir/lr2hook/router.test.ts | 197 + .../src/server/router/ir/lr2hook/router.ts | 9 +- .../server/src/server/router/ir/router.ts | 10 +- .../router/ir/usc/_playtype/router.oldtest.ts | 881 - .../router/ir/usc/_playtype/router.test.ts | 141 + .../server/router/ir/usc/_playtype/router.ts | 58 +- .../router/ir/usc/_playtype/usc.oldtest.ts | 415 - .../router/ir/usc/_playtype/usc.test.ts | 169 + .../src/server/router/ir/usc/_playtype/usc.ts | 39 +- typescript/server/src/services/pg/seeds.ts | 169 +- typescript/server/src/test-utils/misc.ts | 103 +- typescript/server/src/test-utils/test-data.ts | 514 +- typescript/server/src/utils/chart-mongo-id.ts | 20 - typescript/server/src/utils/class.test.ts | 6 +- typescript/server/src/utils/class.ts | 70 +- typescript/server/src/utils/db.ts | 117 +- typescript/server/src/utils/folder.ts | 2 +- typescript/server/src/utils/misc.ts | 40 +- .../server/src/utils/naive-csv-parser.test.ts | 2 +- .../server/src/utils/naive-csv-parser.ts | 2 +- .../server/src/utils/queries/api-clients.ts | 4 +- typescript/server/src/utils/queries/auth.ts | 4 +- .../server/src/utils/queries/charts.test.ts | 6 +- typescript/server/src/utils/queries/charts.ts | 317 +- typescript/server/src/utils/queries/pbs.ts | 10 +- .../server/src/utils/queries/questlines.ts | 29 +- .../server/src/utils/queries/scores.test.ts | 6 +- typescript/server/src/utils/queries/scores.ts | 75 +- .../server/src/utils/queries/sessions.ts | 30 +- .../server/src/utils/queries/songs.test.ts | 10 +- typescript/server/src/utils/queries/songs.ts | 16 +- typescript/server/src/utils/queries/stats.ts | 12 +- .../server/src/utils/queries/summary.ts | 9 +- typescript/server/src/utils/req-tachi-data.ts | 51 +- .../reset-state/destroy-user-game-profile.ts | 28 +- typescript/server/src/utils/scores.test.ts | 96 +- typescript/server/src/utils/scores.ts | 34 +- .../server/src/utils/session.oldtest.ts | 31 - typescript/server/src/utils/session.test.ts | 16 +- typescript/server/src/utils/session.ts | 6 +- typescript/server/src/utils/string-checks.ts | 23 +- typescript/server/src/utils/types.ts | 80 +- typescript/server/src/utils/user.oldtest.ts | 105 - typescript/server/src/utils/user.test.ts | 90 +- typescript/server/src/utils/user.ts | 67 +- typescript/server/tsconfig.json | 2 +- typescript/server/vitest.config.ts | 16 +- typescript/server/vitest.setup.ts | 40 +- typescript/sieglinde/package.json | 1 + typescript/sieglinde/src/calc/util/calc.ts | 3 +- typescript/sieglinde/src/calc/util/lerp.ts | 2 +- typescript/sieglinde/src/fetch-tables.ts | 8 +- typescript/sieglinde/src/main.ts | 12 +- .../sieglinde/src/scripts/fetch-chart-data.ts | 10 +- typescript/sieglinde/src/types.ts | 4 +- typescript/sieglinde/vitest.config.ts | 8 + 1073 files changed, 52259 insertions(+), 56015 deletions(-) delete mode 100644 db/migrations/20260405120000_dirty_queues.sql create mode 100644 typescript/common/src/lib/folder-slug.ts delete mode 100644 typescript/db/src/placeholder.test.ts create mode 100644 typescript/db/vitest.config.ts create mode 100644 typescript/eslint-config/rules/tachi-imports-plugin.js create mode 100644 typescript/seeds-scripts/rerunners/folder-slugs/add-folder-slugs.ts create mode 100644 typescript/seeds-scripts/rerunners/folder-slugs/add-iidx-folder-slugs.ts create mode 100644 typescript/seeds-scripts/rerunners/folder-slugs/apply-folder-slugs.ts create mode 100644 typescript/seeds-scripts/rerunners/folder-slugs/folder-slug.ts create mode 100644 typescript/seeds-scripts/rerunners/v3/6-bms-course-lookup-playtype-to-game.ts create mode 100644 typescript/seeds-scripts/rerunners/v3/6-tables-folder-refs-to-slugs.ts delete mode 100644 typescript/server/src/game-implementations/games/arcaea.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/arcaea.test.ts create mode 100644 typescript/server/src/game-implementations/games/bms-pms.impl.test.ts delete mode 100644 typescript/server/src/game-implementations/games/bms-pms.oldtest.ts delete mode 100644 typescript/server/src/game-implementations/games/chunithm.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/chunithm.test.ts delete mode 100644 typescript/server/src/game-implementations/games/ddr.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/ddr.test.ts delete mode 100644 typescript/server/src/game-implementations/games/gitadora.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/gitadora.test.ts create mode 100644 typescript/server/src/game-implementations/games/iidx.impl.extra.test.ts create mode 100644 typescript/server/src/game-implementations/games/iidx.impl.test.ts delete mode 100644 typescript/server/src/game-implementations/games/iidx.oldtest.ts delete mode 100644 typescript/server/src/game-implementations/games/jubeat.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/jubeat.test.ts delete mode 100644 typescript/server/src/game-implementations/games/maimai.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/maimai.test.ts delete mode 100644 typescript/server/src/game-implementations/games/maimaidx.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/maimaidx.test.ts delete mode 100644 typescript/server/src/game-implementations/games/museca.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/museca.test.ts delete mode 100644 typescript/server/src/game-implementations/games/ongeki.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/ongeki.test.ts delete mode 100644 typescript/server/src/game-implementations/games/popn.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/popn.test.ts delete mode 100644 typescript/server/src/game-implementations/games/sdvx.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/sdvx.test.ts delete mode 100644 typescript/server/src/game-implementations/games/usc.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/usc.test.ts delete mode 100644 typescript/server/src/game-implementations/games/wacca.oldtest.ts create mode 100644 typescript/server/src/game-implementations/games/wacca.test.ts delete mode 100644 typescript/server/src/lib/game-settings/create-game-settings.oldtest.ts create mode 100644 typescript/server/src/lib/game-settings/create-game-settings.test.ts delete mode 100644 typescript/server/src/lib/imports/imports.oldtest.ts create mode 100644 typescript/server/src/lib/imports/imports.test.ts delete mode 100644 typescript/server/src/lib/invites/invites.oldtest.ts create mode 100644 typescript/server/src/lib/invites/invites.test.ts delete mode 100644 typescript/server/src/lib/notifications/notifications.oldtest.ts create mode 100644 typescript/server/src/lib/router/middleware.ts create mode 100644 typescript/server/src/lib/router/typed-router.ts delete mode 100644 typescript/server/src/lib/score-import/framework/common/string-asserts.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/common/string-asserts.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/goals/goals.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/goals/goals.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/orphans/orphans.oldtest.ts delete mode 100644 typescript/server/src/lib/score-import/framework/pb/create-pb-doc.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/pb/process-pbs.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/pb/process-pbs.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/quests/quests.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/quests/quests.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/score-import.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/score-import.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.oldtest.ts delete mode 100644 typescript/server/src/lib/score-import/framework/score-importing/insert-score.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/score-importing/insert-score.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/score-importing/score-id.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/score-importing/score-import-error.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/score-importing/score-import-error.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/score-importing/score-importing.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/score-importing/score-importing.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/score-importing/validate-score.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/score-importing/validate-score.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/sessions/name-generation.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/sessions/name-generation.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/sessions/sessions.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/sessions/sessions.test.ts delete mode 100644 typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts rename typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/{class-handler.oldtest.ts => class-handler.test.ts} (58%) delete mode 100644 typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.test.ts rename typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/{parser.oldtest.ts => parser.test.ts} (64%) rename typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/{class-handler.oldtest.ts => class-handler.test.ts} (56%) delete mode 100644 typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.test.ts rename typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/{parser.oldtest.ts => parser.test.ts} (64%) rename typescript/server/src/lib/score-import/import-types/common/api-kai/{traverse-api.oldtest.ts => traverse-api.test.ts} (64%) delete mode 100644 typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.oldtest.ts rename typescript/server/src/lib/score-import/import-types/common/batch-manual/{parser.oldtest.ts => parser.test.ts} (69%) delete mode 100644 typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/converter.oldtest.ts rename typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/{parser.oldtest.ts => parser.test.ts} (59%) delete mode 100644 typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.test.ts rename typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/{parser.oldtest.ts => parser.test.ts} (52%) delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex-static/converter.oldtest.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/usc/converter.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/usc/converter.test.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/ir/usc/parser.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/ir/usc/parser.test.ts create mode 100644 typescript/server/src/lib/setup/build-email-config.test.ts delete mode 100644 typescript/server/src/lib/showcase/evaluator.oldtest.ts create mode 100644 typescript/server/src/lib/showcase/evaluator.test.ts delete mode 100644 typescript/server/src/lib/showcase/get-stats.oldtest.ts create mode 100644 typescript/server/src/lib/showcase/get-stats.test.ts delete mode 100644 typescript/server/src/lib/targets/goals.oldtest.ts create mode 100644 typescript/server/src/lib/targets/goals.test.ts delete mode 100644 typescript/server/src/server/middleware/auth.oldtest.ts create mode 100644 typescript/server/src/server/middleware/auth.test.ts delete mode 100644 typescript/server/src/server/middleware/prudence-validate.oldtest.ts create mode 100644 typescript/server/src/server/middleware/prudence-validate.test.ts rename typescript/server/src/server/middleware/{sanitise-qstring.oldtest.ts => sanitise-qstring.test.ts} (52%) delete mode 100644 typescript/server/src/server/router/api/v1/admin/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/admin/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/clients/middleware.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/clients/middleware.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/clients/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/config/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/config/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/middleware.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/middleware.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/middleware.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/middleware.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/middleware.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/_game/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/middleware.ts rename typescript/server/src/server/router/api/v1/games/{_game => _gameGroup}/_playtype/charts/_chartID/router.test.ts (87%) create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/middleware.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/middleware.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/middleware.ts rename typescript/server/src/server/router/api/v1/games/{_game => _gameGroup}/_playtype/songs/_songID/router.test.ts (78%) create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/middleware.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.ts rename typescript/server/src/server/router/api/v1/games/{_game => _gameGroup}/_playtype/targets/questlines/router.test.ts (91%) create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/questlines/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/router.ts rename typescript/server/src/server/router/api/v1/games/{_game => _gameGroup}/middleware.ts (64%) create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/games/_gameGroup/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/games/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/games/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/import/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/imports/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/imports/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/scores/_scoreID/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/scores/_scoreID/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/search/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/sessions/_sessionID/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/spec.ts delete mode 100644 typescript/server/src/server/router/api/v1/status/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/status/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/banner/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/banner/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts rename typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/{_folderID => _folderSlug}/router.test.ts (91%) create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/middleware.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/imports/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/imports/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/invites/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/invites/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/notifications/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/pfp/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/settings/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/router.oldtest.ts delete mode 100644 typescript/server/src/server/router/ir/barbatos/router.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/barbatos/router.test.ts delete mode 100644 typescript/server/src/server/router/ir/beatoraja/auth.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/beatoraja/auth.test.ts delete mode 100644 typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts delete mode 100644 typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.test.ts delete mode 100644 typescript/server/src/server/router/ir/beatoraja/router.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/beatoraja/router.test.ts delete mode 100644 typescript/server/src/server/router/ir/direct-manual/router.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/direct-manual/router.test.ts delete mode 100644 typescript/server/src/server/router/ir/fervidex/router.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/fervidex/router.test.ts delete mode 100644 typescript/server/src/server/router/ir/lr2hook/router.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/lr2hook/router.test.ts delete mode 100644 typescript/server/src/server/router/ir/usc/_playtype/router.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/usc/_playtype/router.test.ts delete mode 100644 typescript/server/src/server/router/ir/usc/_playtype/usc.oldtest.ts create mode 100644 typescript/server/src/server/router/ir/usc/_playtype/usc.test.ts delete mode 100644 typescript/server/src/utils/chart-mongo-id.ts delete mode 100644 typescript/server/src/utils/session.oldtest.ts delete mode 100644 typescript/server/src/utils/user.oldtest.ts diff --git a/.cursor/skills/no-oldtest-edits/SKILL.md b/.cursor/skills/no-oldtest-edits/SKILL.md index d28dd9131..411a27888 100644 --- a/.cursor/skills/no-oldtest-edits/SKILL.md +++ b/.cursor/skills/no-oldtest-edits/SKILL.md @@ -1,19 +1,10 @@ --- name: no-oldtest-edits -description: Treats `*.oldtest.ts` files as read-only legacy snapshots. Never edits them. Use when changing tests, game implementations, or when any path matches `*.oldtest.ts`. +description: Legacy. `*.oldtest.ts` snapshots were removed from the Tachi server after migration to Vitest `*.test.ts`. No action needed unless oldtest files are reintroduced. --- -# No edits to `*.oldtest.ts` +# Legacy note -## Hard rule +Server tests live in `*.test.ts` under `typescript/server/src/`. The former Tap `*.oldtest.ts` tree has been deleted. -**Do not modify** any file whose name matches `*.oldtest.ts` (for example `jubeat.oldtest.ts`). These are frozen reference tests. - -## What to do instead - -- Make test or implementation changes in the corresponding non-`oldtest` files (for example `*.test.ts` next to the implementation, or the main `*.ts` source). -- If behavior must be verified against old expectations, add or update tests outside `*.oldtest.ts`. - -## Checklist before finishing a task - -- [ ] No file edited under a `*.oldtest.ts` path. +If `*.oldtest.ts` files appear again, treat them as read-only snapshots and add or change behavior only in `*.test.ts` or source files. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b8577e8d8..6bc17f1a7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,12 @@ "service": "tachi-dev", "workspaceFolder": "/tachi", "containerEnv": { - "MINIO_ENDPOINT": "http://tachi-s3:9000" + "MINIO_ENDPOINT": "http://tachi-s3:9000", + "TACHI_EMAIL_FROM": "dev@localhost", + "TACHI_EMAIL_AUTH_POSTMARK": "false", + "TACHI_EMAIL_HOST": "tachi-mailpit", + "TACHI_EMAIL_PORT": "1025", + "TACHI_EMAIL_SECURE": "false" }, "postAttachCommand": "./dev/bootstrap.sh", "updateRemoteUserUID": true, diff --git a/.vscode/settings.json b/.vscode/settings.json index c9731932f..c3df51b3f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -44,5 +44,6 @@ "files.associations": { "Justfile-*": "makefile" }, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "remote.autoForwardPortsFallback": 0 } diff --git a/Justfile-apps b/Justfile-apps index 45adba16e..ade51026e 100644 --- a/Justfile-apps +++ b/Justfile-apps @@ -14,7 +14,7 @@ start: server: - cd typescript/server && \ + cd typescript/server && \ VERSION=$(jq -r .version package.json) \ COMMIT_HASH=$(git rev-parse --short HEAD) \ bun --watch src/main.ts diff --git a/Justfile-misc b/Justfile-misc index 9ce90c798..310c607ba 100644 --- a/Justfile-misc +++ b/Justfile-misc @@ -1,4 +1,4 @@ # Misc nonsense count-lines-of-code: - tokei -e node_modules/ -e vendor/ -e pnpm-lock.yaml + tokei -e node_modules/ -e vendor/ -e bun.lock diff --git a/Justfile-test b/Justfile-test index 5c0a7171a..587b93b02 100644 --- a/Justfile-test +++ b/Justfile-test @@ -68,7 +68,7 @@ test-typescript FILTER="*": #!/bin/bash set -euo pipefail - bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml + bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml # Summarize Vitest v8 coverage across workspaces (see typescript/coverage-tools). coverage-report *ARGS: diff --git a/README.md b/README.md index f289d0e90..e05ddf915 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ That said, if you're against using vscode you can: If those instructions don't make sense to you, I really recommend just using dev containers. -Alternatively **if you want to work outside of docker you're on your own**. Figure out the correct versions for everything (npm, pnpm, mongo, redis, ts-node...) and `dev/bootstrap.sh`. +Alternatively **if you want to work outside of docker you're on your own**. Figure out the correct versions for everything (bun, postgres, redis, ts-node...) and `dev/bootstrap.sh`. ## Repository Info diff --git a/bun.lock b/bun.lock index dc7877449..1d2addcc3 100644 --- a/bun.lock +++ b/bun.lock @@ -238,6 +238,7 @@ "eslint-plugin-react": "catalog:", "eslint-plugin-react-hooks": "catalog:", "eslint-plugin-react-refresh": "catalog:", + "eslint-plugin-unused-imports": "catalog:", "globals": "catalog:", "typescript-eslint": "catalog:", }, @@ -310,7 +311,7 @@ }, "typescript/server": { "name": "tachi-server", - "version": "2.5.1", + "version": "3.0.0", "dependencies": { "@aws-sdk/client-s3": "catalog:", "@types/bcryptjs": "catalog:", @@ -393,6 +394,7 @@ }, "devDependencies": { "@typescript/native-preview": "catalog:", + "@vitest/coverage-v8": "catalog:", "eslint-config-tachi": "workspace:*", "vitest": "catalog:", }, @@ -487,6 +489,7 @@ "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.14", + "eslint-plugin-unused-imports": "^4.1.4", "express": "4.20.0", "express-async-errors": "3.1.1", "express-prom-bundle": "^7.0.0", @@ -1029,6 +1032,8 @@ "@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + "@package-json/types": ["@package-json/types@0.0.12", "", {}, "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], @@ -1581,6 +1586,8 @@ "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="], + "commist": ["commist@1.1.0", "", { "dependencies": { "leven": "^2.1.0", "minimist": "^1.1.0" } }, "sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg=="], "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], @@ -1825,6 +1832,8 @@ "eslint-config-tachi": ["eslint-config-tachi@workspace:typescript/eslint-config"], + "eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="], + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], @@ -1833,6 +1842,8 @@ "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + "eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.2", "", { "dependencies": { "@package-json/types": "^0.0.12", "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw=="], + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@3.9.1", "", { "dependencies": { "@typescript-eslint/types": "^8.9.0", "@typescript-eslint/utils": "^8.9.0", "minimatch": "^9.0.5", "natural-compare-lite": "^1.4.0" }, "peerDependencies": { "astro-eslint-parser": "^1.0.2", "eslint": ">=8.0.0", "svelte": ">=3.0.0", "svelte-eslint-parser": "^0.41.1", "vue-eslint-parser": ">=9.0.0" }, "optionalPeers": ["astro-eslint-parser", "svelte", "svelte-eslint-parser", "vue-eslint-parser"] }, "sha512-9WRzf6XaAxF4Oi5t/3TqKP5zUjERhasHmLFHin2Yw6ZAp/EP/EVA2dr3BhQrrHWCm5SzTMZf0FcjDnBkO2xFkA=="], @@ -1843,6 +1854,8 @@ "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], + "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="], + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-utils": ["eslint-utils@3.0.0", "", { "dependencies": { "eslint-visitor-keys": "^2.0.0" }, "peerDependencies": { "eslint": ">=5" } }, "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA=="], @@ -2757,6 +2770,8 @@ "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -3207,6 +3222,10 @@ "eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + "eslint-plugin-import-x/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "eslint-plugin-import-x/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "eslint-plugin-perfectionist/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], "eslint-plugin-perfectionist/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -3425,6 +3444,8 @@ "duplexify/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "eslint-plugin-import-x/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "eslint-plugin-perfectionist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "express-session/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3487,6 +3508,8 @@ "@vitest/mocker/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.15.18", "", { "os": "linux", "cpu": "none" }, "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ=="], + "eslint-plugin-import-x/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/db/migrations/20260301154256_genesis.sql b/db/migrations/20260301154256_genesis.sql index 0bd9f1a1f..21cc13ffb 100644 --- a/db/migrations/20260301154256_genesis.sql +++ b/db/migrations/20260301154256_genesis.sql @@ -415,7 +415,7 @@ CREATE TABLE "bms_course_lookup" ( ), title TEXT NOT NULL UNIQUE, set TEXT NOT NULL, - playtype TEXT NOT NULL, + game GAME NOT NULL, value TEXT NOT NULL ); @@ -427,7 +427,7 @@ CREATE TABLE "folder" ( title TEXT NOT NULL, -- Used in URLs. should be short, but must be unique per game! - slug TEXT, + slug TEXT NOT NULL, -- SQL predicate (no leading WHERE) for charts in this folder; matches seeds `where`. -- Quoted: "where" is a reserved word in SQL. @@ -437,7 +437,7 @@ CREATE TABLE "folder" ( version_filter TEXT[], search_terms TEXT[] NOT NULL ); -CREATE UNIQUE INDEX folder_unique_slug_game ON "folder" (game, slug) WHERE slug IS NOT NULL; +CREATE UNIQUE INDEX folder_unique_slug_game ON "folder" (game, slug); CREATE TABLE "table" ( -- heh id TEXT PRIMARY KEY NOT NULL, @@ -458,6 +458,8 @@ CREATE UNIQUE INDEX one_default_table_per_game ON "table" (game) WHERE default_v CREATE TABLE "table_folder" ( table_id TEXT REFERENCES "table"(id) NOT NULL, folder_id TEXT REFERENCES folder(id) NOT NULL, + -- Order within the table; matches the `folders` array index in `tables.json`. + ordering INTEGER NOT NULL, PRIMARY KEY (table_id, folder_id) ); @@ -522,7 +524,11 @@ CREATE TABLE "chart" ( versions TEXT[] NOT NULL, - data JSONB NOT NULL -- game specific payload + data JSONB NOT NULL, -- game specific payload + + -- SHA-256 hex digest of chart fields that feed scoreDeriver/scoreCalcs. When this + -- changes, scores on the chart need re-derivation. + derivation_checksum TEXT ); -- Denormalized chart → folders cache (rebuilt by app; see rebuildFolderChartLookup). @@ -750,6 +756,58 @@ CREATE TABLE "score" ( committed BOOLEAN NOT NULL DEFAULT TRUE ); +-- Mark here when PBs become dirty and need to be recalculated. +CREATE TABLE pb_dirty ( + user_id BIGINT NOT NULL, + chart_id TEXT NOT NULL, + enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, chart_id) +); + +-- Mark here when scores become dirty and need to be recalculated. +CREATE TABLE "score_rederive" ( + chart_id TEXT NOT NULL PRIMARY KEY, + enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Trigger function: on any score INSERT/UPDATE/DELETE, mark the (user_id, chart_id) +-- pair as needing PB recalculation. +CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + INSERT INTO pb_dirty (user_id, chart_id) + VALUES (OLD.user_id, OLD.chart_id) + ON CONFLICT DO NOTHING; + ELSE + INSERT INTO pb_dirty (user_id, chart_id) + VALUES (NEW.user_id, NEW.chart_id) + ON CONFLICT DO NOTHING; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "score_pb_dirty" + AFTER INSERT OR UPDATE OR DELETE ON score + FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty(); + +-- Trigger function: on chart UPDATE, if derivation_checksum changed, enqueue the chart +-- for score re-derivation. +CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$ +BEGIN + IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN + INSERT INTO score_rederive (chart_id) + VALUES (NEW.id) + ON CONFLICT DO NOTHING; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "chart_score_rederive" + AFTER UPDATE ON chart + FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive(); + CREATE TABLE "pb" ( row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(), diff --git a/db/migrations/20260405120000_dirty_queues.sql b/db/migrations/20260405120000_dirty_queues.sql deleted file mode 100644 index ff7a792cc..000000000 --- a/db/migrations/20260405120000_dirty_queues.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Derivation checksum on chart: a SHA-256 hex digest of the chart fields --- that feed into scoreDeriver/scoreCalcs. When this changes, all scores --- on the chart need re-derivation. -ALTER TABLE chart ADD COLUMN derivation_checksum TEXT; - --- Dirty queue: score mutations enqueue (user_id, chart_id) pairs that --- need PB recalculation. Deduplication via the PK. -CREATE TABLE pb_dirty ( - user_id BIGINT NOT NULL, - chart_id TEXT NOT NULL, - enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, chart_id) -); - --- Dirty queue: chart mutations (when derivation_checksum changes) enqueue --- chart_ids whose scores need re-derivation. One row per chart. -CREATE TABLE score_rederive ( - chart_id TEXT NOT NULL PRIMARY KEY, - enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Trigger function: on any score INSERT/UPDATE/DELETE, mark the --- (user_id, chart_id) pair as needing PB recalculation. -CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO pb_dirty (user_id, chart_id) - VALUES (OLD.user_id, OLD.chart_id) - ON CONFLICT DO NOTHING; - ELSE - INSERT INTO pb_dirty (user_id, chart_id) - VALUES (NEW.user_id, NEW.chart_id) - ON CONFLICT DO NOTHING; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER score_pb_dirty - AFTER INSERT OR UPDATE OR DELETE ON score - FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty(); - --- Trigger function: on chart UPDATE, if derivation_checksum changed, --- enqueue the chart for score re-derivation. -CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$ -BEGIN - IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN - INSERT INTO score_rederive (chart_id) - VALUES (NEW.id) - ON CONFLICT DO NOTHING; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER chart_score_rederive - AFTER UPDATE ON chart - FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive(); diff --git a/db/seeds/bms-course-lookup.json b/db/seeds/bms-course-lookup.json index c575cfda8..2c4190d60 100644 --- a/db/seeds/bms-course-lookup.json +++ b/db/seeds/bms-course-lookup.json @@ -1,952 +1,952 @@ [ { + "game": "bms-14k", "md5sums": "98fa5d9e8e9ddca1dc78ba15d231bd279a25380cede7740b2f61d03dc5480daf50a21694c6d36650104f07e5ecde5a316c77ca5053489a4857ed4f6d53ff0bda", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 初段", "value": "NORMAL_1" }, { + "game": "bms-14k", "md5sums": "b88c27b0eb914887e9866b17a9428f6c98b5e55861b7d80aa2f804bb8e7da67a9000d359f49fa0a5b23b57ba53a637b2d4d1121468e3a2c6df5e7ace219be6ed", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 二段", "value": "NORMAL_2" }, { + "game": "bms-14k", "md5sums": "74aed698dbaaefcfb7c127ffa6e52df0864cd738c77820e47b534d5604076ad58afe65a262f4baf6fe3e77ff14053e615b0fe12541a65f142e8bbd6a82cfc05c", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 三段", "value": "NORMAL_3" }, { + "game": "bms-14k", "md5sums": "4a61bd30f1c05fd31c4a8674e94cf9973340e601f36de19f588619bca3711cad1cedfdbcf57dfb61034742856d98ae49aa48dcf2d9e1255d486b68cf4ab7030c", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 四段", "value": "NORMAL_4" }, { + "game": "bms-14k", "md5sums": "efb3782c7b24dfacdc5b4a82e271285c4fd0fd1666452b5b8303657e916c7e2b0b681af46981a7cf2b545b91a342558cdbf68c2913053f4bdd522bb22b603cf4", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 五段", "value": "NORMAL_5" }, { + "game": "bms-14k", "md5sums": "b6c15d50d23d26dd6f4ed704bf841ca030da8fb7c26513b4e4ddbe181f9f76f7d47f8fd5e6869fb8145faef6116d4103f07a6de8994eb2da3c042c22a312631f", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 六段", "value": "NORMAL_6" }, { + "game": "bms-14k", "md5sums": "edf3b1ea6f50db56f0e60e6424d6be06409598edc02012ea8221a51a0e13653246b4e241f33bd7c6b29747baf35b31ec537cf5dab0dd15f0269883ca9637369a", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 七段", "value": "NORMAL_7" }, { + "game": "bms-14k", "md5sums": "76ffc39aeb37f764acaf0438b4d963808849d1b1d585c41169a0bd293df39bc252c1eb44fd0349389922481998467eac8a5764f0870aef1cd76d7cddc0645dc5", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 八段", "value": "NORMAL_8" }, { + "game": "bms-14k", "md5sums": "bdfe182cded7b5a9a3621e144b2cc7211aa32cee26bc4bd1015993ba90443b09163abdc453532bf9fffd7274017785cd209cc0bcdf99cc8bc20406d503a3aff6", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 九段", "value": "NORMAL_9" }, { + "game": "bms-14k", "md5sums": "1c99cfd2b59be2886912d33a4847c54bcd08172d882815ec050329e414d902b93e95ebbb4a00a339a6c2507afa74e804dde0d1af6661b320ef3b5280e5bd1c7d", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 十段", "value": "NORMAL_10" }, { + "game": "bms-14k", "md5sums": "bce5f4227525f6d985080959d7cf8c4530ee162381930f52ade58071c521029852ffc90743c0f229a15898df0408f6fe29f477f2d0010cd070f13ba7e799a731", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂初段", "value": "INSANE_1" }, { + "game": "bms-14k", "md5sums": "04bd108676a4d74c9667f1d46c59877193001c4a476b60aefb397cd47e016ee60679dc2738135b8584327e7440aa604683dfffe6695cad75d18262e58c93803e", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂二段", "value": "INSANE_2" }, { + "game": "bms-14k", "md5sums": "a18f96c14a98ce2290b35e1827f8ade6c02d8d918d8ce038129464211c9889aff05446eb6b77c79ee0d62114005d840bc0fcc9de190b380187921ebd8171d18a", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂三段", "value": "INSANE_3" }, { + "game": "bms-14k", "md5sums": "a663bfa7a84978c1d96b024c7fa2b7dc40aaa9dc5ab54026719370c068ea39bd7345e3f41c6f0918d6dc431fd0be4bf0a27df82d4fcac0d543a397764a4f265c", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂四段", "value": "INSANE_4" }, { + "game": "bms-14k", "md5sums": "3eb02eb1762dfa2cf6f2689ba15977fec4a096ac67f6e8224e9872bd465308f71db8dea9aabbe945ff39e16545f10f09180b0eb62d37b5cc74a3f5937bd9fdda", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂五段", "value": "INSANE_5" }, { + "game": "bms-14k", "md5sums": "7480de49b770f6852898bf8b6ab51cbca6f82198424691c199e5163f49506554b0a883ac48797b392c1dcf096592809c44f2352a791a52d7e584fa063b4d1b24", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂六段", "value": "INSANE_6" }, { + "game": "bms-14k", "md5sums": "322ef08c16f7420069a67fb66301f93f706085fc30e345fb202037e2482e91f7952e1b207987c5941daa66ed4905e5c429b709780e5bafad29dd451b94137bf8", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂七段", "value": "INSANE_7" }, { + "game": "bms-14k", "md5sums": "c8dc214003898e6ace09d3341701fc01438ab055f0d920b181c67687cebf1cd423ad14776bda285fe04640ebdf98e2681e4c60623dbcdec050af73f4260ba029", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂八段", "value": "INSANE_8" }, { + "game": "bms-14k", "md5sums": "4d59b6c9c5e7ca79b899dc450c2a26d630dc841e3829dfb3003c22fbc77d6fc111ea77e96ba9a1a61447cd35ccecdb77876c09843f04168601f52aa4b17522f6", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂九段", "value": "INSANE_9" }, { + "game": "bms-14k", "md5sums": "a40ee9ce409b7b8ad3a6462843c6950a07ea69b9a864dac1a7e0535e28adccbc5eec61334ada9b17e6f9680283e312d86abd5e8d57c981af7484d9fa25c44e51", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂十段", "value": "INSANE_10" }, { + "game": "bms-14k", "md5sums": "ce4b526b809db30a71e9e2be9880f303307012cfa0cdb675d0b1d4021cfd416a400f6ec1dd53fd3b97c82596f313ea56d085ad2a0303878aefbbd571d02e2790", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 発狂皆伝", "value": "INSANE_KAIDEN" }, { + "game": "bms-14k", "md5sums": "6fe6ad45f4759fe83787b7aa1da94236344270b1434da215f09d837cf282ca5c1337ece34d90ced31890c67bca9106da4d2362fba5a3940ce9c97763bd92a5a2", - "playtype": "14K", "set": "genocideDan", "title": "GENOSIDE 2018 DP段位認定 Overjoy", "value": "OVERJOY" }, { + "game": "bms-14k", "md5sums": "71bc274a1042e15361058fc79dda3745ea2807ec3ef16bb50dba561ea8e5cb29fa736e9530e3c7170c570efd03b69b07de22bde8f104e132afdc15ac65c14021", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl0", "value": "SL0" }, { + "game": "bms-14k", "md5sums": "bc9f6970a45fa7fdefd7c2265b132651ffbe7d2200a82384f2d3a29c003a24e2dff6930e3435fe6552d4670c689d2270d02c8e12c4678a01566f42937444eb79", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl1", "value": "SL1" }, { + "game": "bms-14k", "md5sums": "81ed3f5c9df93ce4e98a623052a526ac2981f0481ec485193832293599820e19dfecee0e19120dcdc4f92271699016faa52cd28568ed5d56a571362f94a35007", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl2", "value": "SL2" }, { + "game": "bms-14k", "md5sums": "fdc6f8e3e78e78f45e763ef20ef848a65c1c97a00af7b108e2a53afecba82cc34b09fc33f71a32e2c6bbc7602048ab21386b1e2c965fd5feb15e82e6cced53ca", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl3", "value": "SL3" }, { + "game": "bms-14k", "md5sums": "aee587970d848afb5259956b8f184d2cce7d6db9389dea40079e947f2d83a2c5adaba76dca4f2d8eb5518f79d6a0dbd37413baf27804bd657cb0e8aea502ba7c", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl4", "value": "SL4" }, { + "game": "bms-14k", "md5sums": "637302837e71bae8d1e6528eb146448109d1c8ce11ca00865862fd4a94d0025099662346a80bd6fdb7c777aba68e9bd85a71ac30e80907739a14741a8c26ca5d", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl5", "value": "SL5" }, { + "game": "bms-14k", "md5sums": "0927a8e429d04fb821bd166947c8bcd1cc2151f6ba99e25ee52cf2fb2899c2ebf4c22271fa6556baa900d799df05371cbea9dcaf2cf21b7bfbce7da243b1a68b", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl6", "value": "SL6" }, { + "game": "bms-14k", "md5sums": "f7498e0befb9d2e581871430d9670ecc8060e0d74d653ee380c684caae0656b0c00887580bcdb3dd9dd594f2eef5c63d8b64dfa0cfcc8bf659f8f863e9dea811", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl7", "value": "SL7" }, { + "game": "bms-14k", "md5sums": "d73cdd6d5d1897e2d3052b74263b528397120ea69565c29f8acae5551caa6d0b5ad08be587d9d42971084fc701df069309ddc29dfc8ac70ceba82c7755efd84f", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl8", "value": "SL8" }, { + "game": "bms-14k", "md5sums": "3d03c5f1f609206bd51d852ebe5b4b748a03405fa7a6a3b0f93880e81ccc3f463af95ce4ad9e59bcdd001bd151225200de1b0b31e2a9a2d5eef92f2395f13c26", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl9", "value": "SL9" }, { + "game": "bms-14k", "md5sums": "61296a55d89ae86f957873df1cd011f167717946ae51422ad272a230f39c76ebbadd87bc03a1b5657c4f9061e3bbd0838dfba7248b732cf93d0bb5cf0e91878b", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl10", "value": "SL10" }, { + "game": "bms-14k", "md5sums": "ad27797ac54d934ad13ee1dcafdd329df11ba440f47e6485789b507d7ca5b450c11998a30371142374cd158c995d9de337cc734cb35cc8f74fa0f36d2ae0e916", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl11", "value": "SL11" }, { + "game": "bms-14k", "md5sums": "6f9b11e0ff8d347672bbd8e715f04ccdcfb9622768f5b5415e6a82169266eacfa3ed09fedd0af81df96743f8671fccd40b1234798d2da9bbde81c1eaf6111005", - "playtype": "14K", "set": "stslDan", "title": "DPSatellite Skill Analyzer sl12", "value": "SL12" }, { + "game": "bms-7k", "md5sums": "ae11930223a8f24e91633f1a7864e1ebcb661ee05c67990012bfd12703ba656c328dbf235f3b6cb2791e71bfb62951f87c6ca04aa14d07b2c081f4dd35432844", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 初段", "value": "NORMAL_1" }, { + "game": "bms-7k", "md5sums": "56528c5f77d140bebf5c3b7070471808d3ed28d0958cd17a93635c6a5604d0aadaab2e157ac86ff4374ffff00a129f85a0aac381114f31691c16b864a401d926", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 二段", "value": "NORMAL_2" }, { + "game": "bms-7k", "md5sums": "ad6a62789520c5591c37dada7a063892fba939901ecf81d6d0ffe6c605f17676d89ea641d2ae9502d43f6905a5ede5a029ff7e29c35b7d72cb1a7437fc09b36d", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 三段", "value": "NORMAL_3" }, { + "game": "bms-7k", "md5sums": "935ac8a2d9a3e2307a6e0206f87c22adb177143ae68115a0702db737a7e7c5048e17f96a8440ff15923bfe228409c9b66dddd7873ce15935d891730e6f17b992", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 四段", "value": "NORMAL_4" }, { + "game": "bms-7k", "md5sums": "c3912d126e1af67d32d236397a7510cbdbf71bc8344d4fd406e154e599c5c27c2e962e84f9ceeb87535fe50a382a380ad2f44370fe455dc1fbffd8c5c6505e77", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 五段", "value": "NORMAL_5" }, { + "game": "bms-7k", "md5sums": "33c6dd9cfe024f2600c2cfe6ae42df43c72d51c6d648ea7b5863ca3bfe52a66cf2ef13a310d8d500b918c5126e13ccb152650f7ca6b989917f095f0635c71da4", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 六段", "value": "NORMAL_6" }, { + "game": "bms-7k", "md5sums": "4d87528933213d3427ee870c71791538761863c63ecfc1a579f87be40632cce1af07aa8958ee69c4b1ba0876adab86eae9d2bc9465b6172c27df0d7da71f10d6", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 七段", "value": "NORMAL_7" }, { + "game": "bms-7k", "md5sums": "07193291a7222c7a1d1f542b6c081953cb95efd400d207ab019bb481c508060a86030ae0cb6945ba653176e021fca4fd5dc2ecda7d5a5615ed10a132ac3cd4bd", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 八段", "value": "NORMAL_8" }, { + "game": "bms-7k", "md5sums": "18aa482e76edfda904b3cf3d646c59bf07baa34abf34131ee20ab40b703540377f31675fdb02c22e392e5b98de1baec477dd6afb7bfb291774fbdcf5aa0db282", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 九段", "value": "NORMAL_9" }, { + "game": "bms-7k", "md5sums": "f14357d72c2c11081fd45dc00aa4cfface73f117ef9c4a3ff40fb8069e1c3dda2356c21a671f059293a24d4cf14f0db25f6f1c5d3fca5edba8edd537d9927c61", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 十段", "value": "NORMAL_10" }, { + "game": "bms-7k", "md5sums": "f872dd65dd08638b06d80470a3233fb91b72e8f6439a698e78f94be16470b7892371263af3b0d644fba62526c9f494818a8a6c2f3511eb0876a6c9a2027d7bbe", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂初段", "value": "INSANE_1" }, { + "game": "bms-7k", "md5sums": "d70e2549e841819708d279d2478bb1e25e3711f2f576f3929156c9594b87efaca56ddb2169246817fb7527363fb9c687cefb4735c26f9488438021b4d7fdca31", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂二段", "value": "INSANE_2" }, { + "game": "bms-7k", "md5sums": "19fbf62d711282d81208f97f7135e2a23791d42c00cdfa135df3779eb0f78505b46b23fa8e2ad96e02763f687bb5a494efae90d527a71d753ea7ad2847ec2006", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂三段", "value": "INSANE_3" }, { + "game": "bms-7k", "md5sums": "47de06762f16d164426e3197c5de967ca718de9a2cd2f88deafbd40f5929abb45077cd14b4759d0e8d61e854eac4e88ac87682922d1b87622b34fe7a7ffba52c", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂四段", "value": "INSANE_4" }, { + "game": "bms-7k", "md5sums": "e9fc0fbbde68bfd10f2d289751a1fc6a3a500560e6ace69e08b51736ff1be0ca8a8019d432ab4dd9bde4e65faf1b663f7fd4d1b5a767fd97343129157782a95f", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂五段", "value": "INSANE_5" }, { + "game": "bms-7k", "md5sums": "f96cf3af3d9356407bff483d27f4aecca29f9500cebd16cbb8d14b69ace79db1871c6aed53c5dd3ac35686d9a92af4f440673cc8ea628a9452f6ae9ac74d5817", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂六段", "value": "INSANE_6" }, { + "game": "bms-7k", "md5sums": "a4b59ca055a856b9a200ccfd1bf5c7bca0f48e96286e0ce275a33fd8cb851ff0c4134c29746e1ece168159ba4ae88d4567a76e7d26f3c3ea99fdc6f912f83021", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂七段", "value": "INSANE_7" }, { + "game": "bms-7k", "md5sums": "f0feb9654ae044c0e95ebf68e2497c2e51e0a29d5c7fc9cdf5e5e8eee1ad11241cf2e138abd7199d6e4656447cfaed6c8e0df85dbf59c4753346f1582e2d4422", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂八段", "value": "INSANE_8" }, { + "game": "bms-7k", "md5sums": "603aae2c3681cc2562b9b53b50408c6979dc444b48299814462ff23a2bf3ab79ff5d7235ba643bc85b804a3df2778590451ed38cdba0323388027129f5929645", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂九段", "value": "INSANE_9" }, { + "game": "bms-7k", "md5sums": "d6fea1389a86fc14eb354fcc5e60e03ee58e3f718d089ec37caa0f2c7149a54b1e049d189ef9bef79824f55764517a58fb975909eb0f60a9190c7a1007515d4b", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂十段", "value": "INSANE_10" }, { + "game": "bms-7k", "md5sums": "10420967a85371e65db57967e6c696cdebc5946c3de24a01048d1c90cbd5c9d645446f6c96bdb081a2054b80cd8f720839d0732ef7fee9ad0c8b044ccbe8a396", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 発狂皆伝", "value": "INSANE_KAIDEN" }, { + "game": "bms-7k", "md5sums": "cfad3baadce9e02c45021963453d7c9477d23be22b2370925c573d922276bce0188a99f74ab71804f2e360dcf484545cc46a81cb184f5a804c119930d6eba748", - "playtype": "7K", "set": "genocideDan", "title": "GENOSIDE 2018 段位認定 Overjoy", "value": "OVERJOY" }, { + "game": "bms-7k", "md5sums": "dca02104c7101cd6d00a5853ba72339da037473f6035219037329c12aef663520127fc65b6e0bfee4814bebd403a77f20d11bf38034b09b171101f0e2815521a", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 初段", "value": "DAN_1" }, { + "game": "bms-7k", "md5sums": "dbdebdc2db0a49900e05ca39ac32f83be2cfa196586410891e4cfc17de9961fb15e3c89d392646c647fd73071db69307144b2e2239364e305e7950e5578b858a", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 二段", "value": "DAN_2" }, { + "game": "bms-7k", "md5sums": "46ae355e3680cdd2884e4771881538b5b71c2e87f98b4a9c01c384cf0e854403b587486b12f38529d119edd858b73e8abfaa77caae32c4d51326c555bef5cb6a", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 三段", "value": "DAN_3" }, { + "game": "bms-7k", "md5sums": "652978806b176ef33481bcb3446130bfe1f42fcf977970f4eb462b48619ed76de70e4d6bf89e94e85dbcb234ea31df23d03d7dcaea9b95bfd6134c6bb2ef29ca", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 四段", "value": "DAN_4" }, { + "game": "bms-7k", "md5sums": "3ad8d78baa4fdf5edd91924785a7d6cc20090008a5d50566f34f01363e28595d57b5fd7b487d0f5be4c2f79d360d1a63632a7db28f7d7b3f077b256c1d62fa39", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 五段", "value": "DAN_5" }, { + "game": "bms-7k", "md5sums": "73439b00d535412947aa74d67aaf35b5a22cb279ce7f795411088b27b2734a219e882adb779d29f1e20d9fce885c598ab5ed1381386d6de7af0a8b1b854f5c04", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 六段", "value": "DAN_6" }, { + "game": "bms-7k", "md5sums": "066eed095b7a6dd2a91a4284839913d97bfb838b6c55da4bb5148405193f200fb9ffe5ed468b4bb8f48a9b66c4454e49224f02343f10f0d5c8aefe2efd24f934", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 七段", "value": "DAN_7" }, { + "game": "bms-7k", "md5sums": "ef74578215fefb8d60e43a68bec581681b9c813b7d3f76728691d96ccdc2b21f45d9360b9d535ee5462511f643a052cfa8efbdc00c0319febb7032bceb021b8c", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 八段", "value": "DAN_8" }, { + "game": "bms-7k", "md5sums": "0fd3a31cdb1a745e049d78b94656d46c63a528d3f67fe3a138f2dd3f2d7a9639029dfba1b2ec18937fac78256a866efe547bb7f01e814330f35d7d6f63209838", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 九段", "value": "DAN_9" }, { + "game": "bms-7k", "md5sums": "f0d9c290e30de61defd29fafb2e346bf0cc8abd2a1d29fac01876bae6b5197296142fc4473735b3be1604adcf3de1ac7daa8054030ebc0cdac82795117777ea7", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 十段", "value": "DAN_10" }, { + "game": "bms-7k", "md5sums": "c9d94a5387ded1454d27c2d7049b76a7302d04a4d66dbcedd9ef617c19eeb595cee68c43c6effd2f7bca54521b99d4fa1b92d4d96ed2bcca18179323ac90bb0b", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 皆伝", "value": "KAIDEN" }, { + "game": "bms-7k", "md5sums": "2c0e44825ffb611caf472816f078b0dc84bc096c876667e905e72ecd73652d087e942336a88d8f67784b1d31376051f5e85bb7112caee8c9b9dd54f34c9cdd90", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 Overjoy", "value": "OVERJOY" }, { + "game": "bms-7k", "md5sums": "f5695a4a39887bc2970c19c884ca9bfbb7c14bec0632e1b3ad11764fd0281dd48a4dcc16636a91f3240abe813dcb68e2e75f513c5485e9f6599071b208771606", - "playtype": "7K", "set": "lnDan", "title": "LN段位認定 うどん", "value": "UDON" }, { + "game": "bms-7k", "md5sums": "1582787258f430961e6a3245454def5ba9028508eabc2372c58b24286dd2659ccb439c3f276d9f8aa1dfb29f315936ad799cf85057ae5bf2b93ebfecc322eff7", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂皆伝", "value": "INSANE_KAIDEN" }, { + "game": "bms-7k", "md5sums": "d4a1b4ad0f0bfd5681b7b0ccc012bed593bfccb37e1a3b1f44bc341383d08eb69bf55882fa4a1762bfb927f2731dbb1372eb962296d74fa32fa0dd32a17369e8", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂十段", "value": "INSANE_10" }, { + "game": "bms-7k", "md5sums": "f1ac6350ac2620a7fb5563febb53116a730118f9bf142d9170ce46bedd37f96a01e0d67a5ba44cd57168ede127bfba3c434b339e1ac512e498a45369b157388b", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂九段", "value": "INSANE_9" }, { + "game": "bms-7k", "md5sums": "273965c4669994761c71c0da25a49c6ffeef4d30cbd180e78eb96cb9c8772ee4f649c932d7df6ba14f3c7c4ef69cce4ea28676c6c74f68fbe6cd72574de784ba", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂八段", "value": "INSANE_8" }, { + "game": "bms-7k", "md5sums": "cf872b2c69d451e5c31b59ee49b3866156ef487b38517cae523b4720d6898dd308fccad28bf0537406364224b2e597d1166c4a11b18268d2b39a7ac88a1bcc74", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂七段", "value": "INSANE_7" }, { + "game": "bms-7k", "md5sums": "12465f3ced547f6a6735f94c9f6751b11834e440eb1574429d84354807389809d69d4f8b5183ede4c383d57241f16ba86391c83fb4177e30ccc58496432d39e6", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂六段", "value": "INSANE_6" }, { + "game": "bms-7k", "md5sums": "f7e8da83e7507755266a1dc5fc4f0c25de1f9baa74bb6fe87f74c6bd9430f5c0e36992529f68da6ef63e34b0045fc2795f30b80a5cea33c2756cdbed08490735", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂五段", "value": "INSANE_5" }, { + "game": "bms-7k", "md5sums": "decb0c6c112389652bdaa870bc89fba5a6771c323d296190f13c4eb7045f27c2011c0147f43fd1a84196782ccd88c203590510ce88bd7423e1c9939b891ab750", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂四段", "value": "INSANE_4" }, { + "game": "bms-7k", "md5sums": "fcdc14c55ebadeaccab843a2cce15ae53f24fa74c7b7914e9bb3e94ee5b5c3c24c11405a191dfd9b5410fda5b646f863a1fc8f39becc3b3ee381a800b624b809", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂三段", "value": "INSANE_3" }, { + "game": "bms-7k", "md5sums": "b8bf6163d5c4454ada801c972e95a414032401f30d2c7f1f764d106fb3842301dcbfe6891ef8e9405f49f563d4c99c58e4d02272c104dc5e393a4e2a0f1284c0", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂二段", "value": "INSANE_2" }, { + "game": "bms-7k", "md5sums": "fd8425255fbde9dd84e6db44ffb12329973aa88c6e8082e4d096281238d9a3fea49868a3e1c9eb580967c67d96a07e1018f23cd1ad1b0505aec5f9ae10c07600", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂初段", "value": "INSANE_1" }, { + "game": "bms-7k", "md5sums": "d2d922aa6bcaeed61087d434b330c30eb9c7bc62daea8f9ccd4502328d624486ac0742b6db091fb76f08841056d0e4b89863fa6d88f44158a1d899cc2f0f9f87", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 発狂入門", "value": "INSANE_0" }, { + "game": "bms-7k", "md5sums": "91b54734a22bed760a953963893f8e0eeef8d0c82be70c2dc3becfc1d41d8aca9556d61f768faa592b0f49f8132f71423f168702da8b2d738862304c315a5954", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 十段", "value": "NORMAL_10" }, { + "game": "bms-7k", "md5sums": "1486448b03ccc4d643b210c12d73a9157545700edd28023f8ea759e0901a5c9bd2d5792aa418494573c6885c6b8f85f709f70fef057a5ef2e3a4cae194d5388b", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 九段", "value": "NORMAL_9" }, { + "game": "bms-7k", "md5sums": "1580d908e2b3f679174d232b18164f17cf9786af6107164addae85a1d3680e6673fb4847967683227b3a348b0076b13ca856404b747a23b25e8785ccde5fca63", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 八段", "value": "NORMAL_8" }, { + "game": "bms-7k", "md5sums": "4f7194ff14462a3145f3aa2b88cb70ea754050a3165d9a7a742da2f1390378cea45c28522052b8920bc2b00ffa436424f0571f9ffd44be2dd7acf420fc025a53", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 七段", "value": "NORMAL_7" }, { + "game": "bms-7k", "md5sums": "7a916c323e24b215146d698f63b75b96a5d3cb037e4fa7b7c28a8a5932c1c7cf3a16881ef5ebbb61612524c5ce8bc115583651c7df1035502636f37db24c060b", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 六段", "value": "NORMAL_6" }, { + "game": "bms-7k", "md5sums": "5ed0d97f7f66d9a031ff9176efff07e9224bbb19e568282ff63130f1931eb53766c8c81ac9ef3ed701a90b5d84bfaf97a5f15f76b2bfc1a248e1ee093d69d644", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 五段", "value": "NORMAL_5" }, { + "game": "bms-7k", "md5sums": "e8f7a57bfa91c9b1a5173d12c4d9584b503522fb02a2f279923030c1be1fcbc7ace78078aad6103d8fdbec3a682c9f28b49ddb9e8ffea4d75ea6f8c03b690fae", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 四段", "value": "NORMAL_4" }, { + "game": "bms-7k", "md5sums": "f8876484d74f536a0b24764cbe3ab9c77b48acd7eab9e800738a3b72d2da0e577d21fdb4c13aa53e223774ac85f47b28f5b3911b04267dc36cca4ce961eb0f7a", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 三段", "value": "NORMAL_3" }, { + "game": "bms-7k", "md5sums": "a23cf15dffec468ac87e101fb2b5e3ab3e5a252169b7aaa315b95ee1db26f00d6afea3bb3054b57e51dd2ee14cbfe82bf0b424aea7daee8512cda44addf3ae51", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 二段", "value": "NORMAL_2" }, { + "game": "bms-7k", "md5sums": "c3ada42b7771b9d6f27d7b59bf3f3ae5148e9f208f0d13304179a7b781ccfc58da6a14018c6f26e6a556d00b322931501da6bdaff412c794e4a3ca5888103ad0", - "playtype": "7K", "set": "newGenerationDan", "title": "NEW GENERATION 初段", "value": "NORMAL_1" }, { + "game": "bms-7k", "md5sums": "3d3d5255905b468abe944bd17699e1f2d49d1a220c1be93ac3dda1ed24c11728872727c4dc8116897bfd2316dd8a0389aa47eec9e2483891f85f116ad5bc481e", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 七級", "value": "KYU_7" }, { + "game": "bms-7k", "md5sums": "f496ca4b6bb0d3364ea591bb2ca5e6e545b4dc4124e2837d5ec87934903479ade7060267ec5bc4bf7064483f17f84bf90885845f9067ef127cafe48a8fc7bd1b", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 六級", "value": "KYU_6" }, { + "game": "bms-7k", "md5sums": "de11613763c1aa998b9d85e2f63b64b221ad5555b6e9aebbc7ad95ebbab1a611820004612007ca3f3b5dd17aa1f1712da69ef5923ed5a8b1b30b5df32de26f13", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 五級", "value": "KYU_5" }, { + "game": "bms-7k", "md5sums": "81ab1dddee58699c52e474d1962f5fc645400e292f7e1f35f5c66eefdb0fd19da81096c5160f406c9cec45b094e8c6d7af248c48b7ecd1931bed175b4c80c755", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 四級", "value": "KYU_4" }, { + "game": "bms-7k", "md5sums": "c27648e170f545877eadacf6427e7324cc2f28a9ebf06e9328b4f853674010129b1aad42e6faf6882bfd1361dd77a94c87da3e5636873ef817391cf0e3e0a6c5", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 三級", "value": "KYU_3" }, { + "game": "bms-7k", "md5sums": "f75e2b23fd6cc6dc82b1f5ea28afc11c94a4e9aeb457b5c8c714d14b2a618f9b5cb08ee8f6a44e56f274897765ea89a136734a52f3e052a4bc0f744c4e282952", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 二級", "value": "KYU_2" }, { + "game": "bms-7k", "md5sums": "2c1fd9cd9912db67f15785ad86ee43e1fb4de222e2abea567651a3e2e1ae3ce12290a0f15dd0387806b03417338fa74141b6863d6b6d218c335d0d8ea62f1033", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 一級", "value": "KYU_1" }, { + "game": "bms-7k", "md5sums": "e4ce0e7b32dcf2be5cbab112da3edf75714461caefdead80e1222335a1f62db3da501cc53023d990f9cb1566fdfa8c1552c60cf7e8bbb32ed46e9e199546b510", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 初段", "value": "DAN_1" }, { + "game": "bms-7k", "md5sums": "f946889a19787e7e9d405dc17d3e1c1ff390abb6b59b72bba9c7dba752cabc12eebd547affb449235039090819de4e0f289de1af0f13830112203eb76410a4d6", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 二段", "value": "DAN_2" }, { + "game": "bms-7k", "md5sums": "d604130c9a409cde9827500d8534b9672e4fbaa3f9968ca6cd5239049520ad1bd11d6bcf5d208fedb19843f3d0cfc02bed40d68d31873d52dd87cd1cf7896113", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 三段", "value": "DAN_3" }, { + "game": "bms-7k", "md5sums": "a16d63ac359d357f1ad0feb85ff0b89f937b20699bb0df1b236c2fb77c95587f68dd0f1a8f598f1496936c2c744464676ac4a7d3a047e307ce12272d4f6a7751", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 四段", "value": "DAN_4" }, { + "game": "bms-7k", "md5sums": "38489aa89b243feecb0b007f4dfa1c69d312e5f3b5a3f1211b96f764a0e3ac91cda3343fd00d31998b74fe74f664b941ed4068be79d17221c1ee74fe44fbd1fe", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 五段", "value": "DAN_5" }, { + "game": "bms-7k", "md5sums": "888333edc187e10c3052421f9e8fd103d047727ac1e04f53c2611a7383123403b3125dad9faddd06386110933be5314311ca0610aeb3b564ea77d95959ac6888", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 六段", "value": "DAN_6" }, { + "game": "bms-7k", "md5sums": "56dbebc0002f45164112679266a088956bd2f03d509e62e20a3d301771b0de88766ca3606583ca51ee87a97389a8700a78a9cdaf70047109cd4681fce9f1e1fc", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 七段", "value": "DAN_7" }, { + "game": "bms-7k", "md5sums": "d05a25b1cca3f83ad10894f89303012df346c17c76b5a5b3457d33cc462a992593059fe84d521fb5885122f82e384ddd189b677f1d3c310243d4586388e21497", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 八段", "value": "DAN_8" }, { + "game": "bms-7k", "md5sums": "169dc9f915a9e43b8488a20e2fb6df510dac32ca8ba27e6f66146e1054e43674336b6155717ce1841028482f6470d4df180d8babdfd3ef6f0066d27ab09b92b3", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 九段", "value": "DAN_9" }, { + "game": "bms-7k", "md5sums": "e2d6ef694fcf6caffd6d436e8fd4a38cf818fb0fbf819b395877ea7566eda5ac41418f90b426431c94754098e74e2c0ace2f0b5d9752ece78bef80c2506bf871", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 十段", "value": "DAN_10" }, { + "game": "bms-7k", "md5sums": "89b6c441fd021c872c63453c3041d7a91a89ba0ab527113fc3d00d63870a69a47a80295d155c62c11858d0d586d20f3bf11050396cb3fd3156d4804edeece18a", - "playtype": "7K", "set": "scratchDan", "title": "皿段位認定2 皆伝", "value": "KAIDEN" }, { + "game": "bms-7k", "md5sums": "9ee5524e4ed8a3e7033b4de1fedc35fd9498378a847b94b949d11a566a86d3c04023045d9134dfc47e953bc430e5ef07bb5d0716c18b37f9687bbcea17fc646c", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl0", "value": "SL0" }, { + "game": "bms-7k", "md5sums": "ddb2c34e1543c219c4b251b13671be1fd2d922aa6bcaeed61087d434b330c30e02af89d2c76d4494b6561ee2c94b15cda0f64c8af8621f4fdce97e24193a76cb", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl1", "value": "SL1" }, { + "game": "bms-7k", "md5sums": "e5f4a30db9a9bd1ebab13d97b37fca75fffcdd50280873a3a6a8efb584b5454bbb51e06228e7279b47120b9a5c527421dc5d89d22642fe863b5ec6ec83b3eada", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl2", "value": "SL2" }, { + "game": "bms-7k", "md5sums": "de4b2ccfd35befc3aa9fd746343115c18eb958b06d294ec88dc0620ce43e10b36334415043e69d0eb5afc18da3aab1a4e85c07e4aa2601ea97d90d7157a89894", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl3", "value": "SL3" }, { + "game": "bms-7k", "md5sums": "d72477a3cbf5f2c096039a4685b4929f1804c40d5ed2092fea58ad5b7e98e8821cc3ef8d8b7dec5515d7168e5e03cfa7d5414294b120d5f2dbfec4eb6cd59f38", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl4", "value": "SL4" }, { + "game": "bms-7k", "md5sums": "b6f2c8d798b81f67a14ce05c50cb3b0785ff41c5e0d78d19d06507dbb16ba72f21b4c6161aa75946b0692ed375010f68164efb64a46f8bd10b83c00b9436f5f8", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl5", "value": "SL5" }, { + "game": "bms-7k", "md5sums": "fe2fbfefcdf57b56bb6aaa89d6ee11ccc565d6dbf6346e6a28277742ecd9d60c7d8b3dcd63e6adea25807ab25ea1544c438fa3931fcbb5b71c27b6587c5034c3", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl6", "value": "SL6" }, { + "game": "bms-7k", "md5sums": "649ee01d75eae442f8c1122ca35c70f0e6e9e37764eb800b5417d43824c92717c778712db728ca7e88b9ba3cf1836479489ddffa0c8d458530d93da6b0354cae", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl7", "value": "SL7" }, { + "game": "bms-7k", "md5sums": "b7148c7e0a7a5fd58232c85f3b4b86924ca647a61e781803b2d7bf54f0d582c21bc969cfd715e1bea67839599035c94e225552f7a4a30610e0ff28671ca7230f", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl8", "value": "SL8" }, { + "game": "bms-7k", "md5sums": "116b85b4c01a2cd2cc76ac9e4572e3fed7f000e155172ab880f7be09769124cbcf7c4f006cd571da3b83e355bc64f3141bc98542eaf7f0cccfb4026bb3c25f49", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl9", "value": "SL9" }, { + "game": "bms-7k", "md5sums": "fd76b7d207db6c3942bfac0af6fac8db24c869a51384af113e9271a766e7d4be1fdd8463a1bc45db75e803f33708ab8e8b3c66f945d041b5df53ba3e292e5c54", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl10", "value": "SL10" }, { + "game": "bms-7k", "md5sums": "cc1c4f40886d7ea1d32f867fb9ea2e205ef59b86b228978d435115aa7a9e564a4f86c4d20af4d619f9fbe693db0c0d30be15c9f54f0a0cd0e5c1737415dc7182", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl11", "value": "SL11" }, { + "game": "bms-7k", "md5sums": "1eeb095a41e3db3addbacf2483c80a3a6a3762ad91ec89392dda8554360f66c184f24615319041873d799faf257d86bf4648b0a5679acf21bc8102ac95e50585", - "playtype": "7K", "set": "stslDan", "title": "Satellite Skill Analyzer 2nd sl12", "value": "SL12" }, { + "game": "bms-7k", "md5sums": "349bc491ec40d5595412637d8a4c8d2ebaee0a1921fc5041b44d7d87c7b5548d72b1ce4b2051bd2a396dfa11a2d785ee3a1661a3eaafa13f976e1010d5b87ca0", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st0", "value": "ST0" }, { + "game": "bms-7k", "md5sums": "a87c666d3232097ac5359d6913ad5b23d26ad712ceef97a5a843226bd77553eb965d42e6aa003f95958cd7bcf3a59bec8b327890a493ead1825472d4c4f7bc79", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st1", "value": "ST1" }, { + "game": "bms-7k", "md5sums": "fe100f7877adee1ef88de03e0ed7a6a0c3c99394db35ed28295a589bafebbf5e196b23d514f7831e2c40f094a23e476ea76e440491797451b441beb6c1258b13", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st2", "value": "ST2" }, { + "game": "bms-7k", "md5sums": "3d460b72527d59879b9c7a78f8504289c8f94305dd353be85579d3b8b4e377d4923fde6c150712f6c9b5b0065116f25b9537a84edc60f14997d57061e16107e5", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st3", "value": "ST3" }, { + "game": "bms-7k", "md5sums": "a04edbabe5a670e5b7ef98c693371fd26edb09c73a5d5296af82ea181a612aa3a0e534d02c919996fef1f2a83c44c418ee9f9aadb4592505074302d51c07a3b5", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st4", "value": "ST4" }, { + "game": "bms-7k", "md5sums": "dd24030898da373f26f21c6878074aececafeddfc8731c5243ce3c5165767715d65545a1dc09aa9f32871e222c4bb302b91a63ffda3c1997b0cb4b8866711ba7", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st5", "value": "ST5" }, { + "game": "bms-7k", "md5sums": "f750f674cca67d3d6682f9932257dc43bd97bff3356c5d85dd3490c15d25035b280769115bfe876243b1b03706ee0081ccdbd7df4c2a0a2af1cdc1cbba7b083f", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st6", "value": "ST6" }, { + "game": "bms-7k", "md5sums": "37d83b96775bfd40c944cdcabe57e31fa92661b69575c3a96dce81439df4870082aeb140cc56a5618c14cffc97c60d474fa32b9ef9b0eea82a9816bc9323250a", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st7", "value": "ST7" }, { + "game": "bms-7k", "md5sums": "a8023093392b1f37cc9678e7f8244fa8a9e227310f8514ef4c38d746aa2a2218cbc9a270b836804d61294eb7519d3d908b56f71a2c636e7bf94191e874f1f854", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st8", "value": "ST8" }, { + "game": "bms-7k", "md5sums": "a1b0cffc57e1c0376b6980480212e86455d5fd21b94ed9d5e9039120b570b699f5ab931263d62a063b1bce32bfae492db320b8cf4b476baf51fbae08ff3fad40", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st9", "value": "ST9" }, { + "game": "bms-7k", "md5sums": "d06e6018c5406e3433c080dc7c3ffa0cb08f18bb57f68c8e44da738adeadeaa65ec8fc39e595eb170a9d531546b6959db3ced645875aa00d2001f54beec2bb6e", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st10", "value": "ST10" }, { + "game": "bms-7k", "md5sums": "b6c36c7b5a53f59932126097ed9a278f4e8047d3e6fc04366e2b92fcf67cef029babdbf73ea3bee55904b664e8a236e422f71b0463955fcedb84eca9d09e60ca", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st11", "value": "ST11" }, { + "game": "bms-7k", "md5sums": "85cee7e7e4a3cd07230d7b4cb4c98be8d7f1230979fe3ed7490fd1dc5378a906bb843baa1332cdacd049f1e142c790451a0fd489a4c29a86773d234f045a6472", - "playtype": "7K", "set": "stslDan", "title": "Stella Skill Simulator 4th st12", "value": "ST12" diff --git a/db/seeds/folders.json b/db/seeds/folders.json index 5675c1641..dc194fd51 100644 --- a/db/seeds/folders.json +++ b/db/seeds/folders.json @@ -5,9 +5,10 @@ "inactive": false, "legacyFolderID": "F9957cf7fd3bbea6fa4c6b00a7ef2c24daede1a5a1b669a1394634e9c3360e378", "searchTerms": [], + "slug": "1-mobile", "title": "Level 1 (Mobile)", - "where": "chart.level = '1'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '1'" }, { "game": "arcaea", @@ -15,9 +16,10 @@ "inactive": false, "legacyFolderID": "F6fdc5d8ca6252f719d3a49bef32921a400e6e61f4e049562c8a71c8a670eb9f1", "searchTerms": [], + "slug": "10-mobile", "title": "Level 10 (Mobile)", - "where": "chart.level = '10'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '10'" }, { "game": "arcaea", @@ -25,9 +27,10 @@ "inactive": false, "legacyFolderID": "F578049804782294b79b9d654a575077d382fe020aec7af1f203c78dfad0f4c95", "searchTerms": [], + "slug": "10p-mobile", "title": "Level 10+ (Mobile)", - "where": "chart.level = '10+'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '10+'" }, { "game": "arcaea", @@ -35,9 +38,10 @@ "inactive": false, "legacyFolderID": "Fa88057f6b64fbf0e7eccd702569ae9f0824444ce73e41c32874c72af23a0481d", "searchTerms": [], + "slug": "11-mobile", "title": "Level 11 (Mobile)", - "where": "chart.level = '11'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '11'" }, { "game": "arcaea", @@ -45,9 +49,10 @@ "inactive": false, "legacyFolderID": "F9a381e5482d641280c0c6c70a4396a268ed005cfc346e171cae98f2e525068ee", "searchTerms": [], + "slug": "12-mobile", "title": "Level 12 (Mobile)", - "where": "chart.level = '12'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '12'" }, { "game": "arcaea", @@ -55,9 +60,10 @@ "inactive": false, "legacyFolderID": "F1a3c37e36e2484f4fdf954f01d638e89b39a206196e3ca031780be8d679e41cf", "searchTerms": [], + "slug": "2-mobile", "title": "Level 2 (Mobile)", - "where": "chart.level = '2'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '2'" }, { "game": "arcaea", @@ -65,9 +71,10 @@ "inactive": false, "legacyFolderID": "F42c7b6e4b79fe487efa366bffb2eb4b8878f03e523633e12c52824fb76d5078a", "searchTerms": [], + "slug": "3-mobile", "title": "Level 3 (Mobile)", - "where": "chart.level = '3'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '3'" }, { "game": "arcaea", @@ -75,9 +82,10 @@ "inactive": false, "legacyFolderID": "F566bb72dfe119120c112446389cb2d6a589801e33ae4e180f99948d5903c0ea6", "searchTerms": [], + "slug": "4-mobile", "title": "Level 4 (Mobile)", - "where": "chart.level = '4'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '4'" }, { "game": "arcaea", @@ -85,9 +93,10 @@ "inactive": false, "legacyFolderID": "Fc43cc6eedd28da067165f23aa84f277ff170fcad33e80a62cf82eaebc0fef08b", "searchTerms": [], + "slug": "5-mobile", "title": "Level 5 (Mobile)", - "where": "chart.level = '5'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '5'" }, { "game": "arcaea", @@ -95,9 +104,10 @@ "inactive": false, "legacyFolderID": "F326cf1f25625d2265b38175121ad7c299ad9273119e8098362e6fa681b314818", "searchTerms": [], + "slug": "6-mobile", "title": "Level 6 (Mobile)", - "where": "chart.level = '6'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '6'" }, { "game": "arcaea", @@ -105,9 +115,10 @@ "inactive": false, "legacyFolderID": "Fb730f33699da1a607c124b14c7af75cbb0bd8837ea2288dabc50edb4fbe70cf9", "searchTerms": [], + "slug": "7-mobile", "title": "Level 7 (Mobile)", - "where": "chart.level = '7'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '7'" }, { "game": "arcaea", @@ -115,9 +126,10 @@ "inactive": false, "legacyFolderID": "F248840fcf44c5d23f308d1674e6a74b80080d8d967f8901601e7c8279721f8c0", "searchTerms": [], + "slug": "8-mobile", "title": "Level 8 (Mobile)", - "where": "chart.level = '8'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '8'" }, { "game": "arcaea", @@ -125,9 +137,10 @@ "inactive": false, "legacyFolderID": "Fefdf5d7fdf90e0c7af5f9c5f28fb87f413bc4066c1017ed9efd8ed73f2694ab0", "searchTerms": [], + "slug": "9-mobile", "title": "Level 9 (Mobile)", - "where": "chart.level = '9'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '9'" }, { "game": "arcaea", @@ -135,9 +148,10 @@ "inactive": false, "legacyFolderID": "Ff000980cd658a7313774f59b16da2ca9652576e523b1c5e0ec7616371c7abd91", "searchTerms": [], + "slug": "9p-mobile", "title": "Level 9+ (Mobile)", - "where": "chart.level = '9+'", - "versionFilter": ["mobile"] + "versionFilter": ["mobile"], + "where": "chart.level = '9+'" }, { "game": "bms-14k", @@ -145,6 +159,7 @@ "inactive": false, "legacyFolderID": "Fd6619a8d8ef470ee45fc9690871ff80821e642941283d8d6f2818a42022e7599", "searchTerms": ["DP Insane ?"], + "slug": "tf-dpinsane-q", "title": "★?", "where": "(chart.data->'tableFolders'->>'★') = '?'" }, @@ -154,6 +169,7 @@ "inactive": false, "legacyFolderID": "F70fb59d662e76d446536c586c21bf424c267a4d532a537c3618b64b6094cfc6e", "searchTerms": ["DP Insane 1"], + "slug": "tf-dpinsane-1", "title": "★1", "where": "(chart.data->'tableFolders'->>'★') = '1'" }, @@ -163,6 +179,7 @@ "inactive": false, "legacyFolderID": "Fc95b3702aa717f73116d6b8a8b54d5c8df3875728540c3bd45de0b050b99b8e9", "searchTerms": ["DP Insane 10"], + "slug": "tf-dpinsane-1-0", "title": "★10", "where": "(chart.data->'tableFolders'->>'★') = '10'" }, @@ -172,6 +189,7 @@ "inactive": false, "legacyFolderID": "F6010d2722a203eefccd66d78f1e5e805e16df1adb0219038f209bb8dbd587baa", "searchTerms": ["DP Insane 11"], + "slug": "tf-dpinsane-1-1", "title": "★11", "where": "(chart.data->'tableFolders'->>'★') = '11'" }, @@ -181,6 +199,7 @@ "inactive": false, "legacyFolderID": "F9d08bd7b09f786fb6a27b0e0452ff7c3e83f870223e9ec2768ebca9a8cc1da0b", "searchTerms": ["DP Insane 12"], + "slug": "tf-dpinsane-1-2", "title": "★12", "where": "(chart.data->'tableFolders'->>'★') = '12'" }, @@ -190,6 +209,7 @@ "inactive": false, "legacyFolderID": "Fe23ab5d59781a65ecae685a762a06e355131e556d5817b681806735bf71d660b", "searchTerms": ["DP Insane 13"], + "slug": "tf-dpinsane-1-3", "title": "★13", "where": "(chart.data->'tableFolders'->>'★') = '13'" }, @@ -199,6 +219,7 @@ "inactive": false, "legacyFolderID": "F0cb47a5e7e8d3741e77a98464523ddc4bffd21a86376fa37313d60a0b94dd9fc", "searchTerms": ["DP Insane 2"], + "slug": "tf-dpinsane-2", "title": "★2", "where": "(chart.data->'tableFolders'->>'★') = '2'" }, @@ -208,6 +229,7 @@ "inactive": false, "legacyFolderID": "Fca2896312129e42d56631361b041f984f133cb26d032a548c95fbea65b4f58b3", "searchTerms": ["DP Insane 3"], + "slug": "tf-dpinsane-3", "title": "★3", "where": "(chart.data->'tableFolders'->>'★') = '3'" }, @@ -217,6 +239,7 @@ "inactive": false, "legacyFolderID": "Ffa3d86df9ded6209436396269cc6137b4762c07ceaa543c643e1afe64cbca3fb", "searchTerms": ["DP Insane 4"], + "slug": "tf-dpinsane-4", "title": "★4", "where": "(chart.data->'tableFolders'->>'★') = '4'" }, @@ -226,6 +249,7 @@ "inactive": false, "legacyFolderID": "Ff9446d6f01fcf0f078b04f0f26124830b6c4e36edaab543566dc5724ae1c7c25", "searchTerms": ["DP Insane 5"], + "slug": "tf-dpinsane-5", "title": "★5", "where": "(chart.data->'tableFolders'->>'★') = '5'" }, @@ -235,6 +259,7 @@ "inactive": false, "legacyFolderID": "F24bcaaa2dc1eaaa2acca3124af12c28d71b69565d91c229d56a1b2a552493c12", "searchTerms": ["DP Insane 6"], + "slug": "tf-dpinsane-6", "title": "★6", "where": "(chart.data->'tableFolders'->>'★') = '6'" }, @@ -244,6 +269,7 @@ "inactive": false, "legacyFolderID": "F56cc4b0fc29d3a65425700c7f5f3ab86fab128015f5fc1b3b495f071127c0e66", "searchTerms": ["DP Insane 7"], + "slug": "tf-dpinsane-7", "title": "★7", "where": "(chart.data->'tableFolders'->>'★') = '7'" }, @@ -253,6 +279,7 @@ "inactive": false, "legacyFolderID": "F89d518cba7a2876eed16ad41d60434ef4d1debff26d498f697ffc5a0ceafc726", "searchTerms": ["DP Insane 8"], + "slug": "tf-dpinsane-8", "title": "★8", "where": "(chart.data->'tableFolders'->>'★') = '8'" }, @@ -262,6 +289,7 @@ "inactive": false, "legacyFolderID": "F19f4987662554d257e1dc565c76af338e515a84249719d434e636f3a5ec7face", "searchTerms": ["DP Insane 9"], + "slug": "tf-dpinsane-9", "title": "★9", "where": "(chart.data->'tableFolders'->>'★') = '9'" }, @@ -271,6 +299,7 @@ "inactive": false, "legacyFolderID": "Fabc87551958f41fbe9e18ae52307d87e9d50a6dc8293e64a77fbf9705ffbb0fe", "searchTerms": [], + "slug": "tf-dplibrary-q", "title": "☆?", "where": "(chart.data->'tableFolders'->>'☆') = '?'" }, @@ -280,6 +309,7 @@ "inactive": false, "legacyFolderID": "F85e5a6cb478173a31a095d4dc9dc091f9db565af0280c2d6f0661827b428b082", "searchTerms": [], + "slug": "tf-dplibrary-1", "title": "☆1", "where": "(chart.data->'tableFolders'->>'☆') = '1'" }, @@ -289,6 +319,7 @@ "inactive": false, "legacyFolderID": "Fdab8a9cb11f8a6d80bb7db26d352c241f11936c51bfa41e5ab99ac48383789be", "searchTerms": [], + "slug": "tf-dplibrary-1-0-u4e2d", "title": "☆10中", "where": "(chart.data->'tableFolders'->>'☆') = '10中'" }, @@ -298,6 +329,7 @@ "inactive": false, "legacyFolderID": "F23daea5cf06c12ac964561a4cee99b537c781953afcdd180fedb606bfffd2e99", "searchTerms": [], + "slug": "tf-dplibrary-1-0-u5f31", "title": "☆10弱", "where": "(chart.data->'tableFolders'->>'☆') = '10弱'" }, @@ -307,6 +339,7 @@ "inactive": false, "legacyFolderID": "F3eaf1f10e6d46f220b45526bd9cfa181e321dd3ac5e8e425f62ba97a66b709b0", "searchTerms": [], + "slug": "tf-dplibrary-1-0-u5f37", "title": "☆10強", "where": "(chart.data->'tableFolders'->>'☆') = '10強'" }, @@ -316,6 +349,7 @@ "inactive": false, "legacyFolderID": "Fc913ce0110dcf4d0c7208ebc7bc14f6a66232cd866362ea8869a6eef673c822a", "searchTerms": [], + "slug": "tf-dplibrary-1-1-u4e2d", "title": "☆11中", "where": "(chart.data->'tableFolders'->>'☆') = '11中'" }, @@ -325,6 +359,7 @@ "inactive": false, "legacyFolderID": "Ffb5eddce6485497ae8d4429c3d04346e17af63776aaddf0b9025970f005af0a9", "searchTerms": [], + "slug": "tf-dplibrary-1-1-u5f31", "title": "☆11弱", "where": "(chart.data->'tableFolders'->>'☆') = '11弱'" }, @@ -334,6 +369,7 @@ "inactive": false, "legacyFolderID": "F6302556b656f03c330c01bcf5f29b65ec42989c63e54eb1b39a5f99a9cc236c8", "searchTerms": [], + "slug": "tf-dplibrary-1-1-u5f37", "title": "☆11強", "where": "(chart.data->'tableFolders'->>'☆') = '11強'" }, @@ -343,6 +379,7 @@ "inactive": false, "legacyFolderID": "F61e8e3cb52b9d70358f33a7558c3ea5d9f6422be29b92de203f81e2ef7f6f356", "searchTerms": [], + "slug": "tf-dplibrary-1-2", "title": "☆12", "where": "(chart.data->'tableFolders'->>'☆') = '12'" }, @@ -352,6 +389,7 @@ "inactive": false, "legacyFolderID": "Ff64b0c187fb528d51d6d42bb843f5e81481193d72b46b384d61e1de38931e4c9", "searchTerms": [], + "slug": "tf-dplibrary-2", "title": "☆2", "where": "(chart.data->'tableFolders'->>'☆') = '2'" }, @@ -361,6 +399,7 @@ "inactive": false, "legacyFolderID": "F70388fd85e665e8e2a1d8aee26167ad822b05555175778c5492afa4d969c8ec5", "searchTerms": [], + "slug": "tf-dplibrary-3", "title": "☆3", "where": "(chart.data->'tableFolders'->>'☆') = '3'" }, @@ -370,6 +409,7 @@ "inactive": false, "legacyFolderID": "Fd050ee0d0a1a20ccde5da8b5f4cd429b1ad829c68e971dacd720646f102cf02c", "searchTerms": [], + "slug": "tf-dplibrary-4", "title": "☆4", "where": "(chart.data->'tableFolders'->>'☆') = '4'" }, @@ -379,6 +419,7 @@ "inactive": false, "legacyFolderID": "F631b1e2a5d7142d71c7fff75a0a45cec5d6a0073e0061fcf7d4b3b0ecbe7503e", "searchTerms": [], + "slug": "tf-dplibrary-5", "title": "☆5", "where": "(chart.data->'tableFolders'->>'☆') = '5'" }, @@ -388,6 +429,7 @@ "inactive": false, "legacyFolderID": "F097e70e31cd9a197c1c3cff0fadcac621855bacf965b97f6f2e3f3fbd1937d86", "searchTerms": [], + "slug": "tf-dplibrary-6", "title": "☆6", "where": "(chart.data->'tableFolders'->>'☆') = '6'" }, @@ -397,6 +439,7 @@ "inactive": false, "legacyFolderID": "F010882d42b33a08301b9b2a66bac20ec03bc74584df16fb4574fc9545d4bdc65", "searchTerms": [], + "slug": "tf-dplibrary-7", "title": "☆7", "where": "(chart.data->'tableFolders'->>'☆') = '7'" }, @@ -406,6 +449,7 @@ "inactive": false, "legacyFolderID": "F3abf6dfc63479acb04c37953b5e47ea9b8c4f4a89257003fe26d30b85b7c1484", "searchTerms": [], + "slug": "tf-dplibrary-8-u5f31", "title": "☆8弱", "where": "(chart.data->'tableFolders'->>'☆') = '8弱'" }, @@ -415,6 +459,7 @@ "inactive": false, "legacyFolderID": "F68dc542e10b4e7943ee2ff7833457aa88fccde93e79dee2708459838268b03e1", "searchTerms": [], + "slug": "tf-dplibrary-8-u5f37", "title": "☆8強", "where": "(chart.data->'tableFolders'->>'☆') = '8強'" }, @@ -424,6 +469,7 @@ "inactive": false, "legacyFolderID": "Fe4e8beac2736bd65d9cd14a54b293616db54c4c0a93ef3f60ca5a1ddd8f1114b", "searchTerms": [], + "slug": "tf-dplibrary-9-u4e2d", "title": "☆9中", "where": "(chart.data->'tableFolders'->>'☆') = '9中'" }, @@ -433,6 +479,7 @@ "inactive": false, "legacyFolderID": "F0ae9eae5d7b84d3a6aac1d3a6bf3d45949396b23266c295176ecb59624f979e8", "searchTerms": [], + "slug": "tf-dplibrary-9-u5f31", "title": "☆9弱", "where": "(chart.data->'tableFolders'->>'☆') = '9弱'" }, @@ -442,6 +489,7 @@ "inactive": false, "legacyFolderID": "Ff907793e104a0fe7cbd1923176311557619f9e8e921341ccddadc55adf4506c6", "searchTerms": [], + "slug": "tf-dplibrary-9-u5f37", "title": "☆9強", "where": "(chart.data->'tableFolders'->>'☆') = '9強'" }, @@ -451,6 +499,7 @@ "inactive": false, "legacyFolderID": "Fa7aa1988d283bee7866fb584fb13c3253c75ef2cdd2d35a408adb7682e6331d2", "searchTerms": [], + "slug": "tf-dplibrary-u8db3", "title": "☆足", "where": "(chart.data->'tableFolders'->>'☆') = '足'" }, @@ -460,6 +509,7 @@ "inactive": false, "legacyFolderID": "F769d0e12ce12897efe33a7a402be7260e0990d76629b4fe103ea9a11547a0865", "searchTerms": ["dpInsane"], + "slug": "tf-dpinsane-present", "title": "DP Insane", "where": "(chart.data->'tableFolders') ? '★'" }, @@ -469,6 +519,7 @@ "inactive": false, "legacyFolderID": "F264b966b13e2b83f0a6c5c43bd531272cb1eaf0a7fabb275eadbecba88706863", "searchTerms": ["dpLibrary"], + "slug": "tf-dplibrary-present", "title": "DP Library", "where": "(chart.data->'tableFolders') ? '☆'" }, @@ -478,6 +529,7 @@ "inactive": false, "legacyFolderID": "Fe1d3fc21babda6bbf8f0421bbe641e914fe3a6fa7eb61708ea7e3dc44b29e58a", "searchTerms": ["dpNormal"], + "slug": "tf-dpnormal-present", "title": "DP Normal", "where": "(chart.data->'tableFolders') ? 'δ'" }, @@ -487,6 +539,7 @@ "inactive": false, "legacyFolderID": "F4442d60db39ca8f8b37c960271e59b8039545272495185c899589db4b11e31f9", "searchTerms": ["dpSatellite"], + "slug": "tf-dpsatellite-present", "title": "DP Satellite", "where": "(chart.data->'tableFolders') ? 'sl'" }, @@ -496,6 +549,7 @@ "inactive": false, "legacyFolderID": "Fee8bddee9835ce705fbd5cf3cb5ebc132aa77c1aa1a17d3d01f96fb0d20cae93", "searchTerms": ["DP Satellite 0"], + "slug": "tf-dpsatellite-0", "title": "sl0", "where": "(chart.data->'tableFolders'->>'sl') = '0'" }, @@ -505,6 +559,7 @@ "inactive": false, "legacyFolderID": "F8e4051a54622748f43166f958a1840ca2606fae623ced71eed70ea107c119fee", "searchTerms": ["DP Satellite 1"], + "slug": "tf-dpsatellite-1", "title": "sl1", "where": "(chart.data->'tableFolders'->>'sl') = '1'" }, @@ -514,6 +569,7 @@ "inactive": false, "legacyFolderID": "F033609b84d8e0eab44894727958c985bacf476691f8bee8a017e690c0a493bb2", "searchTerms": ["DP Satellite 10"], + "slug": "tf-dpsatellite-1-0", "title": "sl10", "where": "(chart.data->'tableFolders'->>'sl') = '10'" }, @@ -523,6 +579,7 @@ "inactive": false, "legacyFolderID": "F1cbcb55500c5bc566a594a8b3f9131d6a3107d2210a8e58bffceafdad7ac96e0", "searchTerms": ["DP Satellite 11"], + "slug": "tf-dpsatellite-1-1", "title": "sl11", "where": "(chart.data->'tableFolders'->>'sl') = '11'" }, @@ -532,6 +589,7 @@ "inactive": false, "legacyFolderID": "F80c2a2e0d8c74c7314bb10949a680027215f0ad0b7dd31964c3f1656deddf8ae", "searchTerms": ["DP Satellite 12"], + "slug": "tf-dpsatellite-1-2", "title": "sl12", "where": "(chart.data->'tableFolders'->>'sl') = '12'" }, @@ -541,6 +599,7 @@ "inactive": false, "legacyFolderID": "F30219ee1cdd6e37c8f1aca81a5c6391911a25389221dc1eeb460082d66a53940", "searchTerms": ["DP Satellite 2"], + "slug": "tf-dpsatellite-2", "title": "sl2", "where": "(chart.data->'tableFolders'->>'sl') = '2'" }, @@ -550,6 +609,7 @@ "inactive": false, "legacyFolderID": "F42eaa8e25dde2207c875731eb4bf954d0fa21868f8f96c187de298236aa473a8", "searchTerms": ["DP Satellite 3"], + "slug": "tf-dpsatellite-3", "title": "sl3", "where": "(chart.data->'tableFolders'->>'sl') = '3'" }, @@ -559,6 +619,7 @@ "inactive": false, "legacyFolderID": "F3901caf2991cebd7844b281dc6b4b29ccf8aac73ea0f376d4462d2247292a494", "searchTerms": ["DP Satellite 4"], + "slug": "tf-dpsatellite-4", "title": "sl4", "where": "(chart.data->'tableFolders'->>'sl') = '4'" }, @@ -568,6 +629,7 @@ "inactive": false, "legacyFolderID": "F2b1ddd7af4e09c20bec5386c716a3cfcd0073a5988b36f755136a2749e6d2657", "searchTerms": ["DP Satellite 5"], + "slug": "tf-dpsatellite-5", "title": "sl5", "where": "(chart.data->'tableFolders'->>'sl') = '5'" }, @@ -577,6 +639,7 @@ "inactive": false, "legacyFolderID": "F5399807ea6a32abe489850a520fe1a29297b30d4e12cab9bcd5d2bacfd036444", "searchTerms": ["DP Satellite 6"], + "slug": "tf-dpsatellite-6", "title": "sl6", "where": "(chart.data->'tableFolders'->>'sl') = '6'" }, @@ -586,6 +649,7 @@ "inactive": false, "legacyFolderID": "Fa069b7b23f92413abcb0451d051685ed00d4be6515bc0c7b1c512153a31ad72d", "searchTerms": ["DP Satellite 7"], + "slug": "tf-dpsatellite-7", "title": "sl7", "where": "(chart.data->'tableFolders'->>'sl') = '7'" }, @@ -595,6 +659,7 @@ "inactive": false, "legacyFolderID": "F474ba10be45823e5eb3368932e54225454ffb153c19bd252b25824800d94a76f", "searchTerms": ["DP Satellite 8"], + "slug": "tf-dpsatellite-8", "title": "sl8", "where": "(chart.data->'tableFolders'->>'sl') = '8'" }, @@ -604,6 +669,7 @@ "inactive": false, "legacyFolderID": "Fbce56c0b502fa073ae280d17292a0a224cc02a6c710e9e4a0b591fc546b77361", "searchTerms": ["DP Satellite 9"], + "slug": "tf-dpsatellite-9", "title": "sl9", "where": "(chart.data->'tableFolders'->>'sl') = '9'" }, @@ -613,6 +679,7 @@ "inactive": false, "legacyFolderID": "F17019c7edd6a9aa2cc561b31e5a5934bfb8f07bc43eea44647bd7d2761f5fc53", "searchTerms": ["DP Normal 1"], + "slug": "tf-dpnormal-1", "title": "δ1", "where": "(chart.data->'tableFolders'->>'δ') = '1'" }, @@ -622,6 +689,7 @@ "inactive": false, "legacyFolderID": "Ff0a4dfc860c547e45cc3399be3fbd085c9d45948ba076006af0e802dd81f8438", "searchTerms": ["DP Normal 10"], + "slug": "tf-dpnormal-1-0", "title": "δ10", "where": "(chart.data->'tableFolders'->>'δ') = '10'" }, @@ -631,6 +699,7 @@ "inactive": false, "legacyFolderID": "Fc6622f9df3b34243a757cb298829cba15ac1327529ffdea01b90bbc55c249e2b", "searchTerms": ["DP Normal 11"], + "slug": "tf-dpnormal-1-1", "title": "δ11", "where": "(chart.data->'tableFolders'->>'δ') = '11'" }, @@ -640,6 +709,7 @@ "inactive": false, "legacyFolderID": "F499fe2201a518266d770b5db8c572a6b54e3e1593b41a2f805c1f140169b756e", "searchTerms": ["DP Normal 2"], + "slug": "tf-dpnormal-2", "title": "δ2", "where": "(chart.data->'tableFolders'->>'δ') = '2'" }, @@ -649,6 +719,7 @@ "inactive": false, "legacyFolderID": "Fa62d734765af78a0168289d28e1d4ba809270aa29532619000488c2101ba4c43", "searchTerms": ["DP Normal 3"], + "slug": "tf-dpnormal-3", "title": "δ3", "where": "(chart.data->'tableFolders'->>'δ') = '3'" }, @@ -658,6 +729,7 @@ "inactive": false, "legacyFolderID": "F177451a9737510198bfd294c5c0bed5f632b9e6d3b8bc8b4f40afc1a44848821", "searchTerms": ["DP Normal 4"], + "slug": "tf-dpnormal-4", "title": "δ4", "where": "(chart.data->'tableFolders'->>'δ') = '4'" }, @@ -667,6 +739,7 @@ "inactive": false, "legacyFolderID": "F0f76ced75b22bc888b89c0748d4f9ad87ee994a2900e95824d19834d05ec3696", "searchTerms": ["DP Normal 5"], + "slug": "tf-dpnormal-5", "title": "δ5", "where": "(chart.data->'tableFolders'->>'δ') = '5'" }, @@ -676,6 +749,7 @@ "inactive": false, "legacyFolderID": "Fb341fae88883bbbfb578bcb4bcfac4ce87e0e41fea98282b5d1fa25018dd1769", "searchTerms": ["DP Normal 6"], + "slug": "tf-dpnormal-6", "title": "δ6", "where": "(chart.data->'tableFolders'->>'δ') = '6'" }, @@ -685,6 +759,7 @@ "inactive": false, "legacyFolderID": "F353f23361895daddb0c2501c9ba96fec271a239b8c73113a99d3c74ca2b1c245", "searchTerms": ["DP Normal 7"], + "slug": "tf-dpnormal-7", "title": "δ7", "where": "(chart.data->'tableFolders'->>'δ') = '7'" }, @@ -694,6 +769,7 @@ "inactive": false, "legacyFolderID": "Fa1fa36a86685e53d9764ca9418ced9c3fd64a623b1a20ad5d11799612ba5ceb0", "searchTerms": ["DP Normal 8"], + "slug": "tf-dpnormal-8", "title": "δ8", "where": "(chart.data->'tableFolders'->>'δ') = '8'" }, @@ -703,6 +779,7 @@ "inactive": false, "legacyFolderID": "F5a01bedd74cfd51d05d478add7859b535c2f951a8e77e5795bd3fe56c7dbc721", "searchTerms": ["DP Normal 9"], + "slug": "tf-dpnormal-9", "title": "δ9", "where": "(chart.data->'tableFolders'->>'δ') = '9'" }, @@ -712,6 +789,7 @@ "inactive": false, "legacyFolderID": "Fb2f9d8b5adc1d8580fb6dd9ab086fed2d676b17a914b18e96c48e186462068be", "searchTerms": ["DP Normal 99"], + "slug": "tf-dpnormal-9-9", "title": "δ99", "where": "(chart.data->'tableFolders'->>'δ') = '99'" }, @@ -721,6 +799,7 @@ "inactive": false, "legacyFolderID": "Fd23e6796ab717e17fd9e0968ee78650f1939aff65fdd3e8920f6440d48d8b5af", "searchTerms": ["Insane2 ?"], + "slug": "tf-insane2-q", "title": "▼?", "where": "(chart.data->'tableFolders'->>'▼') = '?'" }, @@ -730,6 +809,7 @@ "inactive": false, "legacyFolderID": "F5d5b2ef861df184eae015f2c5eb0cf56a809ee754d0599c3e43490b196d8fe62", "searchTerms": ["Insane2 0"], + "slug": "tf-insane2-0", "title": "▼0", "where": "(chart.data->'tableFolders'->>'▼') = '0'" }, @@ -739,6 +819,7 @@ "inactive": false, "legacyFolderID": "F880cde1eb4d94c36b1965a87772c874b93178820047554c18e7c3d592e6e6022", "searchTerms": ["Insane2 0-"], + "slug": "tf-insane2-0-dash", "title": "▼0-", "where": "(chart.data->'tableFolders'->>'▼') = '0-'" }, @@ -748,6 +829,7 @@ "inactive": false, "legacyFolderID": "F92961a15278a8ae6839aeeb0d2ad14857f2d2860ff8cf26629cc46f6ffabf2ed", "searchTerms": ["Insane2 1"], + "slug": "tf-insane2-1", "title": "▼1", "where": "(chart.data->'tableFolders'->>'▼') = '1'" }, @@ -757,6 +839,7 @@ "inactive": false, "legacyFolderID": "F6633dce23c9576221fd4bc2239c7faefbbd59aa7d51b518dabcbf757c7acd6f3", "searchTerms": ["Insane2 10"], + "slug": "tf-insane2-1-0", "title": "▼10", "where": "(chart.data->'tableFolders'->>'▼') = '10'" }, @@ -766,6 +849,7 @@ "inactive": false, "legacyFolderID": "F991fb0a1cc55f4690f89f8669b64734d85f42034966d624396335bdef0fda5ba", "searchTerms": ["Insane2 11"], + "slug": "tf-insane2-1-1", "title": "▼11", "where": "(chart.data->'tableFolders'->>'▼') = '11'" }, @@ -775,6 +859,7 @@ "inactive": false, "legacyFolderID": "F994b6b727fcbbe46b79aa383612588aeadf786e97244cadd57580c3823087abe", "searchTerms": ["Insane2 12"], + "slug": "tf-insane2-1-2", "title": "▼12", "where": "(chart.data->'tableFolders'->>'▼') = '12'" }, @@ -784,6 +869,7 @@ "inactive": false, "legacyFolderID": "Fecd9d593722a50781ed164ee5c579233aafda0e91c96041988e7f76968f1ed58", "searchTerms": ["Insane2 13"], + "slug": "tf-insane2-1-3", "title": "▼13", "where": "(chart.data->'tableFolders'->>'▼') = '13'" }, @@ -793,6 +879,7 @@ "inactive": false, "legacyFolderID": "Fd262c2a5bec497d01b4a212da3bd2602cd5b4a6b8298dbfa019ca2af196ba122", "searchTerms": ["Insane2 14"], + "slug": "tf-insane2-1-4", "title": "▼14", "where": "(chart.data->'tableFolders'->>'▼') = '14'" }, @@ -802,6 +889,7 @@ "inactive": false, "legacyFolderID": "F4dbffe59b2198b17d7d6bc9f41b1806c67a2613449e8da38026811be32c5e2e6", "searchTerms": ["Insane2 15"], + "slug": "tf-insane2-1-5", "title": "▼15", "where": "(chart.data->'tableFolders'->>'▼') = '15'" }, @@ -811,6 +899,7 @@ "inactive": false, "legacyFolderID": "F556a3699a9e266983f4c41c02307dbf9ab0339e91710e84d9fee12e100ba77d6", "searchTerms": ["Insane2 16"], + "slug": "tf-insane2-1-6", "title": "▼16", "where": "(chart.data->'tableFolders'->>'▼') = '16'" }, @@ -820,6 +909,7 @@ "inactive": false, "legacyFolderID": "F130413c789b7c0d490457a15ae0fecbb6fedcbcd095a0331116508206f0cb210", "searchTerms": ["Insane2 17"], + "slug": "tf-insane2-1-7", "title": "▼17", "where": "(chart.data->'tableFolders'->>'▼') = '17'" }, @@ -829,6 +919,7 @@ "inactive": false, "legacyFolderID": "F611cc68fd886f3368f031d7193a3cdc47bd35da5ce14296e699919b46a417d2d", "searchTerms": ["Insane2 18"], + "slug": "tf-insane2-1-8", "title": "▼18", "where": "(chart.data->'tableFolders'->>'▼') = '18'" }, @@ -838,6 +929,7 @@ "inactive": false, "legacyFolderID": "F857e0e796d9f7f7e4b13ce9ecb4ea94fe25e9f0a6364f28caa8ac69ab802f6a8", "searchTerms": ["Insane2 19"], + "slug": "tf-insane2-1-9", "title": "▼19", "where": "(chart.data->'tableFolders'->>'▼') = '19'" }, @@ -847,6 +939,7 @@ "inactive": false, "legacyFolderID": "Fe4ae381f749f456acbe1f370972e1d489035274cd6cca834f63b68136a38c55f", "searchTerms": ["Insane2 2"], + "slug": "tf-insane2-2", "title": "▼2", "where": "(chart.data->'tableFolders'->>'▼') = '2'" }, @@ -856,6 +949,7 @@ "inactive": false, "legacyFolderID": "Faad525f2424e16e0f551c33d14e5045ea97420b1f035752ab523787f6b930e86", "searchTerms": ["Insane2 20"], + "slug": "tf-insane2-2-0", "title": "▼20", "where": "(chart.data->'tableFolders'->>'▼') = '20'" }, @@ -865,6 +959,7 @@ "inactive": false, "legacyFolderID": "F7cd7faa144626b74e2422e3c86a5d0b4ad2396ca334663e226ae1b5aaecba4f9", "searchTerms": ["Insane2 21"], + "slug": "tf-insane2-2-1", "title": "▼21", "where": "(chart.data->'tableFolders'->>'▼') = '21'" }, @@ -874,6 +969,7 @@ "inactive": false, "legacyFolderID": "Fecc97c502e5f4bf8f51f9b51c29bba22a25f034f4be3c58133158e64bd51595e", "searchTerms": ["Insane2 22"], + "slug": "tf-insane2-2-2", "title": "▼22", "where": "(chart.data->'tableFolders'->>'▼') = '22'" }, @@ -883,6 +979,7 @@ "inactive": false, "legacyFolderID": "Fd6412b428c09daff0cb61de47e05d518a20eb6761474b0e811f9a39cff8d0a22", "searchTerms": ["Insane2 23"], + "slug": "tf-insane2-2-3", "title": "▼23", "where": "(chart.data->'tableFolders'->>'▼') = '23'" }, @@ -892,6 +989,7 @@ "inactive": false, "legacyFolderID": "F5fa0c270ab683b0261f38ab76e0005db64c4ddf4d007f8019cc54e25adc18b00", "searchTerms": ["Insane2 24"], + "slug": "tf-insane2-2-4", "title": "▼24", "where": "(chart.data->'tableFolders'->>'▼') = '24'" }, @@ -901,6 +999,7 @@ "inactive": false, "legacyFolderID": "F6b05e5ff5f439e3191bd7edde969f5142791776229178f438e612a8179a93519", "searchTerms": ["Insane2 3"], + "slug": "tf-insane2-3", "title": "▼3", "where": "(chart.data->'tableFolders'->>'▼') = '3'" }, @@ -910,6 +1009,7 @@ "inactive": false, "legacyFolderID": "F60086a9c76675d6c206173ae7cbf185f00184ac3590ab17194f9fca5fe7fe98b", "searchTerms": ["Insane2 4"], + "slug": "tf-insane2-4", "title": "▼4", "where": "(chart.data->'tableFolders'->>'▼') = '4'" }, @@ -919,6 +1019,7 @@ "inactive": false, "legacyFolderID": "Fb1cb3750fc72ff62ce231fed84feec69168006a55a09856e24bafcef3ed9202f", "searchTerms": ["Insane2 5"], + "slug": "tf-insane2-5", "title": "▼5", "where": "(chart.data->'tableFolders'->>'▼') = '5'" }, @@ -928,6 +1029,7 @@ "inactive": false, "legacyFolderID": "Fd829256db3f903c014515fdd290227cfe06c6ff7013619e9d2ff366534e8669e", "searchTerms": ["Insane2 6"], + "slug": "tf-insane2-6", "title": "▼6", "where": "(chart.data->'tableFolders'->>'▼') = '6'" }, @@ -937,6 +1039,7 @@ "inactive": false, "legacyFolderID": "Ff8a9af0383845d2776ca3ebc7b5e871a6af3b35232cdbe04388066a2f841ea7f", "searchTerms": ["Insane2 7"], + "slug": "tf-insane2-7", "title": "▼7", "where": "(chart.data->'tableFolders'->>'▼') = '7'" }, @@ -946,6 +1049,7 @@ "inactive": false, "legacyFolderID": "Fe3bee8ba49082e5827a2ce0073826523671c8be069826c4c9e117ccbf0b1a8ed", "searchTerms": ["Insane2 8"], + "slug": "tf-insane2-8", "title": "▼8", "where": "(chart.data->'tableFolders'->>'▼') = '8'" }, @@ -955,6 +1059,7 @@ "inactive": false, "legacyFolderID": "F28b35c5a0aa6962beb878a67af59b910ab4de9c85962a794410adf4e101505f6", "searchTerms": ["Insane2 9"], + "slug": "tf-insane2-9", "title": "▼9", "where": "(chart.data->'tableFolders'->>'▼') = '9'" }, @@ -964,6 +1069,7 @@ "inactive": false, "legacyFolderID": "F4199bcc40cfda2346535a6b17352a82f8e4e0c03922a6853a340215e42c200a6", "searchTerms": ["Normal2 ?"], + "slug": "tf-normal2-uff1f", "title": "▽?", "where": "(chart.data->'tableFolders'->>'▽') = '?'" }, @@ -973,6 +1079,7 @@ "inactive": false, "legacyFolderID": "F3d872e0f753919e9852f9dd8f2d5115cb040723b4fb6a5561e9974400308a444", "searchTerms": ["Normal2 1"], + "slug": "tf-normal2-1", "title": "▽1", "where": "(chart.data->'tableFolders'->>'▽') = '1'" }, @@ -982,6 +1089,7 @@ "inactive": false, "legacyFolderID": "F6a2e4abc0eb75550e639fcf0a31168ab323a24957bc9a73dae270b5b578fe4c2", "searchTerms": ["Normal2 10"], + "slug": "tf-normal2-1-0", "title": "▽10", "where": "(chart.data->'tableFolders'->>'▽') = '10'" }, @@ -991,6 +1099,7 @@ "inactive": false, "legacyFolderID": "F3f7563059cd5dc80b51c49f57664f99dcd724850c9d09f8085c1b352c11f802d", "searchTerms": ["Normal2 11"], + "slug": "tf-normal2-1-1", "title": "▽11", "where": "(chart.data->'tableFolders'->>'▽') = '11'" }, @@ -1000,6 +1109,7 @@ "inactive": false, "legacyFolderID": "Fc7c34f7f4336d51f72f4694f6ea9fa8a8329a345848d3eb7e336358c3dcbe163", "searchTerms": ["Normal2 11+"], + "slug": "tf-normal2-1-1-p", "title": "▽11+", "where": "(chart.data->'tableFolders'->>'▽') = '11+'" }, @@ -1009,6 +1119,7 @@ "inactive": false, "legacyFolderID": "F7809148f1f83e154d9956203d5558b59ca6269028921d21ca563d4a6ade4496a", "searchTerms": ["Normal2 12"], + "slug": "tf-normal2-1-2", "title": "▽12", "where": "(chart.data->'tableFolders'->>'▽') = '12'" }, @@ -1018,6 +1129,7 @@ "inactive": false, "legacyFolderID": "Fb4d87d508d067662f3591f51d81f18e54fc3afb2c6666326f81c12872ea3235c", "searchTerms": ["Normal2 12-"], + "slug": "tf-normal2-1-2-dash", "title": "▽12-", "where": "(chart.data->'tableFolders'->>'▽') = '12-'" }, @@ -1027,6 +1139,7 @@ "inactive": false, "legacyFolderID": "F7a81d6e09dd8bcaebdfe43cd84bcf34b718aade6279d132ab3e0631d054e3886", "searchTerms": ["Normal2 12+"], + "slug": "tf-normal2-1-2-p", "title": "▽12+", "where": "(chart.data->'tableFolders'->>'▽') = '12+'" }, @@ -1036,6 +1149,7 @@ "inactive": false, "legacyFolderID": "Fd73c5cef26f9db29781a63c1470710c977b3f49064d61c6c91db0465103f69d7", "searchTerms": ["Normal2 2"], + "slug": "tf-normal2-2", "title": "▽2", "where": "(chart.data->'tableFolders'->>'▽') = '2'" }, @@ -1045,6 +1159,7 @@ "inactive": false, "legacyFolderID": "Fd823f18cb23402308e5b454d45edb805baafaa6d7203dad57808a59e2d1f1fff", "searchTerms": ["Normal2 3"], + "slug": "tf-normal2-3", "title": "▽3", "where": "(chart.data->'tableFolders'->>'▽') = '3'" }, @@ -1054,6 +1169,7 @@ "inactive": false, "legacyFolderID": "Fb9f4483a609387df04c4a7f1935359664c102612053da24573d85862600c9637", "searchTerms": ["Normal2 4"], + "slug": "tf-normal2-4", "title": "▽4", "where": "(chart.data->'tableFolders'->>'▽') = '4'" }, @@ -1063,6 +1179,7 @@ "inactive": false, "legacyFolderID": "F1771e5b74b804296dad660ab9f1c1c29ced97bdc38a8ed16c3ab877325b2b25d", "searchTerms": ["Normal2 5"], + "slug": "tf-normal2-5", "title": "▽5", "where": "(chart.data->'tableFolders'->>'▽') = '5'" }, @@ -1072,6 +1189,7 @@ "inactive": false, "legacyFolderID": "F903dc4c77b2da81c90119f1b6c33087ddbd58261533ac6ce27fad9456146e3db", "searchTerms": ["Normal2 6"], + "slug": "tf-normal2-6", "title": "▽6", "where": "(chart.data->'tableFolders'->>'▽') = '6'" }, @@ -1081,6 +1199,7 @@ "inactive": false, "legacyFolderID": "Fe54851480d86902dc6bf386d0a96e1755ee20ee9691fd20e2bad61f2882b92c6", "searchTerms": ["Normal2 7"], + "slug": "tf-normal2-7", "title": "▽7", "where": "(chart.data->'tableFolders'->>'▽') = '7'" }, @@ -1090,6 +1209,7 @@ "inactive": false, "legacyFolderID": "Fdc40fec8c64d983f9d0e62f7b6264b10d3dcc429237e81c4e0364972d3befad5", "searchTerms": ["Normal2 8"], + "slug": "tf-normal2-8", "title": "▽8", "where": "(chart.data->'tableFolders'->>'▽') = '8'" }, @@ -1099,6 +1219,7 @@ "inactive": false, "legacyFolderID": "F6f56958f640830ac1a14aba48348d55c670de4ff896d281c82ab85a2029d1aa7", "searchTerms": ["Normal2 9"], + "slug": "tf-normal2-9", "title": "▽9", "where": "(chart.data->'tableFolders'->>'▽') = '9'" }, @@ -1108,6 +1229,7 @@ "inactive": false, "legacyFolderID": "F922fd5f450c24ba8b185abea765e2f7547ad511d1dfa7d514e379602739cef38", "searchTerms": [], + "slug": "tf-lnoverjoy-1", "title": "◆◆1", "where": "(chart.data->'tableFolders'->>'◆◆') = '1'" }, @@ -1117,6 +1239,7 @@ "inactive": false, "legacyFolderID": "F86bc72d8fd637c277667785d0eb87d1212d6276e5618e16cbc67c627c3166847", "searchTerms": [], + "slug": "tf-lnoverjoy-1-0", "title": "◆◆10", "where": "(chart.data->'tableFolders'->>'◆◆') = '10'" }, @@ -1126,6 +1249,7 @@ "inactive": false, "legacyFolderID": "F9f8106872a5eea05b1178ac4dc43e8fe792f548d02203a424c43d78a85d8c0a2", "searchTerms": [], + "slug": "tf-lnoverjoy-1-1", "title": "◆◆11", "where": "(chart.data->'tableFolders'->>'◆◆') = '11'" }, @@ -1135,6 +1259,7 @@ "inactive": false, "legacyFolderID": "Ffc0778e31d1d6eb5850ae01be4b1804d6bd97baf02f6c3eb8b3208e3c4d8fcc6", "searchTerms": [], + "slug": "tf-lnoverjoy-1-2", "title": "◆◆12", "where": "(chart.data->'tableFolders'->>'◆◆') = '12'" }, @@ -1144,6 +1269,7 @@ "inactive": false, "legacyFolderID": "Fbb43bb052a11ef65d3d33e6e3f8210b3bd6a98a6b034d00908d99a0f981acbcd", "searchTerms": [], + "slug": "tf-lnoverjoy-1-3", "title": "◆◆13", "where": "(chart.data->'tableFolders'->>'◆◆') = '13'" }, @@ -1153,6 +1279,7 @@ "inactive": false, "legacyFolderID": "F3e29ccc393806694fbb1e0ebec6b55fa1b3a4715f28bcadd2c9ff33f8949c9fc", "searchTerms": [], + "slug": "tf-lnoverjoy-2", "title": "◆◆2", "where": "(chart.data->'tableFolders'->>'◆◆') = '2'" }, @@ -1162,6 +1289,7 @@ "inactive": false, "legacyFolderID": "F24e5b23650c1edd6748da66997bd04d01c142407dd7ee531be817faef696b831", "searchTerms": [], + "slug": "tf-lnoverjoy-3", "title": "◆◆3", "where": "(chart.data->'tableFolders'->>'◆◆') = '3'" }, @@ -1171,6 +1299,7 @@ "inactive": false, "legacyFolderID": "F17cfcdd33e012824dad1dec81b231c878a1aad20d801dee7d57ab2491d6928d9", "searchTerms": [], + "slug": "tf-lnoverjoy-4", "title": "◆◆4", "where": "(chart.data->'tableFolders'->>'◆◆') = '4'" }, @@ -1180,6 +1309,7 @@ "inactive": false, "legacyFolderID": "Fa379e26c300b189862e3b373fe273b04b65af49f227b817b2ac808cf6e7cff44", "searchTerms": [], + "slug": "tf-lnoverjoy-5", "title": "◆◆5", "where": "(chart.data->'tableFolders'->>'◆◆') = '5'" }, @@ -1189,6 +1319,7 @@ "inactive": false, "legacyFolderID": "F88ff4abf8ea97284c18add9d5eef03da2705824dfc5a815b1f3c60d4e37588c3", "searchTerms": [], + "slug": "tf-lnoverjoy-6", "title": "◆◆6", "where": "(chart.data->'tableFolders'->>'◆◆') = '6'" }, @@ -1198,6 +1329,7 @@ "inactive": false, "legacyFolderID": "F377b0530c99ff30f7aa240f089dc5f3c3f0d8edbdf40c7279617f5b5c8c6b8fb", "searchTerms": [], + "slug": "tf-lnoverjoy-7", "title": "◆◆7", "where": "(chart.data->'tableFolders'->>'◆◆') = '7'" }, @@ -1207,6 +1339,7 @@ "inactive": false, "legacyFolderID": "Fe01acdedb807c4fe76683f6b55838430d38bf200b5d236122095b4eabf481da4", "searchTerms": [], + "slug": "tf-lnoverjoy-8", "title": "◆◆8", "where": "(chart.data->'tableFolders'->>'◆◆') = '8'" }, @@ -1216,6 +1349,7 @@ "inactive": false, "legacyFolderID": "Fcad05b9f752c780ac4d5a5f763386099d8163ae2c9ae5c9795140e53634baec3", "searchTerms": [], + "slug": "tf-lnoverjoy-9", "title": "◆◆9", "where": "(chart.data->'tableFolders'->>'◆◆') = '9'" }, @@ -1225,6 +1359,7 @@ "inactive": false, "legacyFolderID": "F7d0b42d1d0a5d456e12835c66334a6b740616e2a498bbbf5d903c5439da6a6f0", "searchTerms": ["LN 1"], + "slug": "tf-ln-1", "title": "◆1", "where": "(chart.data->'tableFolders'->>'◆') = '1'" }, @@ -1234,6 +1369,7 @@ "inactive": false, "legacyFolderID": "Ff88c3ac50f22d3441dd078f407db1878a053dfcbd40bb42a45a3a94ed4db0048", "searchTerms": ["LN 10"], + "slug": "tf-ln-1-0", "title": "◆10", "where": "(chart.data->'tableFolders'->>'◆') = '10'" }, @@ -1243,6 +1379,7 @@ "inactive": false, "legacyFolderID": "Fcbb98173ba97e1eb7397e58be5e59388f96e5f1839138d66c18d71f4c7e8ea0e", "searchTerms": ["LN 11"], + "slug": "tf-ln-1-1", "title": "◆11", "where": "(chart.data->'tableFolders'->>'◆') = '11'" }, @@ -1252,6 +1389,7 @@ "inactive": false, "legacyFolderID": "F271552159baf362cd63cb9179a8692cc347115315ae9a2f1ae7d38a23c3053e0", "searchTerms": ["LN 12"], + "slug": "tf-ln-1-2", "title": "◆12", "where": "(chart.data->'tableFolders'->>'◆') = '12'" }, @@ -1261,6 +1399,7 @@ "inactive": false, "legacyFolderID": "Facc781cc1a955a4c940036a4cfafdf287feadcc1d6806f6b5bb6cb97eacd545d", "searchTerms": ["LN 13"], + "slug": "tf-ln-1-3", "title": "◆13", "where": "(chart.data->'tableFolders'->>'◆') = '13'" }, @@ -1270,6 +1409,7 @@ "inactive": false, "legacyFolderID": "Fa7ab0b2ec8c571551568f6c0d22a58d0f9e3fd421147a031d35ebc88c359c4f9", "searchTerms": ["LN 14"], + "slug": "tf-ln-1-4", "title": "◆14", "where": "(chart.data->'tableFolders'->>'◆') = '14'" }, @@ -1279,6 +1419,7 @@ "inactive": false, "legacyFolderID": "F24e692a44f1fe84e2b28a3534f1a9ea044d8a94f8a45e68999351bdde7e474c6", "searchTerms": ["LN 15"], + "slug": "tf-ln-1-5", "title": "◆15", "where": "(chart.data->'tableFolders'->>'◆') = '15'" }, @@ -1288,6 +1429,7 @@ "inactive": false, "legacyFolderID": "F652c39777e6ecaeb585880fc49f7c3cdf0c373c819ba62c4e34d11f3feebd460", "searchTerms": ["LN 16"], + "slug": "tf-ln-1-6", "title": "◆16", "where": "(chart.data->'tableFolders'->>'◆') = '16'" }, @@ -1297,6 +1439,7 @@ "inactive": false, "legacyFolderID": "F00a1bde1ebbc7a77c9bb1c545800ac0d58c4b14fa5b1f92b2218080030e9273e", "searchTerms": ["LN 17"], + "slug": "tf-ln-1-7", "title": "◆17", "where": "(chart.data->'tableFolders'->>'◆') = '17'" }, @@ -1306,6 +1449,7 @@ "inactive": false, "legacyFolderID": "F31213e832442973a63c22ea8e5d18cd0baf75e7273486423e5495481229cc049", "searchTerms": ["LN 18"], + "slug": "tf-ln-1-8", "title": "◆18", "where": "(chart.data->'tableFolders'->>'◆') = '18'" }, @@ -1315,6 +1459,7 @@ "inactive": false, "legacyFolderID": "F0be6cdae6f6b30c8fa4889c487f1a77c954c56ebefd17dd5b69338242a54be25", "searchTerms": ["LN 19"], + "slug": "tf-ln-1-9", "title": "◆19", "where": "(chart.data->'tableFolders'->>'◆') = '19'" }, @@ -1324,6 +1469,7 @@ "inactive": false, "legacyFolderID": "Fb073b854c419e278ace5f65088ce4425be15f0595cf6b0bbd3bf86415891df37", "searchTerms": ["LN 2"], + "slug": "tf-ln-2", "title": "◆2", "where": "(chart.data->'tableFolders'->>'◆') = '2'" }, @@ -1333,6 +1479,7 @@ "inactive": false, "legacyFolderID": "F20ee0910ea6e81d9f0decb73ebcfeeda6c371303658acaf15a7ec44b6d1145fb", "searchTerms": ["LN 20"], + "slug": "tf-ln-2-0", "title": "◆20", "where": "(chart.data->'tableFolders'->>'◆') = '20'" }, @@ -1342,6 +1489,7 @@ "inactive": false, "legacyFolderID": "F768c371e87c620cd000dcffa2ff2446fe4d5a3b87854f9455eef4f0933e32b50", "searchTerms": ["LN 21"], + "slug": "tf-ln-2-1", "title": "◆21", "where": "(chart.data->'tableFolders'->>'◆') = '21'" }, @@ -1351,6 +1499,7 @@ "inactive": false, "legacyFolderID": "F0a30e2046578a4ed2acdfe60e21c3ceee8a2d05550608aedd86bfe522bdf5450", "searchTerms": ["LN 22"], + "slug": "tf-ln-2-2", "title": "◆22", "where": "(chart.data->'tableFolders'->>'◆') = '22'" }, @@ -1360,6 +1509,7 @@ "inactive": false, "legacyFolderID": "F0f6a299a130ec188d4cb7445ce346cbaf8f9bfa604d196da4654f91c7f2277d7", "searchTerms": ["LN 23"], + "slug": "tf-ln-2-3", "title": "◆23", "where": "(chart.data->'tableFolders'->>'◆') = '23'" }, @@ -1369,6 +1519,7 @@ "inactive": false, "legacyFolderID": "Fa4e5beb16d689894b931b4699c1e286b59618247510f71feb15dfcb2f593d218", "searchTerms": ["LN 24"], + "slug": "tf-ln-2-4", "title": "◆24", "where": "(chart.data->'tableFolders'->>'◆') = '24'" }, @@ -1378,6 +1529,7 @@ "inactive": false, "legacyFolderID": "F051dc7c9a243d4f99723c372e79943a110435cf67c773fbf5a878285ec5cb479", "searchTerms": ["LN 25"], + "slug": "tf-ln-2-5", "title": "◆25", "where": "(chart.data->'tableFolders'->>'◆') = '25'" }, @@ -1387,6 +1539,7 @@ "inactive": false, "legacyFolderID": "Fe60cc95c0c77785c92eeffb74674bc0b3d96b7c933ec24810058bd869aa20168", "searchTerms": ["LN 26"], + "slug": "tf-ln-2-6", "title": "◆26", "where": "(chart.data->'tableFolders'->>'◆') = '26'" }, @@ -1396,6 +1549,7 @@ "inactive": false, "legacyFolderID": "F2c3f0f67dd3f09e048f5a5e49c89c997f4dd9064200d1eab2464fd39f2f1ea7d", "searchTerms": ["LN 3"], + "slug": "tf-ln-3", "title": "◆3", "where": "(chart.data->'tableFolders'->>'◆') = '3'" }, @@ -1405,6 +1559,7 @@ "inactive": false, "legacyFolderID": "F12aaef02ab66721ad1f397c7a74f88753792330ef0dfab792a3c8333daa56961", "searchTerms": ["LN 4"], + "slug": "tf-ln-4", "title": "◆4", "where": "(chart.data->'tableFolders'->>'◆') = '4'" }, @@ -1414,6 +1569,7 @@ "inactive": false, "legacyFolderID": "Fc482345761f8558e58042eadbe7ff0f000c4dd0e241be14d0ddc436403f03b0c", "searchTerms": ["LN 5"], + "slug": "tf-ln-5", "title": "◆5", "where": "(chart.data->'tableFolders'->>'◆') = '5'" }, @@ -1423,6 +1579,7 @@ "inactive": false, "legacyFolderID": "F6b20d2602d322e9556cc8a8912dcb1df0fd37e255838c07b4c6c499f9ca6955d", "searchTerms": ["LN 6"], + "slug": "tf-ln-6", "title": "◆6", "where": "(chart.data->'tableFolders'->>'◆') = '6'" }, @@ -1432,6 +1589,7 @@ "inactive": false, "legacyFolderID": "F804c51f1399a61a7f297c9b8882fdd6c9f07e1e9e2f0be8a7bcc9aea5eee1006", "searchTerms": ["LN 7"], + "slug": "tf-ln-7", "title": "◆7", "where": "(chart.data->'tableFolders'->>'◆') = '7'" }, @@ -1441,6 +1599,7 @@ "inactive": false, "legacyFolderID": "Fb4d0c48b323bd10cc8e723098bb763a5e5ebac876f5e30e48bd0905f9ba9b9a7", "searchTerms": ["LN 8"], + "slug": "tf-ln-8", "title": "◆8", "where": "(chart.data->'tableFolders'->>'◆') = '8'" }, @@ -1450,6 +1609,7 @@ "inactive": false, "legacyFolderID": "F6e6e39a80c0bf22a1e270e24dd989c2add2a5e4c7ec154ef80bf5bab67d43993", "searchTerms": ["LN 9"], + "slug": "tf-ln-9", "title": "◆9", "where": "(chart.data->'tableFolders'->>'◆') = '9'" }, @@ -1459,6 +1619,7 @@ "inactive": false, "legacyFolderID": "F8f459cc2d2221c012e4cc7e63ab79e0244babbd541f937513fb3d9c1192d0040", "searchTerms": ["Insane ???"], + "slug": "tf-insane-q-q-q", "title": "★???", "where": "(chart.data->'tableFolders'->>'★') = '???'" }, @@ -1468,6 +1629,7 @@ "inactive": false, "legacyFolderID": "F5314bf08b46fda13f7ea87bb77ff2533a3968eb09cc549e970cb64af5c0848ec", "searchTerms": ["Overjoy 0"], + "slug": "tf-overjoy-0", "title": "★★0", "where": "(chart.data->'tableFolders'->>'★★') = '0'" }, @@ -1477,6 +1639,7 @@ "inactive": false, "legacyFolderID": "Fee469b2db17e2e2e9b0095a0548fc8e7f2d50a85584217216aeb7e47bd1a84e4", "searchTerms": ["Overjoy 1"], + "slug": "tf-overjoy-1", "title": "★★1", "where": "(chart.data->'tableFolders'->>'★★') = '1'" }, @@ -1486,6 +1649,7 @@ "inactive": false, "legacyFolderID": "Fd5d5bbb17c801eb150502748601988d0671d1673ddc35445b4a1701da3deff4f", "searchTerms": ["Overjoy 2"], + "slug": "tf-overjoy-2", "title": "★★2", "where": "(chart.data->'tableFolders'->>'★★') = '2'" }, @@ -1495,6 +1659,7 @@ "inactive": false, "legacyFolderID": "Fa479b38008455f3f1f45b653810e90f8486bfd4ef24862b8db93cfc22f9dc56f", "searchTerms": ["Overjoy 3"], + "slug": "tf-overjoy-3", "title": "★★3", "where": "(chart.data->'tableFolders'->>'★★') = '3'" }, @@ -1504,6 +1669,7 @@ "inactive": false, "legacyFolderID": "Ff9fd13260907e7f258d6598972a78077c0f82bf4a53e96df0172acf8b61cca6a", "searchTerms": ["Overjoy 4"], + "slug": "tf-overjoy-4", "title": "★★4", "where": "(chart.data->'tableFolders'->>'★★') = '4'" }, @@ -1513,6 +1679,7 @@ "inactive": false, "legacyFolderID": "Fc73dee6d3d308f38297b6451c5dc7f201327fd6afd443ffbb7e1a151b2db0fce", "searchTerms": ["Overjoy 5"], + "slug": "tf-overjoy-5", "title": "★★5", "where": "(chart.data->'tableFolders'->>'★★') = '5'" }, @@ -1522,6 +1689,7 @@ "inactive": false, "legacyFolderID": "F28d268c18adf47bd3b75699852132866c049d89bbb44a64479efa2d40fd0cbce", "searchTerms": ["Overjoy 6"], + "slug": "tf-overjoy-6", "title": "★★6", "where": "(chart.data->'tableFolders'->>'★★') = '6'" }, @@ -1531,6 +1699,7 @@ "inactive": false, "legacyFolderID": "Fa3c17cc683d11f69f7fed4be2e56ea92fca8e0e86cc4e289f727664e984a6bb6", "searchTerms": ["Overjoy 7"], + "slug": "tf-overjoy-7", "title": "★★7", "where": "(chart.data->'tableFolders'->>'★★') = '7'" }, @@ -1540,6 +1709,7 @@ "inactive": false, "legacyFolderID": "F94b3e236aee4181287c10f405a60a27ae9ac6b8bae38c6126cfcc0280a32df33", "searchTerms": ["Overjoy 8"], + "slug": "tf-overjoy-8", "title": "★★8", "where": "(chart.data->'tableFolders'->>'★★') = '8'" }, @@ -1549,6 +1719,7 @@ "inactive": false, "legacyFolderID": "F19e837c7f0c1561f88a0dc1fff276e624bcb67f52a8165b8f2772a73ab2dee45", "searchTerms": ["Insane 1"], + "slug": "tf-insane-1", "title": "★1", "where": "(chart.data->'tableFolders'->>'★') = '1'" }, @@ -1558,6 +1729,7 @@ "inactive": false, "legacyFolderID": "Ff4bdb759679ad3a76f69228f516f5d5a6f91c9436ff93a9baeca376f6f8cb38e", "searchTerms": ["Insane 10"], + "slug": "tf-insane-1-0", "title": "★10", "where": "(chart.data->'tableFolders'->>'★') = '10'" }, @@ -1567,6 +1739,7 @@ "inactive": false, "legacyFolderID": "F5224cbf4bb41b8f77e7e427bfe2b850af2c6fb2bceec29eceeda32ee7588fa1a", "searchTerms": ["Insane 11"], + "slug": "tf-insane-1-1", "title": "★11", "where": "(chart.data->'tableFolders'->>'★') = '11'" }, @@ -1576,6 +1749,7 @@ "inactive": false, "legacyFolderID": "F899a748da2d37e426ec11ea7da5f95b6bc1d37d91813c0fcb45dc1e224713207", "searchTerms": ["Insane 12"], + "slug": "tf-insane-1-2", "title": "★12", "where": "(chart.data->'tableFolders'->>'★') = '12'" }, @@ -1585,6 +1759,7 @@ "inactive": false, "legacyFolderID": "F5912e35dc358581d44aa1ee9d338f11108aba846bff1e9ebc3750514204f06ac", "searchTerms": ["Insane 13"], + "slug": "tf-insane-1-3", "title": "★13", "where": "(chart.data->'tableFolders'->>'★') = '13'" }, @@ -1594,6 +1769,7 @@ "inactive": false, "legacyFolderID": "Fde841e850cdad7d2407d19c945af869f9a717cdd82fa1045897b8fefbd81d8b9", "searchTerms": ["Insane 14"], + "slug": "tf-insane-1-4", "title": "★14", "where": "(chart.data->'tableFolders'->>'★') = '14'" }, @@ -1603,6 +1779,7 @@ "inactive": false, "legacyFolderID": "Fe56cb37b24994d6dd05b397b94f42b9359f4880977b3068db4196b8e03b31681", "searchTerms": ["Insane 15"], + "slug": "tf-insane-1-5", "title": "★15", "where": "(chart.data->'tableFolders'->>'★') = '15'" }, @@ -1612,6 +1789,7 @@ "inactive": false, "legacyFolderID": "Fc6ce6054f4806b4fc703fe80153a0a1c800a11fa57b7dfd63d261053205f5443", "searchTerms": ["Insane 16"], + "slug": "tf-insane-1-6", "title": "★16", "where": "(chart.data->'tableFolders'->>'★') = '16'" }, @@ -1621,6 +1799,7 @@ "inactive": false, "legacyFolderID": "F17a0ecb5ab6ec5bd570815f066a408dc7b31c03573bc2dab18d58cbf00dd6b58", "searchTerms": ["Insane 17"], + "slug": "tf-insane-1-7", "title": "★17", "where": "(chart.data->'tableFolders'->>'★') = '17'" }, @@ -1630,6 +1809,7 @@ "inactive": false, "legacyFolderID": "Fa8bff3bf18e99ab1043e13bb3ba6b468282dd4229395a5499ebbbc259c1a1932", "searchTerms": ["Insane 18"], + "slug": "tf-insane-1-8", "title": "★18", "where": "(chart.data->'tableFolders'->>'★') = '18'" }, @@ -1639,6 +1819,7 @@ "inactive": false, "legacyFolderID": "F99d8d36668e0fb5225ce16c19cc5c5201fcfc2f7714fee1bb73f00838078a814", "searchTerms": ["Insane 19"], + "slug": "tf-insane-1-9", "title": "★19", "where": "(chart.data->'tableFolders'->>'★') = '19'" }, @@ -1648,6 +1829,7 @@ "inactive": false, "legacyFolderID": "Fe5a286ac70b98d3706149581a09bad6237372aa619c6f46eaeb348ba2f1fb020", "searchTerms": ["Insane 2"], + "slug": "tf-insane-2", "title": "★2", "where": "(chart.data->'tableFolders'->>'★') = '2'" }, @@ -1657,6 +1839,7 @@ "inactive": false, "legacyFolderID": "Fb543a69be562da838bcd6095caf8055757c94438481b2bc12b23978ba143e680", "searchTerms": ["Insane 20"], + "slug": "tf-insane-2-0", "title": "★20", "where": "(chart.data->'tableFolders'->>'★') = '20'" }, @@ -1666,6 +1849,7 @@ "inactive": false, "legacyFolderID": "Ff210f2935b06bde48359221a3429d401668ab0713b7006024c5b8a1b167d114e", "searchTerms": ["Insane 21"], + "slug": "tf-insane-2-1", "title": "★21", "where": "(chart.data->'tableFolders'->>'★') = '21'" }, @@ -1675,6 +1859,7 @@ "inactive": false, "legacyFolderID": "F4bc22d5c2fff4249e7596b2ec7b3144b58ef899d5ff27322965d4aac1f79ee0e", "searchTerms": ["Insane 22"], + "slug": "tf-insane-2-2", "title": "★22", "where": "(chart.data->'tableFolders'->>'★') = '22'" }, @@ -1684,6 +1869,7 @@ "inactive": false, "legacyFolderID": "F89c2a4bb163509004b65c487f21d17886788470409250ce46427229dd099aaca", "searchTerms": ["Insane 23"], + "slug": "tf-insane-2-3", "title": "★23", "where": "(chart.data->'tableFolders'->>'★') = '23'" }, @@ -1693,6 +1879,7 @@ "inactive": false, "legacyFolderID": "F6c947c68ced3954fa0922a1fbeacc90396ad01d0efeab5a025219e9441da6a2b", "searchTerms": ["Insane 24"], + "slug": "tf-insane-2-4", "title": "★24", "where": "(chart.data->'tableFolders'->>'★') = '24'" }, @@ -1702,6 +1889,7 @@ "inactive": false, "legacyFolderID": "F64749f9fe72554bd467f897199725d82e7c6bb663734b9dc8954d4d5f353200c", "searchTerms": ["Insane 25"], + "slug": "tf-insane-2-5", "title": "★25", "where": "(chart.data->'tableFolders'->>'★') = '25'" }, @@ -1711,6 +1899,7 @@ "inactive": false, "legacyFolderID": "F5afafce31bb27de040a20a322f48e2ba282d3174d4c85d71cb5322162fa35d42", "searchTerms": ["Insane 3"], + "slug": "tf-insane-3", "title": "★3", "where": "(chart.data->'tableFolders'->>'★') = '3'" }, @@ -1720,6 +1909,7 @@ "inactive": false, "legacyFolderID": "F608084d2a4cc48bdd189140e5f4dc65c1c2d11d789003ecc69d8a61a9ab8e43b", "searchTerms": ["Insane 4"], + "slug": "tf-insane-4", "title": "★4", "where": "(chart.data->'tableFolders'->>'★') = '4'" }, @@ -1729,6 +1919,7 @@ "inactive": false, "legacyFolderID": "Fe7f71d5cb8b691630a633e994e5e2d01cac26b07d3fe9c1a8c4cc78c2bb453de", "searchTerms": ["Insane 5"], + "slug": "tf-insane-5", "title": "★5", "where": "(chart.data->'tableFolders'->>'★') = '5'" }, @@ -1738,6 +1929,7 @@ "inactive": false, "legacyFolderID": "Fccd755e3f1b5c2ab1fa1165636412943a0d74f4633365e044ac60f1eb0af4301", "searchTerms": ["Insane 6"], + "slug": "tf-insane-6", "title": "★6", "where": "(chart.data->'tableFolders'->>'★') = '6'" }, @@ -1747,6 +1939,7 @@ "inactive": false, "legacyFolderID": "F9ac2124d61bc3a2578260a468abbedcf9955229e1c0b0404d8eacb5401875c5c", "searchTerms": ["Insane 7"], + "slug": "tf-insane-7", "title": "★7", "where": "(chart.data->'tableFolders'->>'★') = '7'" }, @@ -1756,6 +1949,7 @@ "inactive": false, "legacyFolderID": "F6adb4b13ea410cb2f0f3fe47c047680a74b0bb842ce20dff47d21053d78a1eed", "searchTerms": ["Insane 8"], + "slug": "tf-insane-8", "title": "★8", "where": "(chart.data->'tableFolders'->>'★') = '8'" }, @@ -1765,6 +1959,7 @@ "inactive": false, "legacyFolderID": "F7f7e0399e965439c60e70295bc80d61b511f69f9e04091b3965678af97aff817", "searchTerms": ["Insane 9"], + "slug": "tf-insane-9", "title": "★9", "where": "(chart.data->'tableFolders'->>'★') = '9'" }, @@ -1774,6 +1969,7 @@ "inactive": false, "legacyFolderID": "Fccca26c54f38f5d07b1de00d6f5e07e50ac36b9f0a2dcb967a849abc7e7faf54", "searchTerms": ["Normal 1"], + "slug": "tf-normal-1", "title": "☆1", "where": "(chart.data->'tableFolders'->>'☆') = '1'" }, @@ -1783,6 +1979,7 @@ "inactive": false, "legacyFolderID": "Fd4d719757973db7aa14ba660a9b925b5656f3e5a7fbcc94d3cc165597b7d9bfd", "searchTerms": ["Normal 10"], + "slug": "tf-normal-1-0", "title": "☆10", "where": "(chart.data->'tableFolders'->>'☆') = '10'" }, @@ -1792,6 +1989,7 @@ "inactive": false, "legacyFolderID": "F3046664705b059494be27c52bdc9bc9131722e29acc3fff94ba1b2d2074d5e9e", "searchTerms": ["Normal 11"], + "slug": "tf-normal-1-1", "title": "☆11", "where": "(chart.data->'tableFolders'->>'☆') = '11'" }, @@ -1801,6 +1999,7 @@ "inactive": false, "legacyFolderID": "Fb196b2d26f3c9d310079d00c39297f46fd65f16152cd4ab1e86c3a99ba48b5b3", "searchTerms": ["Normal 12"], + "slug": "tf-normal-1-2", "title": "☆12", "where": "(chart.data->'tableFolders'->>'☆') = '12'" }, @@ -1810,6 +2009,7 @@ "inactive": false, "legacyFolderID": "F39deafec06c4d16b896afd2d0cd36d5ad6be20bf6948009dbb24552a67580f79", "searchTerms": ["Normal 13"], + "slug": "tf-normal-1-3", "title": "☆13", "where": "(chart.data->'tableFolders'->>'☆') = '13'" }, @@ -1819,6 +2019,7 @@ "inactive": false, "legacyFolderID": "Fa6aa1e171b9e5af6e1e6f8bc74f3b10de062b2f244ae4e3232902e11d56299f0", "searchTerms": ["Normal 2"], + "slug": "tf-normal-2", "title": "☆2", "where": "(chart.data->'tableFolders'->>'☆') = '2'" }, @@ -1828,6 +2029,7 @@ "inactive": false, "legacyFolderID": "F01a87c05a35e2795ddf3d5622c2d23b9f21e200a2adb09f4ae0811c2c6f7b5ed", "searchTerms": ["Normal 3"], + "slug": "tf-normal-3", "title": "☆3", "where": "(chart.data->'tableFolders'->>'☆') = '3'" }, @@ -1837,6 +2039,7 @@ "inactive": false, "legacyFolderID": "F0972d16357a7a9f3edf0557b2016614acc5ac7a412a31c500d6fbd9666420734", "searchTerms": ["Normal 4"], + "slug": "tf-normal-4", "title": "☆4", "where": "(chart.data->'tableFolders'->>'☆') = '4'" }, @@ -1846,6 +2049,7 @@ "inactive": false, "legacyFolderID": "F34ebec22b6553d8baa585c21bb162fbf9dddf2aeedd390b356cc55c0491ab277", "searchTerms": ["Normal 5"], + "slug": "tf-normal-5", "title": "☆5", "where": "(chart.data->'tableFolders'->>'☆') = '5'" }, @@ -1855,6 +2059,7 @@ "inactive": false, "legacyFolderID": "Fcf00b9aabd21d713bad1488f8aabb4e0587870fe7d60fc97b5cbf6db71b1a004", "searchTerms": ["Normal 6"], + "slug": "tf-normal-6", "title": "☆6", "where": "(chart.data->'tableFolders'->>'☆') = '6'" }, @@ -1864,6 +2069,7 @@ "inactive": false, "legacyFolderID": "Fc7aaaf4d88103722185dee5a5701778385d209d12bf4a676788328d543d96e4e", "searchTerms": ["Normal 7"], + "slug": "tf-normal-7", "title": "☆7", "where": "(chart.data->'tableFolders'->>'☆') = '7'" }, @@ -1873,6 +2079,7 @@ "inactive": false, "legacyFolderID": "Fbd351ecf66ff1d0103e725730a657de15b777f8a84bccdc0c37759e3ac25dd10", "searchTerms": ["Normal 8"], + "slug": "tf-normal-8", "title": "☆8", "where": "(chart.data->'tableFolders'->>'☆') = '8'" }, @@ -1882,6 +2089,7 @@ "inactive": false, "legacyFolderID": "Fd1e35f8603fdf9e2d1520b1158a90f43c133fc7f214a1e2d4af8dd21e42bbfb0", "searchTerms": ["Normal 9"], + "slug": "tf-normal-9", "title": "☆9", "where": "(chart.data->'tableFolders'->>'☆') = '9'" }, @@ -1891,6 +2099,7 @@ "inactive": false, "legacyFolderID": "Fb0a3daa863db00b82926439b24fbeebbb1f2c8a3b43679970596b2cf93cdb775", "searchTerms": ["Normal X"], + "slug": "tf-normal-x", "title": "☆X", "where": "(chart.data->'tableFolders'->>'☆') = 'X'" }, @@ -1900,6 +2109,7 @@ "inactive": false, "legacyFolderID": "Fdfdce10ee518f37c4ab3657abf2ce912cbf523359e2689a96ecf020b0ab8bb26", "searchTerms": ["armShougakkou"], + "slug": "tf-armshougakkou-present", "title": "Arm Shougakkou", "where": "(chart.data->'tableFolders') ? 'Ude'" }, @@ -1909,6 +2119,7 @@ "inactive": false, "legacyFolderID": "Fcf221f3abd17ae3607718898be929121d44c3582c90e300c6e7efdc83df6187a", "searchTerms": ["delayjoy"], + "slug": "tf-delayjoy-present", "title": "delayjoy", "where": "(chart.data->'tableFolders') ? 'dl'" }, @@ -1918,6 +2129,7 @@ "inactive": false, "legacyFolderID": "Fc1c9354e928bda2e964e870ca6ddbdb9d8d16c278864c1c03f953b2d2f58fc70", "searchTerms": [], + "slug": "tf-delayjoy-u20-d-e-l-a-y-u20-b-e-g-i-n-n-e-r", "title": "dl DELAY BEGINNER", "where": "(chart.data->'tableFolders'->>'dl') = ' DELAY BEGINNER'" }, @@ -1927,6 +2139,7 @@ "inactive": false, "legacyFolderID": "F7564a6064a959aa3330f50874199894546696333189ae3f62a690c4655caa7d1", "searchTerms": [], + "slug": "tf-delayjoy-u20-d-e-l-a-y-u20-m-a-s-t-e-r", "title": "dl DELAY MASTER", "where": "(chart.data->'tableFolders'->>'dl') = ' DELAY MASTER'" }, @@ -1936,6 +2149,7 @@ "inactive": false, "legacyFolderID": "F8b333132a49460ab61e84014c97875f40b6a9e968016071cd664104a8c737696", "searchTerms": [], + "slug": "tf-delayjoy-dash-1", "title": "dl-1", "where": "(chart.data->'tableFolders'->>'dl') = '-1'" }, @@ -1945,6 +2159,7 @@ "inactive": false, "legacyFolderID": "F2d4d1253fedb8cbd8ae0a6ea91406b9e03c7b80d87f019d17e4c5013f53c918c", "searchTerms": [], + "slug": "tf-delayjoy-dash-2", "title": "dl-2", "where": "(chart.data->'tableFolders'->>'dl') = '-2'" }, @@ -1954,6 +2169,7 @@ "inactive": false, "legacyFolderID": "F263a2a84b04568658df5a67818e4f399bb3de3344d63a4fe35dc3c1c32140a0d", "searchTerms": [], + "slug": "tf-delayjoy-0", "title": "dl0", "where": "(chart.data->'tableFolders'->>'dl') = '0'" }, @@ -1963,6 +2179,7 @@ "inactive": false, "legacyFolderID": "F362c5ef8ed5a97fe6e7f6eac22e59638ae57d93559a173b8be18c6750ce4daef", "searchTerms": [], + "slug": "tf-delayjoy-1", "title": "dl1", "where": "(chart.data->'tableFolders'->>'dl') = '1'" }, @@ -1972,6 +2189,7 @@ "inactive": false, "legacyFolderID": "F9bb597c4c96d2d461c2d87fc51da69cadd74f5ef63fd1cfb25aabcf01fe4fba3", "searchTerms": [], + "slug": "tf-delayjoy-1-0", "title": "dl10", "where": "(chart.data->'tableFolders'->>'dl') = '10'" }, @@ -1981,6 +2199,7 @@ "inactive": false, "legacyFolderID": "F8bfcdab7b1978ad2ca33dd75cd8677594547a9a9aa6f05fd875ea67cfc9d76e0", "searchTerms": [], + "slug": "tf-delayjoy-1-1", "title": "dl11", "where": "(chart.data->'tableFolders'->>'dl') = '11'" }, @@ -1990,6 +2209,7 @@ "inactive": false, "legacyFolderID": "F638aff74990ebaf19eaafe1d5b53c33c82b9f4d61d5c0fe1285e3c759d693f33", "searchTerms": [], + "slug": "tf-delayjoy-1-2", "title": "dl12", "where": "(chart.data->'tableFolders'->>'dl') = '12'" }, @@ -1999,6 +2219,7 @@ "inactive": false, "legacyFolderID": "F8a6c56f1da112ba17008898b3b8163c18d542f10bf2f8366fef0f085bb60b5f1", "searchTerms": [], + "slug": "tf-delayjoy-2", "title": "dl2", "where": "(chart.data->'tableFolders'->>'dl') = '2'" }, @@ -2008,6 +2229,7 @@ "inactive": false, "legacyFolderID": "Fa2e5b83869994e350bc21fb1b289bcf0cf5602ccf23baf3ad63685ef3e35258a", "searchTerms": [], + "slug": "tf-delayjoy-3", "title": "dl3", "where": "(chart.data->'tableFolders'->>'dl') = '3'" }, @@ -2017,6 +2239,7 @@ "inactive": false, "legacyFolderID": "F57b91005a8de0c24fa0ad97c326036d573d8e85245598d83504dda344bbf3289", "searchTerms": [], + "slug": "tf-delayjoy-4", "title": "dl4", "where": "(chart.data->'tableFolders'->>'dl') = '4'" }, @@ -2026,6 +2249,7 @@ "inactive": false, "legacyFolderID": "Fcbe1af365e5b1fefdead9e9c964c7a7dfc065b3210f5a6deb0b1b786f0c3704f", "searchTerms": [], + "slug": "tf-delayjoy-5", "title": "dl5", "where": "(chart.data->'tableFolders'->>'dl') = '5'" }, @@ -2035,6 +2259,7 @@ "inactive": false, "legacyFolderID": "F7ec784427c8a29fcd38499ac3fecbbaf32a4d409888973640a986210ca504336", "searchTerms": [], + "slug": "tf-delayjoy-6", "title": "dl6", "where": "(chart.data->'tableFolders'->>'dl') = '6'" }, @@ -2044,6 +2269,7 @@ "inactive": false, "legacyFolderID": "F99d9be6408cbe5ebf5aba5fa8beb6cfc76aaf155bec57a65efad9a745641f006", "searchTerms": [], + "slug": "tf-delayjoy-7", "title": "dl7", "where": "(chart.data->'tableFolders'->>'dl') = '7'" }, @@ -2053,6 +2279,7 @@ "inactive": false, "legacyFolderID": "Fc6108a926fc179610399b764454daf721dfcb4f1964e902972c58a1e6054210c", "searchTerms": [], + "slug": "tf-delayjoy-8", "title": "dl8", "where": "(chart.data->'tableFolders'->>'dl') = '8'" }, @@ -2062,6 +2289,7 @@ "inactive": false, "legacyFolderID": "Fa582c8b28d1e9847ced7391da0e1f13775c5ea1e67d5bd60dbf17d654a647922", "searchTerms": [], + "slug": "tf-delayjoy-9", "title": "dl9", "where": "(chart.data->'tableFolders'->>'dl') = '9'" }, @@ -2071,6 +2299,7 @@ "inactive": false, "legacyFolderID": "F4feae15fe1c3f525edb0f955f392c79ac502ec80219a4271af5b9b1a78cfb00c", "searchTerms": [], + "slug": "tf-dystopia-uff1f", "title": "dy?", "where": "(chart.data->'tableFolders'->>'dy') = '?'" }, @@ -2080,6 +2309,7 @@ "inactive": false, "legacyFolderID": "Faca9f7c3a64e81cb18ab3489c31408541f725a397f9bdecfc47cc41966d40012", "searchTerms": [], + "slug": "tf-dystopia-0", "title": "dy0", "where": "(chart.data->'tableFolders'->>'dy') = '0'" }, @@ -2089,6 +2319,7 @@ "inactive": false, "legacyFolderID": "Fba6f89c636959478c6f8c8da1090c70f532229a6520c827435e3aa9945c8490b", "searchTerms": [], + "slug": "tf-dystopia-1", "title": "dy1", "where": "(chart.data->'tableFolders'->>'dy') = '1'" }, @@ -2098,6 +2329,7 @@ "inactive": false, "legacyFolderID": "Fd2fdecdf1c0574b0f17aedcbefb29ea99ed58a8a693cc3bdc1e3f32500fb27a5", "searchTerms": [], + "slug": "tf-dystopia-2", "title": "dy2", "where": "(chart.data->'tableFolders'->>'dy') = '2'" }, @@ -2107,6 +2339,7 @@ "inactive": false, "legacyFolderID": "Fe708c9bb01b971d030b36a27556a204e27fe74a33eba1bdca16fcdc5ada4f992", "searchTerms": [], + "slug": "tf-dystopia-3", "title": "dy3", "where": "(chart.data->'tableFolders'->>'dy') = '3'" }, @@ -2116,6 +2349,7 @@ "inactive": false, "legacyFolderID": "F5d62516c32ea5da46c21a2b1aa1c9ad349613cf39fa76113c081cd21baf9cf0a", "searchTerms": [], + "slug": "tf-dystopia-4", "title": "dy4", "where": "(chart.data->'tableFolders'->>'dy') = '4'" }, @@ -2125,6 +2359,7 @@ "inactive": false, "legacyFolderID": "F4556c9708a752f6f13307fe00549a0bd7f6bdbe6f25d1f72065999e9c116dc3c", "searchTerms": [], + "slug": "tf-dystopia-5", "title": "dy5", "where": "(chart.data->'tableFolders'->>'dy') = '5'" }, @@ -2134,6 +2369,7 @@ "inactive": false, "legacyFolderID": "F8ac40f4583b76adfaec22b9c0c5ae20a544ba350d385272d212cf9971a056821", "searchTerms": [], + "slug": "tf-dystopia-6", "title": "dy6", "where": "(chart.data->'tableFolders'->>'dy') = '6'" }, @@ -2143,6 +2379,7 @@ "inactive": false, "legacyFolderID": "Fed65fc214bf9e0256a23d6798c75d2f3f856f40b80c653611cae738026681a28", "searchTerms": [], + "slug": "tf-dystopia-7", "title": "dy7", "where": "(chart.data->'tableFolders'->>'dy') = '7'" }, @@ -2152,6 +2389,7 @@ "inactive": false, "legacyFolderID": "F9bdaa06980b75f3406dbaa93ed2bf1be47b6b60cfb1463a3b7c88d5c350963c6", "searchTerms": ["dystopia"], + "slug": "tf-dystopia-present", "title": "Dystopia", "where": "(chart.data->'tableFolders') ? 'dy'" }, @@ -2161,6 +2399,7 @@ "inactive": false, "legacyFolderID": "F060330761a73661c7085237ceae7c1cf126f0d32ef8501bf6925fae122883a10", "searchTerms": ["Sieglinde Insane 1"], + "slug": "sglec-ge-13-lt-14", "title": "EC ★1", "where": "(chart.data->>'sglEC')::numeric >= 13 AND (chart.data->>'sglEC')::numeric < 14" }, @@ -2170,6 +2409,7 @@ "inactive": false, "legacyFolderID": "F6c8f1a0bd2625651daeabd4977dd5f532344b62dcc95705eb0f14511e5082ba8", "searchTerms": ["Sieglinde Insane 10"], + "slug": "sglec-ge-22-lt-23", "title": "EC ★10", "where": "(chart.data->>'sglEC')::numeric >= 22 AND (chart.data->>'sglEC')::numeric < 23" }, @@ -2179,6 +2419,7 @@ "inactive": false, "legacyFolderID": "F84656713070f9dd537820e2240f734e4a399c199dd696dab1248d7253927a754", "searchTerms": ["Sieglinde Insane 11"], + "slug": "sglec-ge-23-lt-24", "title": "EC ★11", "where": "(chart.data->>'sglEC')::numeric >= 23 AND (chart.data->>'sglEC')::numeric < 24" }, @@ -2188,6 +2429,7 @@ "inactive": false, "legacyFolderID": "F4d261e63ac061ca043061f63c46cdb6c591217b498201c463eaa2a2e612c1abe", "searchTerms": ["Sieglinde Insane 12"], + "slug": "sglec-ge-24-lt-25", "title": "EC ★12", "where": "(chart.data->>'sglEC')::numeric >= 24 AND (chart.data->>'sglEC')::numeric < 25" }, @@ -2197,6 +2439,7 @@ "inactive": false, "legacyFolderID": "F2c814f00e72a07d472e7e75679033feae497d70cbdfa159b835f741bf44a7274", "searchTerms": ["Sieglinde Insane 13"], + "slug": "sglec-ge-25-lt-26", "title": "EC ★13", "where": "(chart.data->>'sglEC')::numeric >= 25 AND (chart.data->>'sglEC')::numeric < 26" }, @@ -2206,6 +2449,7 @@ "inactive": false, "legacyFolderID": "F658f2e42465404f5f563642ded0f3e004b94cdbd19f856f9666d708fe20868e4", "searchTerms": ["Sieglinde Insane 14"], + "slug": "sglec-ge-26-lt-27", "title": "EC ★14", "where": "(chart.data->>'sglEC')::numeric >= 26 AND (chart.data->>'sglEC')::numeric < 27" }, @@ -2215,6 +2459,7 @@ "inactive": false, "legacyFolderID": "Fe4314e6fa900eda95decea3f7b09a2df6b692a9457e4efff0defc9109d19da50", "searchTerms": ["Sieglinde Insane 15"], + "slug": "sglec-ge-27-lt-28", "title": "EC ★15", "where": "(chart.data->>'sglEC')::numeric >= 27 AND (chart.data->>'sglEC')::numeric < 28" }, @@ -2224,6 +2469,7 @@ "inactive": false, "legacyFolderID": "Fa163d846f4f6420c7c1e200b455daf3a8758220d64380a092e990f91ec301dd1", "searchTerms": ["Sieglinde Insane 16"], + "slug": "sglec-ge-28-lt-29", "title": "EC ★16", "where": "(chart.data->>'sglEC')::numeric >= 28 AND (chart.data->>'sglEC')::numeric < 29" }, @@ -2233,6 +2479,7 @@ "inactive": false, "legacyFolderID": "Fdd6d85123f9eff161ff448d17138877d0fbb47a8fcad28a3fb8beec0e766c18c", "searchTerms": ["Sieglinde Insane 17"], + "slug": "sglec-ge-29-lt-30", "title": "EC ★17", "where": "(chart.data->>'sglEC')::numeric >= 29 AND (chart.data->>'sglEC')::numeric < 30" }, @@ -2242,6 +2489,7 @@ "inactive": false, "legacyFolderID": "F0bfc379b59a944512032b6c818a37adb6eef5a7f6b2152518ac3509e2aaf5936", "searchTerms": ["Sieglinde Insane 18"], + "slug": "sglec-ge-30-lt-31", "title": "EC ★18", "where": "(chart.data->>'sglEC')::numeric >= 30 AND (chart.data->>'sglEC')::numeric < 31" }, @@ -2251,6 +2499,7 @@ "inactive": false, "legacyFolderID": "F57b582937b38f3558e241df77fe2430a4d7d895ed230a8c962891c0f4fdab93b", "searchTerms": ["Sieglinde Insane 19"], + "slug": "sglec-ge-31-lt-32", "title": "EC ★19", "where": "(chart.data->>'sglEC')::numeric >= 31 AND (chart.data->>'sglEC')::numeric < 32" }, @@ -2260,6 +2509,7 @@ "inactive": false, "legacyFolderID": "F1c5a781222a32b206825e77160736f08beba97a4f205b56fc76d5af05c41a843", "searchTerms": ["Sieglinde Insane 2"], + "slug": "sglec-ge-14-lt-15", "title": "EC ★2", "where": "(chart.data->>'sglEC')::numeric >= 14 AND (chart.data->>'sglEC')::numeric < 15" }, @@ -2269,6 +2519,7 @@ "inactive": false, "legacyFolderID": "F6ed48263bf2db7f33f6c0481b9c6d6e945330eeea7babafb76f78d35fe6aced9", "searchTerms": ["Sieglinde Insane 20"], + "slug": "sglec-ge-32-lt-33", "title": "EC ★20", "where": "(chart.data->>'sglEC')::numeric >= 32 AND (chart.data->>'sglEC')::numeric < 33" }, @@ -2278,6 +2529,7 @@ "inactive": false, "legacyFolderID": "F672447e55ffdb2c1c689ee554ce7688cf69b8fafa68f960d9ff49d067830cdc5", "searchTerms": ["Sieglinde Insane 21"], + "slug": "sglec-ge-33-lt-34", "title": "EC ★21", "where": "(chart.data->>'sglEC')::numeric >= 33 AND (chart.data->>'sglEC')::numeric < 34" }, @@ -2287,6 +2539,7 @@ "inactive": false, "legacyFolderID": "F55d35e46b19e321142d51de94e7caa1deaa155b44ebf6d72181921ff1c490f5e", "searchTerms": ["Sieglinde Insane 22"], + "slug": "sglec-ge-34-lt-35", "title": "EC ★22", "where": "(chart.data->>'sglEC')::numeric >= 34 AND (chart.data->>'sglEC')::numeric < 35" }, @@ -2296,6 +2549,7 @@ "inactive": false, "legacyFolderID": "Fd57b5fabca0888b7e5e5b2939524dd902b48a0e470a2974d348be97bdb318ccd", "searchTerms": ["Sieglinde Insane 23"], + "slug": "sglec-ge-35-lt-36", "title": "EC ★23", "where": "(chart.data->>'sglEC')::numeric >= 35 AND (chart.data->>'sglEC')::numeric < 36" }, @@ -2305,6 +2559,7 @@ "inactive": false, "legacyFolderID": "F00115a4ac9fb53dc61dc4fbc8d93354b9943ff48cf17029e8c396ee6fd4f9b15", "searchTerms": ["Sieglinde Insane 24"], + "slug": "sglec-ge-36-lt-37", "title": "EC ★24", "where": "(chart.data->>'sglEC')::numeric >= 36 AND (chart.data->>'sglEC')::numeric < 37" }, @@ -2314,6 +2569,7 @@ "inactive": false, "legacyFolderID": "F878d2f28879e05f29894d5f59038a55b8b0980404982c64b9f9b39e221627dc6", "searchTerms": ["Sieglinde Insane 25"], + "slug": "sglec-ge-37-lt-38", "title": "EC ★25", "where": "(chart.data->>'sglEC')::numeric >= 37 AND (chart.data->>'sglEC')::numeric < 38" }, @@ -2323,6 +2579,7 @@ "inactive": false, "legacyFolderID": "Fb4d29b6389061f17828291176bb30099c75e247a28a30329b5b77e11e22e60ca", "searchTerms": ["Sieglinde Insane 26"], + "slug": "sglec-ge-38-lt-39", "title": "EC ★26", "where": "(chart.data->>'sglEC')::numeric >= 38 AND (chart.data->>'sglEC')::numeric < 39" }, @@ -2332,6 +2589,7 @@ "inactive": false, "legacyFolderID": "Fb1b46992b5fa1b72e40b81966792dd1a00c3a703ac7252bc589bee0b56bba933", "searchTerms": ["Sieglinde Insane 27"], + "slug": "sglec-ge-39-lt-40", "title": "EC ★27", "where": "(chart.data->>'sglEC')::numeric >= 39 AND (chart.data->>'sglEC')::numeric < 40" }, @@ -2341,6 +2599,7 @@ "inactive": false, "legacyFolderID": "F19a7a4305c9697604a817a85e8d726c6e0433a75e224b6de4d1fa9357903b17e", "searchTerms": ["Sieglinde Insane 28"], + "slug": "sglec-ge-40-lt-41", "title": "EC ★28", "where": "(chart.data->>'sglEC')::numeric >= 40 AND (chart.data->>'sglEC')::numeric < 41" }, @@ -2350,6 +2609,7 @@ "inactive": false, "legacyFolderID": "F72d4529b0a7be935a798b5a8328e207c4b48fddbceb7053646db61ae671f4c8e", "searchTerms": ["Sieglinde Insane 29"], + "slug": "sglec-ge-41-lt-42", "title": "EC ★29", "where": "(chart.data->>'sglEC')::numeric >= 41 AND (chart.data->>'sglEC')::numeric < 42" }, @@ -2359,6 +2619,7 @@ "inactive": false, "legacyFolderID": "F4ffb31b91fdfd6f4bc83f43bf2ea127403e7535ff8a516293e1c563eafe763d6", "searchTerms": ["Sieglinde Insane 3"], + "slug": "sglec-ge-15-lt-16", "title": "EC ★3", "where": "(chart.data->>'sglEC')::numeric >= 15 AND (chart.data->>'sglEC')::numeric < 16" }, @@ -2368,6 +2629,7 @@ "inactive": false, "legacyFolderID": "F451985909b3ecebae5aa9a21af28f5ac24d43b7b1917647e5a9ba78d4b03352a", "searchTerms": ["Sieglinde Insane 4"], + "slug": "sglec-ge-16-lt-17", "title": "EC ★4", "where": "(chart.data->>'sglEC')::numeric >= 16 AND (chart.data->>'sglEC')::numeric < 17" }, @@ -2377,6 +2639,7 @@ "inactive": false, "legacyFolderID": "Fc712e459ca7cef4f3baac8d0addbe5c93b2b26e90e5a6dcce1686e11477b648b", "searchTerms": ["Sieglinde Insane 5"], + "slug": "sglec-ge-17-lt-18", "title": "EC ★5", "where": "(chart.data->>'sglEC')::numeric >= 17 AND (chart.data->>'sglEC')::numeric < 18" }, @@ -2386,6 +2649,7 @@ "inactive": false, "legacyFolderID": "Fd652486dfbade3c53776298ef4e0444e293ea6f4da707acc5d5c6185df6e26f1", "searchTerms": ["Sieglinde Insane 6"], + "slug": "sglec-ge-18-lt-19", "title": "EC ★6", "where": "(chart.data->>'sglEC')::numeric >= 18 AND (chart.data->>'sglEC')::numeric < 19" }, @@ -2395,6 +2659,7 @@ "inactive": false, "legacyFolderID": "F48b1794838ccf265381748c91bb5ecaad1878d3d31baa062844520127d069528", "searchTerms": ["Sieglinde Insane 7"], + "slug": "sglec-ge-19-lt-20", "title": "EC ★7", "where": "(chart.data->>'sglEC')::numeric >= 19 AND (chart.data->>'sglEC')::numeric < 20" }, @@ -2404,6 +2669,7 @@ "inactive": false, "legacyFolderID": "Fbe035c10fbb0466615cc356b2c14b6209c4657b7638c876bc2a190b6b5133e8f", "searchTerms": ["Sieglinde Insane 8"], + "slug": "sglec-ge-20-lt-21", "title": "EC ★8", "where": "(chart.data->>'sglEC')::numeric >= 20 AND (chart.data->>'sglEC')::numeric < 21" }, @@ -2413,6 +2679,7 @@ "inactive": false, "legacyFolderID": "Fb59f6dbaf26b7362d51589f89fdd05c779d3fa081f4e6769313762ebccbd45d3", "searchTerms": ["Sieglinde Insane 9"], + "slug": "sglec-ge-21-lt-22", "title": "EC ★9", "where": "(chart.data->>'sglEC')::numeric >= 21 AND (chart.data->>'sglEC')::numeric < 22" }, @@ -2422,6 +2689,7 @@ "inactive": false, "legacyFolderID": "Fbca8c0ff684993c879b0ba1459a9cfa6933f5b3a9b83bcffbb85ab731a5bc82c", "searchTerms": ["Sieglinde Normal 1"], + "slug": "sglec-ge-1-lt-2", "title": "EC ☆1", "where": "(chart.data->>'sglEC')::numeric >= 1 AND (chart.data->>'sglEC')::numeric < 2" }, @@ -2431,6 +2699,7 @@ "inactive": false, "legacyFolderID": "F29df032a6abcb16ef00aee8200244aaa1414df612337a97ca6b6ee9a68f41a20", "searchTerms": ["Sieglinde Normal 10"], + "slug": "sglec-ge-10-lt-11", "title": "EC ☆10", "where": "(chart.data->>'sglEC')::numeric >= 10 AND (chart.data->>'sglEC')::numeric < 11" }, @@ -2440,6 +2709,7 @@ "inactive": false, "legacyFolderID": "Ffc57eda44127a7d34fd2fa4a84f9fde70897f8b7f108d584555e9fd9927c93be", "searchTerms": ["Sieglinde Normal 11"], + "slug": "sglec-ge-11-lt-12", "title": "EC ☆11", "where": "(chart.data->>'sglEC')::numeric >= 11 AND (chart.data->>'sglEC')::numeric < 12" }, @@ -2449,6 +2719,7 @@ "inactive": false, "legacyFolderID": "Ffe6f640c152f8e7d90c0449b9d3b11f9c96a6d8c1cbfc60df1dadbc66123a53b", "searchTerms": ["Sieglinde Normal 12"], + "slug": "sglec-ge-12-lt-13", "title": "EC ☆12", "where": "(chart.data->>'sglEC')::numeric >= 12 AND (chart.data->>'sglEC')::numeric < 13" }, @@ -2458,6 +2729,7 @@ "inactive": false, "legacyFolderID": "Fb00ca4bccf222a7b44f41d50b5117c9bfd1211b6bbc91225277008889bc654fa", "searchTerms": ["Sieglinde Normal 2"], + "slug": "sglec-ge-2-lt-3", "title": "EC ☆2", "where": "(chart.data->>'sglEC')::numeric >= 2 AND (chart.data->>'sglEC')::numeric < 3" }, @@ -2467,6 +2739,7 @@ "inactive": false, "legacyFolderID": "Fa57f11faa4ba6af8fa55fdf4b9be29c4f1edbfff6a9ba00565cde9174b40ca76", "searchTerms": ["Sieglinde Normal 3"], + "slug": "sglec-ge-3-lt-4", "title": "EC ☆3", "where": "(chart.data->>'sglEC')::numeric >= 3 AND (chart.data->>'sglEC')::numeric < 4" }, @@ -2476,6 +2749,7 @@ "inactive": false, "legacyFolderID": "F426b3f3dd8917f66f4ea011e9dfeae672be1c094ebb1a33d6c07de7805e20de5", "searchTerms": ["Sieglinde Normal 4"], + "slug": "sglec-ge-4-lt-5", "title": "EC ☆4", "where": "(chart.data->>'sglEC')::numeric >= 4 AND (chart.data->>'sglEC')::numeric < 5" }, @@ -2485,6 +2759,7 @@ "inactive": false, "legacyFolderID": "Facc0326bb090c7124da3f1a306c5b131ff601f37eb7dc1252a7b949156ca0f50", "searchTerms": ["Sieglinde Normal 5"], + "slug": "sglec-ge-5-lt-6", "title": "EC ☆5", "where": "(chart.data->>'sglEC')::numeric >= 5 AND (chart.data->>'sglEC')::numeric < 6" }, @@ -2494,6 +2769,7 @@ "inactive": false, "legacyFolderID": "F1354cbd0aa7744e7ecdf8acd9728cf69b3d776d511193c1ca2e5d51543ada0de", "searchTerms": ["Sieglinde Normal 6"], + "slug": "sglec-ge-6-lt-7", "title": "EC ☆6", "where": "(chart.data->>'sglEC')::numeric >= 6 AND (chart.data->>'sglEC')::numeric < 7" }, @@ -2503,6 +2779,7 @@ "inactive": false, "legacyFolderID": "F16a27b161f8b3f40bb48e87ba13eac72cc500f474d04b4a1f921ac08e4b9b04d", "searchTerms": ["Sieglinde Normal 7"], + "slug": "sglec-ge-7-lt-8", "title": "EC ☆7", "where": "(chart.data->>'sglEC')::numeric >= 7 AND (chart.data->>'sglEC')::numeric < 8" }, @@ -2512,6 +2789,7 @@ "inactive": false, "legacyFolderID": "F2e4b8fe14b9e56eaeeb4c1c88860e90a3d97244e2d0e81ced796805c89cbd669", "searchTerms": ["Sieglinde Normal 8"], + "slug": "sglec-ge-8-lt-9", "title": "EC ☆8", "where": "(chart.data->>'sglEC')::numeric >= 8 AND (chart.data->>'sglEC')::numeric < 9" }, @@ -2521,6 +2799,7 @@ "inactive": false, "legacyFolderID": "F2c7755ccddb97e38baad94a672eb7ac5c427584816aae30b0f3a449e00250480", "searchTerms": ["Sieglinde Normal 9"], + "slug": "sglec-ge-9-lt-10", "title": "EC ☆9", "where": "(chart.data->>'sglEC')::numeric >= 9 AND (chart.data->>'sglEC')::numeric < 10" }, @@ -2530,6 +2809,7 @@ "inactive": false, "legacyFolderID": "F4c7bb7c15715dfece55170d98a1d7ba2c4b4dafb361f348282701aae5fe8baae", "searchTerms": ["exoplanet"], + "slug": "tf-exoplanet-present", "title": "Exoplanet", "where": "(chart.data->'tableFolders') ? 'fr'" }, @@ -2539,6 +2819,7 @@ "inactive": false, "legacyFolderID": "F95e1c93906b4383747ee1cf3dd497930c8a0a8d4b3f9b0df6aeeb29aa3ab82dc", "searchTerms": [], + "slug": "tf-exoplanet-0", "title": "fr0", "where": "(chart.data->'tableFolders'->>'fr') = '0'" }, @@ -2548,6 +2829,7 @@ "inactive": false, "legacyFolderID": "F4c4691b5586369b3fd710c57fef72e3a9142bbca80e12ed21a42e89a2630fbb1", "searchTerms": [], + "slug": "tf-exoplanet-1", "title": "fr1", "where": "(chart.data->'tableFolders'->>'fr') = '1'" }, @@ -2557,6 +2839,7 @@ "inactive": false, "legacyFolderID": "Fd82d98e194ca42baecd43733b1104849c2505391bd5246870edeba79fef44c18", "searchTerms": [], + "slug": "tf-exoplanet-1-0", "title": "fr10", "where": "(chart.data->'tableFolders'->>'fr') = '10'" }, @@ -2566,6 +2849,7 @@ "inactive": false, "legacyFolderID": "F3cbda44dbd6b920132f1d708f8a12e574952af3bd0aa87d55a143cb9e797601b", "searchTerms": [], + "slug": "tf-exoplanet-1-1", "title": "fr11", "where": "(chart.data->'tableFolders'->>'fr') = '11'" }, @@ -2575,6 +2859,7 @@ "inactive": false, "legacyFolderID": "Fb6960028c17fe316615f5f9f57f4221bbe857181ff9e4a2580ba035e7aae0404", "searchTerms": [], + "slug": "tf-exoplanet-1-2", "title": "fr12", "where": "(chart.data->'tableFolders'->>'fr') = '12'" }, @@ -2584,6 +2869,7 @@ "inactive": false, "legacyFolderID": "F77088b6f565fa8ddd7ef99bc9f29671551ea5543d159ef9275e38abff812ad21", "searchTerms": [], + "slug": "tf-exoplanet-2", "title": "fr2", "where": "(chart.data->'tableFolders'->>'fr') = '2'" }, @@ -2593,6 +2879,7 @@ "inactive": false, "legacyFolderID": "Fb0cc8a33a6b8ec9b963d295881db7b1ffabc110abb322432b4fd8f663fc0f26a", "searchTerms": [], + "slug": "tf-exoplanet-3", "title": "fr3", "where": "(chart.data->'tableFolders'->>'fr') = '3'" }, @@ -2602,6 +2889,7 @@ "inactive": false, "legacyFolderID": "F1216510109aa7fe397fb210c2f121df1b5a69ca8316ba716d2bfdd18afbfc1d7", "searchTerms": [], + "slug": "tf-exoplanet-4", "title": "fr4", "where": "(chart.data->'tableFolders'->>'fr') = '4'" }, @@ -2611,6 +2899,7 @@ "inactive": false, "legacyFolderID": "F0c2ec5ba6b37a1cf4b6eb1172e88123ccaf39689b1d44159d86b7fca9458a268", "searchTerms": [], + "slug": "tf-exoplanet-5", "title": "fr5", "where": "(chart.data->'tableFolders'->>'fr') = '5'" }, @@ -2620,6 +2909,7 @@ "inactive": false, "legacyFolderID": "F9ceecdd6a3b5bf43aa0378fd0e52df07c97d3220c628638fc5eba65d9f2a21c2", "searchTerms": [], + "slug": "tf-exoplanet-6", "title": "fr6", "where": "(chart.data->'tableFolders'->>'fr') = '6'" }, @@ -2629,6 +2919,7 @@ "inactive": false, "legacyFolderID": "F564af57768d6c60a8fcab444b2a1f88f58432a3b9a85a741cb1244212c7acb7a", "searchTerms": [], + "slug": "tf-exoplanet-7", "title": "fr7", "where": "(chart.data->'tableFolders'->>'fr') = '7'" }, @@ -2638,6 +2929,7 @@ "inactive": false, "legacyFolderID": "Ffe1c281214fadcfb786d698330bd3a78cbb40ed5a5c6dfe1b09d07d889b444fd", "searchTerms": [], + "slug": "tf-exoplanet-8", "title": "fr8", "where": "(chart.data->'tableFolders'->>'fr') = '8'" }, @@ -2647,6 +2939,7 @@ "inactive": false, "legacyFolderID": "F681110531867085a17d4eb8f0fad12758beebc875e0f6ecf3c4903198dbe4d9c", "searchTerms": [], + "slug": "tf-exoplanet-9", "title": "fr9", "where": "(chart.data->'tableFolders'->>'fr') = '9'" }, @@ -2656,6 +2949,7 @@ "inactive": false, "legacyFolderID": "Fd87fd6cae620b851468df1da0cc1b7fd4173f6bc0fe3e1c1a01abedf66a2b1e8", "searchTerms": ["gachimjoy"], + "slug": "tf-gachimjoy-present", "title": "Gachimjoy", "where": "(chart.data->'tableFolders') ? '双'" }, @@ -2665,6 +2959,7 @@ "inactive": false, "legacyFolderID": "F0b1c75d6731d9834c6f90509c7fcfd9b62b8cd97aef310e332455fd724eb840d", "searchTerms": ["Scratch 3rd -1"], + "slug": "tf-scratch-dash-1", "title": "h◎-1", "where": "(chart.data->'tableFolders'->>'h◎') = '-1'" }, @@ -2674,6 +2969,7 @@ "inactive": false, "legacyFolderID": "F5bd694997fcb41cb2713a1a858119544853ab60c29c579c6165269c238f59db8", "searchTerms": ["Scratch 3rd 0"], + "slug": "tf-scratch-0", "title": "h◎0", "where": "(chart.data->'tableFolders'->>'h◎') = '0'" }, @@ -2683,6 +2979,7 @@ "inactive": false, "legacyFolderID": "F02eecf8ae10a6dd8b294e2973f28a34e176200aeef3d635d7edc2bd09a2190ba", "searchTerms": ["Scratch 3rd 1"], + "slug": "tf-scratch-1", "title": "h◎1", "where": "(chart.data->'tableFolders'->>'h◎') = '1'" }, @@ -2692,6 +2989,7 @@ "inactive": false, "legacyFolderID": "Feffa4ba156bd0e0dca033da95c9b452313fefe2605fe9e0ec55faefa8c881df0", "searchTerms": ["Scratch 3rd 10"], + "slug": "tf-scratch-1-0", "title": "h◎10", "where": "(chart.data->'tableFolders'->>'h◎') = '10'" }, @@ -2701,6 +2999,7 @@ "inactive": false, "legacyFolderID": "F778752e1f7f5829c2f54beef9f463d1f00abecdb4a0d3567d4db5e6b5e8aa2e6", "searchTerms": ["Scratch 3rd 11"], + "slug": "tf-scratch-1-1", "title": "h◎11", "where": "(chart.data->'tableFolders'->>'h◎') = '11'" }, @@ -2710,6 +3009,7 @@ "inactive": false, "legacyFolderID": "F779570ed0f9b665dd3a4744f252982fb9d7e989475beb71e5140ce9b4c4ae08e", "searchTerms": ["Scratch 3rd 12"], + "slug": "tf-scratch-1-2", "title": "h◎12", "where": "(chart.data->'tableFolders'->>'h◎') = '12'" }, @@ -2719,6 +3019,7 @@ "inactive": false, "legacyFolderID": "F28e354fc06e34bcd7fd6690507f3e03c09727d5405c6a05833181f207ac7182f", "searchTerms": ["Scratch 3rd 13"], + "slug": "tf-scratch-1-3", "title": "h◎13", "where": "(chart.data->'tableFolders'->>'h◎') = '13'" }, @@ -2728,6 +3029,7 @@ "inactive": false, "legacyFolderID": "F8dd49b1aa8e447e443fa9b4d9ce033fe9aa8fba1bdcac8bd84f4f3cb21e95584", "searchTerms": ["Scratch 3rd 14"], + "slug": "tf-scratch-1-4", "title": "h◎14", "where": "(chart.data->'tableFolders'->>'h◎') = '14'" }, @@ -2737,6 +3039,7 @@ "inactive": false, "legacyFolderID": "F157a18634d4d8fa0e4bf2e3207d17642cec30e323d8e29df670b45e8640a00d3", "searchTerms": ["Scratch 3rd 15"], + "slug": "tf-scratch-1-5", "title": "h◎15", "where": "(chart.data->'tableFolders'->>'h◎') = '15'" }, @@ -2746,6 +3049,7 @@ "inactive": false, "legacyFolderID": "F3c0ce816bfbbf842005568d9521388756b3bd345e844fafe1249672f09f20d54", "searchTerms": ["Scratch 3rd 16"], + "slug": "tf-scratch-1-6", "title": "h◎16", "where": "(chart.data->'tableFolders'->>'h◎') = '16'" }, @@ -2755,6 +3059,7 @@ "inactive": false, "legacyFolderID": "F436892ebe29e2c8fac9346f45114400ec716b9c58dfb6e7bdf46c8a36570de4f", "searchTerms": ["Scratch 3rd 2"], + "slug": "tf-scratch-2", "title": "h◎2", "where": "(chart.data->'tableFolders'->>'h◎') = '2'" }, @@ -2764,6 +3069,7 @@ "inactive": false, "legacyFolderID": "F229c1007d2d5998e70dc0fe6dcbb0031efb7fbea052234b6cf10eabc162dfd35", "searchTerms": ["Scratch 3rd 3"], + "slug": "tf-scratch-3", "title": "h◎3", "where": "(chart.data->'tableFolders'->>'h◎') = '3'" }, @@ -2773,6 +3079,7 @@ "inactive": false, "legacyFolderID": "Fe90b5693482ac54837ef864f1675af2d8802a92369f16a3eda38f65d328df89f", "searchTerms": ["Scratch 3rd 4"], + "slug": "tf-scratch-4", "title": "h◎4", "where": "(chart.data->'tableFolders'->>'h◎') = '4'" }, @@ -2782,6 +3089,7 @@ "inactive": false, "legacyFolderID": "F2a37d0cce08f5614fff6b1890a2f56f5c6c556a280321a07942288e2d4cf4d76", "searchTerms": ["Scratch 3rd 5"], + "slug": "tf-scratch-5", "title": "h◎5", "where": "(chart.data->'tableFolders'->>'h◎') = '5'" }, @@ -2791,6 +3099,7 @@ "inactive": false, "legacyFolderID": "Fb7b59796f4029f98f73b08fb6793118739ff0260bc68fcf06b2634a0c1d41458", "searchTerms": ["Scratch 3rd 6"], + "slug": "tf-scratch-6", "title": "h◎6", "where": "(chart.data->'tableFolders'->>'h◎') = '6'" }, @@ -2800,6 +3109,7 @@ "inactive": false, "legacyFolderID": "F8baa889e858a0f710da56999ab98494eae7c2b530cec5d9d08fdeee60b8e7146", "searchTerms": ["Scratch 3rd 7"], + "slug": "tf-scratch-7", "title": "h◎7", "where": "(chart.data->'tableFolders'->>'h◎') = '7'" }, @@ -2809,6 +3119,7 @@ "inactive": false, "legacyFolderID": "Fb462e36882d79bb9ba10094594f99e1a7b7db31977195675c1f707d8a84202aa", "searchTerms": ["Scratch 3rd 8"], + "slug": "tf-scratch-8", "title": "h◎8", "where": "(chart.data->'tableFolders'->>'h◎') = '8'" }, @@ -2818,6 +3129,7 @@ "inactive": false, "legacyFolderID": "F4a8d7cf82ea1bbccb0cc17c4492d5988ea347a8fa5952539bb999d7b150f6fb5", "searchTerms": ["Scratch 3rd 9"], + "slug": "tf-scratch-9", "title": "h◎9", "where": "(chart.data->'tableFolders'->>'h◎') = '9'" }, @@ -2827,6 +3139,7 @@ "inactive": false, "legacyFolderID": "F2f323bb7646f7bb10a9fa0a56630e9012deddda8838f77fb6110f73ce7e874eb", "searchTerms": ["Sieglinde Insane 1"], + "slug": "sglhc-ge-13-lt-14", "title": "HC ★1", "where": "(chart.data->>'sglHC')::numeric >= 13 AND (chart.data->>'sglHC')::numeric < 14" }, @@ -2836,6 +3149,7 @@ "inactive": false, "legacyFolderID": "Fbe4b5df428348a6d4a487bfeb59b3764458fbfd07dcd13e76982819cc90bae40", "searchTerms": ["Sieglinde Insane 10"], + "slug": "sglhc-ge-22-lt-23", "title": "HC ★10", "where": "(chart.data->>'sglHC')::numeric >= 22 AND (chart.data->>'sglHC')::numeric < 23" }, @@ -2845,6 +3159,7 @@ "inactive": false, "legacyFolderID": "F42ca527b3ef73e2b75d0480de8daf30e7fc8ffddc8b7f86ff856ed07faf94700", "searchTerms": ["Sieglinde Insane 11"], + "slug": "sglhc-ge-23-lt-24", "title": "HC ★11", "where": "(chart.data->>'sglHC')::numeric >= 23 AND (chart.data->>'sglHC')::numeric < 24" }, @@ -2854,6 +3169,7 @@ "inactive": false, "legacyFolderID": "Fc8f6885b844e2693ecfa91078a1e76a1deb7acc92ce33deb8eeb74a91ed6041b", "searchTerms": ["Sieglinde Insane 12"], + "slug": "sglhc-ge-24-lt-25", "title": "HC ★12", "where": "(chart.data->>'sglHC')::numeric >= 24 AND (chart.data->>'sglHC')::numeric < 25" }, @@ -2863,6 +3179,7 @@ "inactive": false, "legacyFolderID": "F6f81bb6167a88a25c576a8c2df5d9667c3c397357d4021cdf22edb9bde8076d2", "searchTerms": ["Sieglinde Insane 13"], + "slug": "sglhc-ge-25-lt-26", "title": "HC ★13", "where": "(chart.data->>'sglHC')::numeric >= 25 AND (chart.data->>'sglHC')::numeric < 26" }, @@ -2872,6 +3189,7 @@ "inactive": false, "legacyFolderID": "F664cf31e2fcb6be9fab1a2f53594320cdc71c669d04857f60b313b0ef37ef237", "searchTerms": ["Sieglinde Insane 14"], + "slug": "sglhc-ge-26-lt-27", "title": "HC ★14", "where": "(chart.data->>'sglHC')::numeric >= 26 AND (chart.data->>'sglHC')::numeric < 27" }, @@ -2881,6 +3199,7 @@ "inactive": false, "legacyFolderID": "Fa2602306552e92eefd765343342eddf115a01fbe6bbe42abcc9ebc6f074f5b4a", "searchTerms": ["Sieglinde Insane 15"], + "slug": "sglhc-ge-27-lt-28", "title": "HC ★15", "where": "(chart.data->>'sglHC')::numeric >= 27 AND (chart.data->>'sglHC')::numeric < 28" }, @@ -2890,6 +3209,7 @@ "inactive": false, "legacyFolderID": "F016e254fafe7c34ff1c8006ccf581ddd08c20f05113cb9af5d8e6fa16d7a3d94", "searchTerms": ["Sieglinde Insane 16"], + "slug": "sglhc-ge-28-lt-29", "title": "HC ★16", "where": "(chart.data->>'sglHC')::numeric >= 28 AND (chart.data->>'sglHC')::numeric < 29" }, @@ -2899,6 +3219,7 @@ "inactive": false, "legacyFolderID": "F8dd68d9ed96b8fb35abc63585aef6046807398c951fc2633d719c7c18b821ef1", "searchTerms": ["Sieglinde Insane 17"], + "slug": "sglhc-ge-29-lt-30", "title": "HC ★17", "where": "(chart.data->>'sglHC')::numeric >= 29 AND (chart.data->>'sglHC')::numeric < 30" }, @@ -2908,6 +3229,7 @@ "inactive": false, "legacyFolderID": "F69285e53522679d2cbc196267b6a73dced110787e00774eaf4d4bf9636dd116d", "searchTerms": ["Sieglinde Insane 18"], + "slug": "sglhc-ge-30-lt-31", "title": "HC ★18", "where": "(chart.data->>'sglHC')::numeric >= 30 AND (chart.data->>'sglHC')::numeric < 31" }, @@ -2917,6 +3239,7 @@ "inactive": false, "legacyFolderID": "F993767d01997f3fe818c60626dd31f5f2be91a1962f986a80fdc1c0736a746b6", "searchTerms": ["Sieglinde Insane 19"], + "slug": "sglhc-ge-31-lt-32", "title": "HC ★19", "where": "(chart.data->>'sglHC')::numeric >= 31 AND (chart.data->>'sglHC')::numeric < 32" }, @@ -2926,6 +3249,7 @@ "inactive": false, "legacyFolderID": "F959d7f5b06617d29aa2fe10816bf2258dd23ecbc354356786692228e5078af9c", "searchTerms": ["Sieglinde Insane 2"], + "slug": "sglhc-ge-14-lt-15", "title": "HC ★2", "where": "(chart.data->>'sglHC')::numeric >= 14 AND (chart.data->>'sglHC')::numeric < 15" }, @@ -2935,6 +3259,7 @@ "inactive": false, "legacyFolderID": "F3b56f36dae3c516d885f9aafc58ce1ee6ae58176ffbe1bcd3c06a4e359d3000d", "searchTerms": ["Sieglinde Insane 20"], + "slug": "sglhc-ge-32-lt-33", "title": "HC ★20", "where": "(chart.data->>'sglHC')::numeric >= 32 AND (chart.data->>'sglHC')::numeric < 33" }, @@ -2944,6 +3269,7 @@ "inactive": false, "legacyFolderID": "Fe8de41a3849d41c6664ba0e8917fd87b6d8f380db28ee4c4fcd551a79252d669", "searchTerms": ["Sieglinde Insane 21"], + "slug": "sglhc-ge-33-lt-34", "title": "HC ★21", "where": "(chart.data->>'sglHC')::numeric >= 33 AND (chart.data->>'sglHC')::numeric < 34" }, @@ -2953,6 +3279,7 @@ "inactive": false, "legacyFolderID": "F21b90037e000eea89e172a67f6f5617289b4a197e5d412171cd69e395a0b2338", "searchTerms": ["Sieglinde Insane 22"], + "slug": "sglhc-ge-34-lt-35", "title": "HC ★22", "where": "(chart.data->>'sglHC')::numeric >= 34 AND (chart.data->>'sglHC')::numeric < 35" }, @@ -2962,6 +3289,7 @@ "inactive": false, "legacyFolderID": "F017bf9edeea6e740131ef5f3941abc769c3c9157dce2c0ee0b9a2c9916fc0c2c", "searchTerms": ["Sieglinde Insane 23"], + "slug": "sglhc-ge-35-lt-36", "title": "HC ★23", "where": "(chart.data->>'sglHC')::numeric >= 35 AND (chart.data->>'sglHC')::numeric < 36" }, @@ -2971,6 +3299,7 @@ "inactive": false, "legacyFolderID": "Fbb56f002cc6ae673d1c10464e496fd8c4ea08eda3d108b35d1544ac7c20d4d12", "searchTerms": ["Sieglinde Insane 24"], + "slug": "sglhc-ge-36-lt-37", "title": "HC ★24", "where": "(chart.data->>'sglHC')::numeric >= 36 AND (chart.data->>'sglHC')::numeric < 37" }, @@ -2980,6 +3309,7 @@ "inactive": false, "legacyFolderID": "Fc8a3b3c9158da977bf666768050d60755ccb9d61692c3e1d03a5af02b16ee5f7", "searchTerms": ["Sieglinde Insane 25"], + "slug": "sglhc-ge-37-lt-38", "title": "HC ★25", "where": "(chart.data->>'sglHC')::numeric >= 37 AND (chart.data->>'sglHC')::numeric < 38" }, @@ -2989,6 +3319,7 @@ "inactive": false, "legacyFolderID": "F0b83dea30c7622266f2c6a78ec82db18116e2fba37316b402dd638bd73c5538c", "searchTerms": ["Sieglinde Insane 26"], + "slug": "sglhc-ge-38-lt-39", "title": "HC ★26", "where": "(chart.data->>'sglHC')::numeric >= 38 AND (chart.data->>'sglHC')::numeric < 39" }, @@ -2998,6 +3329,7 @@ "inactive": false, "legacyFolderID": "Fd04c9ab1d00e1b5360b4102d601c6e32368f6074015cffdbb9e42989e5c3473e", "searchTerms": ["Sieglinde Insane 27"], + "slug": "sglhc-ge-39-lt-40", "title": "HC ★27", "where": "(chart.data->>'sglHC')::numeric >= 39 AND (chart.data->>'sglHC')::numeric < 40" }, @@ -3007,6 +3339,7 @@ "inactive": false, "legacyFolderID": "F96d84d332906c37b6b7e941bcf31ce769c90df505a7e680593bf81eaf5a9d903", "searchTerms": ["Sieglinde Insane 28"], + "slug": "sglhc-ge-40-lt-41", "title": "HC ★28", "where": "(chart.data->>'sglHC')::numeric >= 40 AND (chart.data->>'sglHC')::numeric < 41" }, @@ -3016,6 +3349,7 @@ "inactive": false, "legacyFolderID": "Fec0a6dde2a6877ff146a1f4727cc4a9b3ed8263471904acd685fc2b1554e0c6e", "searchTerms": ["Sieglinde Insane 29"], + "slug": "sglhc-ge-41-lt-42", "title": "HC ★29", "where": "(chart.data->>'sglHC')::numeric >= 41 AND (chart.data->>'sglHC')::numeric < 42" }, @@ -3025,6 +3359,7 @@ "inactive": false, "legacyFolderID": "F6ca19031c1851f5b29f14a2fd350491a7920f518fed34aa8f92ec6ea5137b07d", "searchTerms": ["Sieglinde Insane 3"], + "slug": "sglhc-ge-15-lt-16", "title": "HC ★3", "where": "(chart.data->>'sglHC')::numeric >= 15 AND (chart.data->>'sglHC')::numeric < 16" }, @@ -3034,6 +3369,7 @@ "inactive": false, "legacyFolderID": "Ffde83595ae9cd1f03bbbbbce89f6c09d122d9b5f1b9bcced2c628ef4c35f6885", "searchTerms": ["Sieglinde Insane 4"], + "slug": "sglhc-ge-16-lt-17", "title": "HC ★4", "where": "(chart.data->>'sglHC')::numeric >= 16 AND (chart.data->>'sglHC')::numeric < 17" }, @@ -3043,6 +3379,7 @@ "inactive": false, "legacyFolderID": "F550d7779073d118aad3e6fc1c7fe632ebadbe39076b947e14fa0e1c6b1564f58", "searchTerms": ["Sieglinde Insane 5"], + "slug": "sglhc-ge-17-lt-18", "title": "HC ★5", "where": "(chart.data->>'sglHC')::numeric >= 17 AND (chart.data->>'sglHC')::numeric < 18" }, @@ -3052,6 +3389,7 @@ "inactive": false, "legacyFolderID": "F92af6e88883ef6a3c8d725f8aba84ad9b65a26491ac93e8cd21aa012bca8fd78", "searchTerms": ["Sieglinde Insane 6"], + "slug": "sglhc-ge-18-lt-19", "title": "HC ★6", "where": "(chart.data->>'sglHC')::numeric >= 18 AND (chart.data->>'sglHC')::numeric < 19" }, @@ -3061,6 +3399,7 @@ "inactive": false, "legacyFolderID": "F1a4b07fe8c3c8c7d7cc60118b111e7dfe771fa45086a450d7b6f846496751b1e", "searchTerms": ["Sieglinde Insane 7"], + "slug": "sglhc-ge-19-lt-20", "title": "HC ★7", "where": "(chart.data->>'sglHC')::numeric >= 19 AND (chart.data->>'sglHC')::numeric < 20" }, @@ -3070,6 +3409,7 @@ "inactive": false, "legacyFolderID": "F9a9fc255a6148a596b18db462e2347f4537b0dc6414ae40c7ed59ddbb612ebe0", "searchTerms": ["Sieglinde Insane 8"], + "slug": "sglhc-ge-20-lt-21", "title": "HC ★8", "where": "(chart.data->>'sglHC')::numeric >= 20 AND (chart.data->>'sglHC')::numeric < 21" }, @@ -3079,6 +3419,7 @@ "inactive": false, "legacyFolderID": "Fad8e8442315bcce43207979704be79fc6bc0da7e0b420fd93c180ff9bc02ce01", "searchTerms": ["Sieglinde Insane 9"], + "slug": "sglhc-ge-21-lt-22", "title": "HC ★9", "where": "(chart.data->>'sglHC')::numeric >= 21 AND (chart.data->>'sglHC')::numeric < 22" }, @@ -3088,6 +3429,7 @@ "inactive": false, "legacyFolderID": "F39d90954ccf7d0b5b5baed0eae0f2217a503f2f6b3ae83182fe2cbe850a62d20", "searchTerms": ["Sieglinde Normal 1"], + "slug": "sglhc-ge-1-lt-2", "title": "HC ☆1", "where": "(chart.data->>'sglHC')::numeric >= 1 AND (chart.data->>'sglHC')::numeric < 2" }, @@ -3097,6 +3439,7 @@ "inactive": false, "legacyFolderID": "F132194d1b0a5e210209d8ddcbef17f8d6101581cd5cb1425e2f0b4e31b3300aa", "searchTerms": ["Sieglinde Normal 10"], + "slug": "sglhc-ge-10-lt-11", "title": "HC ☆10", "where": "(chart.data->>'sglHC')::numeric >= 10 AND (chart.data->>'sglHC')::numeric < 11" }, @@ -3106,6 +3449,7 @@ "inactive": false, "legacyFolderID": "F633826bca99d0cab810c43fde9d0e2a823be1b07f870bb43cf9c34580ce6ebe5", "searchTerms": ["Sieglinde Normal 11"], + "slug": "sglhc-ge-11-lt-12", "title": "HC ☆11", "where": "(chart.data->>'sglHC')::numeric >= 11 AND (chart.data->>'sglHC')::numeric < 12" }, @@ -3115,6 +3459,7 @@ "inactive": false, "legacyFolderID": "Fa2771151c9d645530755c917102c797e283c6830a6bbff47cf7d8f351c731123", "searchTerms": ["Sieglinde Normal 12"], + "slug": "sglhc-ge-12-lt-13", "title": "HC ☆12", "where": "(chart.data->>'sglHC')::numeric >= 12 AND (chart.data->>'sglHC')::numeric < 13" }, @@ -3124,6 +3469,7 @@ "inactive": false, "legacyFolderID": "F7c411d5c331f520f574d3789e27d8a1d831c011fa1c334851f0a0b4955a2e57b", "searchTerms": ["Sieglinde Normal 2"], + "slug": "sglhc-ge-2-lt-3", "title": "HC ☆2", "where": "(chart.data->>'sglHC')::numeric >= 2 AND (chart.data->>'sglHC')::numeric < 3" }, @@ -3133,6 +3479,7 @@ "inactive": false, "legacyFolderID": "Fd8dec1b98aea06cb09c1119900df57d001ae1faa146411137a2f3c6d96d42ad0", "searchTerms": ["Sieglinde Normal 3"], + "slug": "sglhc-ge-3-lt-4", "title": "HC ☆3", "where": "(chart.data->>'sglHC')::numeric >= 3 AND (chart.data->>'sglHC')::numeric < 4" }, @@ -3142,6 +3489,7 @@ "inactive": false, "legacyFolderID": "Fa837f705143761b1ecc014e86fe71029b00d536b6cd9c123a71503fadf880fbd", "searchTerms": ["Sieglinde Normal 4"], + "slug": "sglhc-ge-4-lt-5", "title": "HC ☆4", "where": "(chart.data->>'sglHC')::numeric >= 4 AND (chart.data->>'sglHC')::numeric < 5" }, @@ -3151,6 +3499,7 @@ "inactive": false, "legacyFolderID": "F25018539334f323cdbabbf1f677122ba255565375bdcb0bb2ebd3b4285a613d4", "searchTerms": ["Sieglinde Normal 5"], + "slug": "sglhc-ge-5-lt-6", "title": "HC ☆5", "where": "(chart.data->>'sglHC')::numeric >= 5 AND (chart.data->>'sglHC')::numeric < 6" }, @@ -3160,6 +3509,7 @@ "inactive": false, "legacyFolderID": "F7b13134926cdffdcf21988cd2ae580d36fb9fcfefbf03f90a04550708e659208", "searchTerms": ["Sieglinde Normal 6"], + "slug": "sglhc-ge-6-lt-7", "title": "HC ☆6", "where": "(chart.data->>'sglHC')::numeric >= 6 AND (chart.data->>'sglHC')::numeric < 7" }, @@ -3169,6 +3519,7 @@ "inactive": false, "legacyFolderID": "Ffc0d589d3bf03a5b643420d12433660dfba35d72a0916ecfa524be31316427f1", "searchTerms": ["Sieglinde Normal 7"], + "slug": "sglhc-ge-7-lt-8", "title": "HC ☆7", "where": "(chart.data->>'sglHC')::numeric >= 7 AND (chart.data->>'sglHC')::numeric < 8" }, @@ -3178,6 +3529,7 @@ "inactive": false, "legacyFolderID": "F6afb0576ae9f0fa9238fc2460f0aad3e3ad1352d6f7b489ac8f668ca6455188f", "searchTerms": ["Sieglinde Normal 8"], + "slug": "sglhc-ge-8-lt-9", "title": "HC ☆8", "where": "(chart.data->>'sglHC')::numeric >= 8 AND (chart.data->>'sglHC')::numeric < 9" }, @@ -3187,6 +3539,7 @@ "inactive": false, "legacyFolderID": "F9c165ea909a1aecd4a0af4199fc9eca9b3b6129d1d4ed8a448e2ae83f8be6afd", "searchTerms": ["Sieglinde Normal 9"], + "slug": "sglhc-ge-9-lt-10", "title": "HC ☆9", "where": "(chart.data->>'sglHC')::numeric >= 9 AND (chart.data->>'sglHC')::numeric < 10" }, @@ -3196,6 +3549,7 @@ "inactive": false, "legacyFolderID": "F82996484fd82762462ce01bc8aa1cc7f613ab2d1f8e20ff595b5de0ab7f95660", "searchTerms": [], + "slug": "tf-insane-present", "title": "Insane", "where": "(chart.data->'tableFolders') ? '★'" }, @@ -3205,6 +3559,7 @@ "inactive": false, "legacyFolderID": "Fdff8862b7962907978887ab56f59d15ca1e2b6f8278421c6008401da48f576b3", "searchTerms": [], + "slug": "tf-insane2-present", "title": "Insane 2", "where": "(chart.data->'tableFolders') ? '▼'" }, @@ -3214,6 +3569,7 @@ "inactive": false, "legacyFolderID": "Fffe3af5600bd7e3e5c78df671c11e8137070531ddee2d612f1c6ddc9c2944b0a", "searchTerms": [], + "slug": "tf-ln-present", "title": "LN", "where": "(chart.data->'tableFolders') ? '◆'" }, @@ -3223,6 +3579,7 @@ "inactive": false, "legacyFolderID": "F902cc950fa7aa25acfec2e4ae6e5630841f3aa0759c95a5095c2e8a2c0a9a4d8", "searchTerms": ["lnOverjoy"], + "slug": "tf-lnoverjoy-present", "title": "LN Overjoy", "where": "(chart.data->'tableFolders') ? '◆◆'" }, @@ -3232,6 +3589,7 @@ "inactive": false, "legacyFolderID": "F9104f4130d83e50920b77845ba592c86a2573754bc13ae4b69d53c1b52f61bdc", "searchTerms": [], + "slug": "tf-luminous-1", "title": "ln1", "where": "(chart.data->'tableFolders'->>'ln') = '1'" }, @@ -3241,6 +3599,7 @@ "inactive": false, "legacyFolderID": "F0a2636dc18f2a5f15dd574596d9cff51292459a0c830a2f07e1a3d971004703e", "searchTerms": [], + "slug": "tf-luminous-1-0", "title": "ln10", "where": "(chart.data->'tableFolders'->>'ln') = '10'" }, @@ -3250,6 +3609,7 @@ "inactive": false, "legacyFolderID": "F5e13ad28285603a07c8ad4c31506bfe8213148f7ae663a3acba8aecfbce551c1", "searchTerms": [], + "slug": "tf-luminous-1-1", "title": "ln11", "where": "(chart.data->'tableFolders'->>'ln') = '11'" }, @@ -3259,6 +3619,7 @@ "inactive": false, "legacyFolderID": "F88b3fbc30a433c7a021108b0b4e8f26b3d4d6ccb3dd856d812fad3e9bbdb6ef3", "searchTerms": [], + "slug": "tf-luminous-1-2", "title": "ln12", "where": "(chart.data->'tableFolders'->>'ln') = '12'" }, @@ -3268,6 +3629,7 @@ "inactive": false, "legacyFolderID": "F1ae9027cdb55636972ac8a25554d809e442ac4762903349ee4db8a7eb9a38087", "searchTerms": [], + "slug": "tf-luminous-1-3", "title": "ln13", "where": "(chart.data->'tableFolders'->>'ln') = '13'" }, @@ -3277,6 +3639,7 @@ "inactive": false, "legacyFolderID": "F4555369433ee1908e97b88ffe3aefe96ccc8730efae6fa6f3229111023c6bf0d", "searchTerms": [], + "slug": "tf-luminous-1-4", "title": "ln14", "where": "(chart.data->'tableFolders'->>'ln') = '14'" }, @@ -3286,6 +3649,7 @@ "inactive": false, "legacyFolderID": "Fc3020e6330d258d8d504c5e828fad97470c1b892a56f9ffde884367348db70b5", "searchTerms": [], + "slug": "tf-luminous-1-5", "title": "ln15", "where": "(chart.data->'tableFolders'->>'ln') = '15'" }, @@ -3295,6 +3659,7 @@ "inactive": false, "legacyFolderID": "F5781b1a90bf41d5f8d20edfe699d9974421c937a317023fe12dd6cae53ab295c", "searchTerms": [], + "slug": "tf-luminous-1-6", "title": "ln16", "where": "(chart.data->'tableFolders'->>'ln') = '16'" }, @@ -3304,6 +3669,7 @@ "inactive": false, "legacyFolderID": "F928b71cd75fdd7120a193bd8441cc0185b6372c02742023272adbb73c57b3044", "searchTerms": [], + "slug": "tf-luminous-1-7", "title": "ln17", "where": "(chart.data->'tableFolders'->>'ln') = '17'" }, @@ -3313,6 +3679,7 @@ "inactive": false, "legacyFolderID": "F94f987c38b8aa1561f3b06e4ad460d66b4d0c95d5f0e9b0eac47186e21e98359", "searchTerms": [], + "slug": "tf-luminous-1-8", "title": "ln18", "where": "(chart.data->'tableFolders'->>'ln') = '18'" }, @@ -3322,6 +3689,7 @@ "inactive": false, "legacyFolderID": "F437e1cee03eb67317f6585f3d5c4d9033a81664445e7592ac5beadd0ce189953", "searchTerms": [], + "slug": "tf-luminous-1-9", "title": "ln19", "where": "(chart.data->'tableFolders'->>'ln') = '19'" }, @@ -3331,6 +3699,7 @@ "inactive": false, "legacyFolderID": "F55be4ea91937b7dcc8b62ab454511e81a61b5427088903903a2ab6cb0dac69e0", "searchTerms": [], + "slug": "tf-luminous-2", "title": "ln2", "where": "(chart.data->'tableFolders'->>'ln') = '2'" }, @@ -3340,6 +3709,7 @@ "inactive": false, "legacyFolderID": "F7164e0b5c29c5dd5fd0e6aba5bdc6fc8d5464380201ab1296056ccf1731df5b9", "searchTerms": [], + "slug": "tf-luminous-2-0", "title": "ln20", "where": "(chart.data->'tableFolders'->>'ln') = '20'" }, @@ -3349,6 +3719,7 @@ "inactive": false, "legacyFolderID": "F12f45ae9f3436449c5c84e56cb848c5deb478e597ec248009af2ed98cf208d04", "searchTerms": [], + "slug": "tf-luminous-2-1", "title": "ln21", "where": "(chart.data->'tableFolders'->>'ln') = '21'" }, @@ -3358,6 +3729,7 @@ "inactive": false, "legacyFolderID": "F318d604bc4ce6bc64562f247f57641b76c81d307cd49b8078e96c86a48abadac", "searchTerms": [], + "slug": "tf-luminous-2-2", "title": "ln22", "where": "(chart.data->'tableFolders'->>'ln') = '22'" }, @@ -3367,6 +3739,7 @@ "inactive": false, "legacyFolderID": "F4e725ba129b46e79ff589d31433e6439840eabaca47ae6df45e85d29e14ed94b", "searchTerms": [], + "slug": "tf-luminous-2-3", "title": "ln23", "where": "(chart.data->'tableFolders'->>'ln') = '23'" }, @@ -3376,6 +3749,7 @@ "inactive": false, "legacyFolderID": "F9b1a242280fb1acf93f5d098b21ace9c3b3e1dbaaa4055b168da1e2c2f113d12", "searchTerms": [], + "slug": "tf-luminous-2-4", "title": "ln24", "where": "(chart.data->'tableFolders'->>'ln') = '24'" }, @@ -3385,6 +3759,7 @@ "inactive": false, "legacyFolderID": "F0890b2dc47d57e23b27cef2afc91427596b2fb7d9a4a5c80322a5416626feaff", "searchTerms": [], + "slug": "tf-luminous-2-5", "title": "ln25", "where": "(chart.data->'tableFolders'->>'ln') = '25'" }, @@ -3394,6 +3769,7 @@ "inactive": false, "legacyFolderID": "F0dbcfafac524a9e62ea5af108df31a77f4a4f4e484c1d386a692e4d06c14eb79", "searchTerms": [], + "slug": "tf-luminous-2-6", "title": "ln26", "where": "(chart.data->'tableFolders'->>'ln') = '26'" }, @@ -3403,6 +3779,7 @@ "inactive": false, "legacyFolderID": "Fb5ffe8ad9f862fc4390161593905f60a9d0e7274ebfda92253d9e9c6aab02933", "searchTerms": [], + "slug": "tf-luminous-3", "title": "ln3", "where": "(chart.data->'tableFolders'->>'ln') = '3'" }, @@ -3412,6 +3789,7 @@ "inactive": false, "legacyFolderID": "F493b3f7ed7b923019a849ad8d8502b699547acec58aabe6e07ad876cf525eaae", "searchTerms": [], + "slug": "tf-luminous-4", "title": "ln4", "where": "(chart.data->'tableFolders'->>'ln') = '4'" }, @@ -3421,6 +3799,7 @@ "inactive": false, "legacyFolderID": "F2bc4a12519a037a976fe98e19cd7152f731793774cb5a97d3b8871feb7ba6a2c", "searchTerms": [], + "slug": "tf-luminous-5", "title": "ln5", "where": "(chart.data->'tableFolders'->>'ln') = '5'" }, @@ -3430,6 +3809,7 @@ "inactive": false, "legacyFolderID": "Fc462deadadfbccb976668adca98efb0f0810c57cd48e77467c2de43d31422bc7", "searchTerms": [], + "slug": "tf-luminous-6", "title": "ln6", "where": "(chart.data->'tableFolders'->>'ln') = '6'" }, @@ -3439,6 +3819,7 @@ "inactive": false, "legacyFolderID": "Fac4373d114b9a580f5ad5c4dc793ab0f1d13820ffd283151963eed0cdbf6f7bb", "searchTerms": [], + "slug": "tf-luminous-7", "title": "ln7", "where": "(chart.data->'tableFolders'->>'ln') = '7'" }, @@ -3448,6 +3829,7 @@ "inactive": false, "legacyFolderID": "Fc3fd2543c09870587b4daf8797ae0aae0a26727237a1bbfd3abbd2a6df12abdf", "searchTerms": [], + "slug": "tf-luminous-8", "title": "ln8", "where": "(chart.data->'tableFolders'->>'ln') = '8'" }, @@ -3457,6 +3839,7 @@ "inactive": false, "legacyFolderID": "F060cecf9aeb09da6bcbce43f41517f9e1ad7f2b5cf9b5e9f220ea4edb1aca9c6", "searchTerms": [], + "slug": "tf-luminous-9", "title": "ln9", "where": "(chart.data->'tableFolders'->>'ln') = '9'" }, @@ -3466,6 +3849,7 @@ "inactive": false, "legacyFolderID": "Ff2984b5a2edc7680a29745bd9290eb38a61eba47555d89725297676d3637a2af", "searchTerms": ["luminous"], + "slug": "tf-luminous-present", "title": "Luminous", "where": "(chart.data->'tableFolders') ? 'ln'" }, @@ -3475,6 +3859,7 @@ "inactive": false, "legacyFolderID": "F53b43bdcf131b45d76a85f6045657d2623a18e78b2fd8bf2cd4953384bbc0c2d", "searchTerms": [], + "slug": "tf-normal-present", "title": "Normal", "where": "(chart.data->'tableFolders') ? '☆'" }, @@ -3484,6 +3869,7 @@ "inactive": false, "legacyFolderID": "F5d044aaf45bbde4ec0213088ee69117bca78db6265004b5d353b1a39107e3a66", "searchTerms": [], + "slug": "tf-normal2-present", "title": "Normal 2", "where": "(chart.data->'tableFolders') ? '▽'" }, @@ -3493,6 +3879,7 @@ "inactive": false, "legacyFolderID": "Fcb411975c6439dac3d16b5894956033a20522aa26d5d4da355cba9fbeff339c9", "searchTerms": [], + "slug": "tf-overjoy-present", "title": "Overjoy", "where": "(chart.data->'tableFolders') ? '★★'" }, @@ -3502,6 +3889,7 @@ "inactive": false, "legacyFolderID": "F9147dcbf43425ad1f48b0c1e35536a667d97e7ac93bcff1656a0321694d1f406", "searchTerms": [], + "slug": "tf-satellite-present", "title": "Satellite", "where": "(chart.data->'tableFolders') ? 'sl'" }, @@ -3511,6 +3899,7 @@ "inactive": false, "legacyFolderID": "F078c8fbb335a2d610a2d80f0a2c551e37bf922764fe2ff33103770bcde058f3f", "searchTerms": [], + "slug": "tf-scramble-dash-1", "title": "SB-1", "where": "(chart.data->'tableFolders'->>'SB') = '-1'" }, @@ -3520,6 +3909,7 @@ "inactive": false, "legacyFolderID": "Feae6078111916ad5037c118c44579dae59887faa3eb9192aa1c4d727490830dd", "searchTerms": [], + "slug": "tf-scramble-excl-i", "title": "SB!i", "where": "(chart.data->'tableFolders'->>'SB') = '!i'" }, @@ -3529,6 +3919,7 @@ "inactive": false, "legacyFolderID": "F18e2b787c802bf8714a83bf25ae386f27be7bd519ad507e7d913e7d9151779cd", "searchTerms": [], + "slug": "tf-scramble-0", "title": "SB0", "where": "(chart.data->'tableFolders'->>'SB') = '0'" }, @@ -3538,6 +3929,7 @@ "inactive": false, "legacyFolderID": "Ff2679db33d051392b6df22400b916d95df84495e809554dabdb4e999db04c162", "searchTerms": [], + "slug": "tf-scramble-1", "title": "SB1", "where": "(chart.data->'tableFolders'->>'SB') = '1'" }, @@ -3547,6 +3939,7 @@ "inactive": false, "legacyFolderID": "F62c6acd92079a90f08457f98eceeee3f48b9a5a12a374162aab2c9460bf94790", "searchTerms": [], + "slug": "tf-scramble-1-0", "title": "SB10", "where": "(chart.data->'tableFolders'->>'SB') = '10'" }, @@ -3556,6 +3949,7 @@ "inactive": false, "legacyFolderID": "Fec3557af11ff5a66b6b5c7a9ff717fc836fbcc9fcc3d2779887b8ffeba08e02f", "searchTerms": [], + "slug": "tf-scramble-1-1", "title": "SB11", "where": "(chart.data->'tableFolders'->>'SB') = '11'" }, @@ -3565,6 +3959,7 @@ "inactive": false, "legacyFolderID": "F12893ed39be532545eb42f5127578da3ac816650373292ec11d2e1a1abc79945", "searchTerms": [], + "slug": "tf-scramble-1-2", "title": "SB12", "where": "(chart.data->'tableFolders'->>'SB') = '12'" }, @@ -3574,6 +3969,7 @@ "inactive": false, "legacyFolderID": "Fccf27ca56017a7e048bbddacecd2b0f0e8ba4853d6605efb5dee7196b4520a3d", "searchTerms": [], + "slug": "tf-scramble-2", "title": "SB2", "where": "(chart.data->'tableFolders'->>'SB') = '2'" }, @@ -3583,6 +3979,7 @@ "inactive": false, "legacyFolderID": "Facee02cb46f9c8b0c4772110bc51653fe7a4497aa638e3a0ab0816758620de7e", "searchTerms": [], + "slug": "tf-scramble-3", "title": "SB3", "where": "(chart.data->'tableFolders'->>'SB') = '3'" }, @@ -3592,6 +3989,7 @@ "inactive": false, "legacyFolderID": "F9c19419eee83b3b7d2ff133ccc96d198493823e16658cfe1cd400651efb6d845", "searchTerms": [], + "slug": "tf-scramble-4", "title": "SB4", "where": "(chart.data->'tableFolders'->>'SB') = '4'" }, @@ -3601,6 +3999,7 @@ "inactive": false, "legacyFolderID": "F364eddb3541d8a81aacafd7ed39b1bb29f9f5e95c89c641753acfda055ecc11a", "searchTerms": [], + "slug": "tf-scramble-5", "title": "SB5", "where": "(chart.data->'tableFolders'->>'SB') = '5'" }, @@ -3610,6 +4009,7 @@ "inactive": false, "legacyFolderID": "F02807f1c8c0f57635a94642465345f5784146c7acb0141ff41ef6bde2ad254f3", "searchTerms": [], + "slug": "tf-scramble-6", "title": "SB6", "where": "(chart.data->'tableFolders'->>'SB') = '6'" }, @@ -3619,6 +4019,7 @@ "inactive": false, "legacyFolderID": "Fb4aac43ae442e2d43ca3a1e4f9961c7415b82d80bd86e8738d2dbee344ad3800", "searchTerms": [], + "slug": "tf-scramble-7", "title": "SB7", "where": "(chart.data->'tableFolders'->>'SB') = '7'" }, @@ -3628,6 +4029,7 @@ "inactive": false, "legacyFolderID": "F844cc3d9adcd5bc687d8515a1bf0ff5da7785d45caecb96a43037b540affa70a", "searchTerms": [], + "slug": "tf-scramble-8", "title": "SB8", "where": "(chart.data->'tableFolders'->>'SB') = '8'" }, @@ -3637,6 +4039,7 @@ "inactive": false, "legacyFolderID": "F4cd8960917057b360eebd413d1df7e63a1c181262694f51f5dc3366dded9f10f", "searchTerms": [], + "slug": "tf-scramble-9", "title": "SB9", "where": "(chart.data->'tableFolders'->>'SB') = '9'" }, @@ -3646,6 +4049,7 @@ "inactive": false, "legacyFolderID": "F93c9abfd0d678a9f5ec1aac023dd945c971a283fedd1fd0017dea0e4aa9b6528", "searchTerms": ["Scramble"], + "slug": "tf-scramble-present", "title": "Scramble", "where": "(chart.data->'tableFolders') ? 'SB'" }, @@ -3655,6 +4059,7 @@ "inactive": false, "legacyFolderID": "Fd8f69f38e6e033a41fa5a24d87060561ed5e1da4c6c81dc32bf24931a27359dc", "searchTerms": [], + "slug": "tf-scratch-present", "title": "Scratch 3rd", "where": "(chart.data->'tableFolders') ? 'h◎'" }, @@ -3664,6 +4069,7 @@ "inactive": false, "legacyFolderID": "Fb5e18d5e855e9dd9b1bddece14a26a292f363522a4e1487c547b47ff99cd24ff", "searchTerms": ["Satellite 0"], + "slug": "tf-satellite-0", "title": "sl0", "where": "(chart.data->'tableFolders'->>'sl') = '0'" }, @@ -3673,6 +4079,7 @@ "inactive": false, "legacyFolderID": "Fbb49b8b0238cc0b335c43ef219aa1673f179e7310939a9c7b4109007263cfa04", "searchTerms": ["Satellite 1"], + "slug": "tf-satellite-1", "title": "sl1", "where": "(chart.data->'tableFolders'->>'sl') = '1'" }, @@ -3682,6 +4089,7 @@ "inactive": false, "legacyFolderID": "F156fec11a0c705a6129893d40bc00f90b13032d4605b6f7e56f30ae8bafcf70c", "searchTerms": ["Satellite 10"], + "slug": "tf-satellite-1-0", "title": "sl10", "where": "(chart.data->'tableFolders'->>'sl') = '10'" }, @@ -3691,6 +4099,7 @@ "inactive": false, "legacyFolderID": "Fccdb3940c28cedf1cfab4b7ffe87778a9f37bcd379d6a28763f90565dbe9f5f9", "searchTerms": ["Satellite 11"], + "slug": "tf-satellite-1-1", "title": "sl11", "where": "(chart.data->'tableFolders'->>'sl') = '11'" }, @@ -3700,6 +4109,7 @@ "inactive": false, "legacyFolderID": "Ffb97ed5f7ccd84a5513332bf9415c9e9d6ab1677b407ff4f0a129710d80e211e", "searchTerms": ["Satellite 12"], + "slug": "tf-satellite-1-2", "title": "sl12", "where": "(chart.data->'tableFolders'->>'sl') = '12'" }, @@ -3709,6 +4119,7 @@ "inactive": false, "legacyFolderID": "F2715effd17ef676ae92f82184889523d76d7bbb9cf461444c9ee7a88bf4a27ec", "searchTerms": ["Satellite 2"], + "slug": "tf-satellite-2", "title": "sl2", "where": "(chart.data->'tableFolders'->>'sl') = '2'" }, @@ -3718,6 +4129,7 @@ "inactive": false, "legacyFolderID": "Fe97787056d94f64bea1a16b93ce7f8b8cad2b680605ac6d45ad086546aa2df39", "searchTerms": ["Satellite 3"], + "slug": "tf-satellite-3", "title": "sl3", "where": "(chart.data->'tableFolders'->>'sl') = '3'" }, @@ -3727,6 +4139,7 @@ "inactive": false, "legacyFolderID": "Facc716f2b3e4d05e69064013418b497ce33c0aa4b3449d34a92e3f3ebd6354fd", "searchTerms": ["Satellite 4"], + "slug": "tf-satellite-4", "title": "sl4", "where": "(chart.data->'tableFolders'->>'sl') = '4'" }, @@ -3736,6 +4149,7 @@ "inactive": false, "legacyFolderID": "Fee35e1d31721483931e7caa89fab84f19c3c03b090f92eb01a66c8c0b216e035", "searchTerms": ["Satellite 5"], + "slug": "tf-satellite-5", "title": "sl5", "where": "(chart.data->'tableFolders'->>'sl') = '5'" }, @@ -3745,6 +4159,7 @@ "inactive": false, "legacyFolderID": "F378e430568b9083e20f61a376f3f1919151ff535e195952921d21654011a7552", "searchTerms": ["Satellite 6"], + "slug": "tf-satellite-6", "title": "sl6", "where": "(chart.data->'tableFolders'->>'sl') = '6'" }, @@ -3754,6 +4169,7 @@ "inactive": false, "legacyFolderID": "F3cbff918489546d0f9a6648cabc0d933aed8475a4d3f99ccbf98f3b6ebf5394e", "searchTerms": ["Satellite 7"], + "slug": "tf-satellite-7", "title": "sl7", "where": "(chart.data->'tableFolders'->>'sl') = '7'" }, @@ -3763,6 +4179,7 @@ "inactive": false, "legacyFolderID": "Fad24f9ef9c639813ad9da1c1dc7160e7e25a50258de1be291c56a334d385a78b", "searchTerms": ["Satellite 8"], + "slug": "tf-satellite-8", "title": "sl8", "where": "(chart.data->'tableFolders'->>'sl') = '8'" }, @@ -3772,6 +4189,7 @@ "inactive": false, "legacyFolderID": "Fb6c6601216d4cff13d13af0a8b98d498e3d842140aff73c41a461a70a7f92b0d", "searchTerms": ["Satellite 9"], + "slug": "tf-satellite-9", "title": "sl9", "where": "(chart.data->'tableFolders'->>'sl') = '9'" }, @@ -3781,6 +4199,7 @@ "inactive": false, "legacyFolderID": "Fc2ac8a9e56455c7502944fc193575dbed1a885708beb0bc598525a96d5d8374f", "searchTerms": [], + "slug": "tf-supernova-0", "title": "sn0", "where": "(chart.data->'tableFolders'->>'sn') = '0'" }, @@ -3790,6 +4209,7 @@ "inactive": false, "legacyFolderID": "Fc1a0e192dcb76e6ffcf2768339a507724c32ea5eea5ec8ed59d0861bef075064", "searchTerms": [], + "slug": "tf-supernova-1", "title": "sn1", "where": "(chart.data->'tableFolders'->>'sn') = '1'" }, @@ -3799,6 +4219,7 @@ "inactive": false, "legacyFolderID": "F66770e27c119af000db4dc73e0f3a9311a96d9be2f32a128db159e60eba1ae71", "searchTerms": [], + "slug": "tf-supernova-1-0", "title": "sn10", "where": "(chart.data->'tableFolders'->>'sn') = '10'" }, @@ -3808,6 +4229,7 @@ "inactive": false, "legacyFolderID": "F67a277f384072f10d4e6c84d87fe397597ce1ee62e193bdcc3e5642867a67ec0", "searchTerms": [], + "slug": "tf-supernova-1-1", "title": "sn11", "where": "(chart.data->'tableFolders'->>'sn') = '11'" }, @@ -3817,6 +4239,7 @@ "inactive": false, "legacyFolderID": "F808d9623055b308630b45af06321555a174005bd795a790237b247d64028358b", "searchTerms": [], + "slug": "tf-supernova-1-2", "title": "sn12", "where": "(chart.data->'tableFolders'->>'sn') = '12'" }, @@ -3826,6 +4249,7 @@ "inactive": false, "legacyFolderID": "F665b0fb22bfb083e7dd102d0962e3ea00a9a0b062d946926b73eeb4368d79c80", "searchTerms": [], + "slug": "tf-supernova-2", "title": "sn2", "where": "(chart.data->'tableFolders'->>'sn') = '2'" }, @@ -3835,6 +4259,7 @@ "inactive": false, "legacyFolderID": "F4d7bf8096511c436e9b4fb0f663d14e2b938bbe2cffad3ea8f40fdd7e1d65f1b", "searchTerms": [], + "slug": "tf-supernova-3", "title": "sn3", "where": "(chart.data->'tableFolders'->>'sn') = '3'" }, @@ -3844,6 +4269,7 @@ "inactive": false, "legacyFolderID": "Feb86a857395601a1d27f84d1578aeceef74c4e83c8fd5f24ff1b248945a64a10", "searchTerms": [], + "slug": "tf-supernova-4", "title": "sn4", "where": "(chart.data->'tableFolders'->>'sn') = '4'" }, @@ -3853,6 +4279,7 @@ "inactive": false, "legacyFolderID": "F1bdfb104793d2cf0a4d8f7462243ea665f701db9f4cc2e953b919692c875187b", "searchTerms": [], + "slug": "tf-supernova-5", "title": "sn5", "where": "(chart.data->'tableFolders'->>'sn') = '5'" }, @@ -3862,6 +4289,7 @@ "inactive": false, "legacyFolderID": "F514f91feb7306b81bcdf89d05b23bd97215a05e9220051aa0e8980c13a8d1c52", "searchTerms": [], + "slug": "tf-supernova-6", "title": "sn6", "where": "(chart.data->'tableFolders'->>'sn') = '6'" }, @@ -3871,6 +4299,7 @@ "inactive": false, "legacyFolderID": "F3d3efbb41eb7395c62f8fe5de1513acf5c7f83316b71a4458629449e4a43aac2", "searchTerms": [], + "slug": "tf-supernova-7", "title": "sn7", "where": "(chart.data->'tableFolders'->>'sn') = '7'" }, @@ -3880,6 +4309,7 @@ "inactive": false, "legacyFolderID": "F179d2df88d5677ce3c2a3ec3ad97c5660fd6505d296507feed2ee1aeaf22f408", "searchTerms": [], + "slug": "tf-supernova-8", "title": "sn8", "where": "(chart.data->'tableFolders'->>'sn') = '8'" }, @@ -3889,6 +4319,7 @@ "inactive": false, "legacyFolderID": "F964275ecd90ebda1fdfc7c7e66243078c13f81908f1257a1418719bb6c410db2", "searchTerms": [], + "slug": "tf-supernova-9", "title": "sn9", "where": "(chart.data->'tableFolders'->>'sn') = '9'" }, @@ -3898,6 +4329,7 @@ "inactive": false, "legacyFolderID": "F90018c2ea858993e4a395fa4a86d687c052390a50b97c516cf37d2b5c9969cd4", "searchTerms": [], + "slug": "tf-solar-0", "title": "so0", "where": "(chart.data->'tableFolders'->>'so') = '0'" }, @@ -3907,6 +4339,7 @@ "inactive": false, "legacyFolderID": "F3ae364912dadd34e419f75b41cc8523e83645b6b0074fda8b84f4b3472b6e324", "searchTerms": [], + "slug": "tf-solar-1", "title": "so1", "where": "(chart.data->'tableFolders'->>'so') = '1'" }, @@ -3916,6 +4349,7 @@ "inactive": false, "legacyFolderID": "F0d9219bcdfbc9e47a00c519ce3613a14823c4ad5722839d5970d178f78e1921f", "searchTerms": [], + "slug": "tf-solar-1-0", "title": "so10", "where": "(chart.data->'tableFolders'->>'so') = '10'" }, @@ -3925,6 +4359,7 @@ "inactive": false, "legacyFolderID": "F6f93be9a9100aabcb420b14b49ea607f22fe9ead7105b0049ca8117a695aa6ad", "searchTerms": [], + "slug": "tf-solar-1-1", "title": "so11", "where": "(chart.data->'tableFolders'->>'so') = '11'" }, @@ -3934,6 +4369,7 @@ "inactive": false, "legacyFolderID": "F569a5ef67ce75df8d232a80feb43923e063c9933da947744fe50923a38af327f", "searchTerms": [], + "slug": "tf-solar-1-2", "title": "so12", "where": "(chart.data->'tableFolders'->>'so') = '12'" }, @@ -3943,6 +4379,7 @@ "inactive": false, "legacyFolderID": "Fdecb12edc6b325f53bebb8851a3c4eb0ca53327fb656b868156c7f2dc3a842f2", "searchTerms": [], + "slug": "tf-solar-2", "title": "so2", "where": "(chart.data->'tableFolders'->>'so') = '2'" }, @@ -3952,6 +4389,7 @@ "inactive": false, "legacyFolderID": "Fe375353ab7cecd5461e8c0e9e9f34522f9e1d0d75cb83cfbcbc5c1da252d7017", "searchTerms": [], + "slug": "tf-solar-3", "title": "so3", "where": "(chart.data->'tableFolders'->>'so') = '3'" }, @@ -3961,6 +4399,7 @@ "inactive": false, "legacyFolderID": "F41f85a95151aa72239cf2196c5baeadc693b6f4d5c227ec1f96e0f6572fb3cdc", "searchTerms": [], + "slug": "tf-solar-4", "title": "so4", "where": "(chart.data->'tableFolders'->>'so') = '4'" }, @@ -3970,6 +4409,7 @@ "inactive": false, "legacyFolderID": "Fabb672bf47e8dba1473308c91cf94bfdb374fde81bd9eff64ab54b8a30e28963", "searchTerms": [], + "slug": "tf-solar-5", "title": "so5", "where": "(chart.data->'tableFolders'->>'so') = '5'" }, @@ -3979,6 +4419,7 @@ "inactive": false, "legacyFolderID": "Fd6d7e8958d4f385490b12844c23a999a42cf0b87dc5cad487e52aa7f53f9b946", "searchTerms": [], + "slug": "tf-solar-6", "title": "so6", "where": "(chart.data->'tableFolders'->>'so') = '6'" }, @@ -3988,6 +4429,7 @@ "inactive": false, "legacyFolderID": "F70aa698b7282ae39d87863b80ae96bc7172df79d68fe8611f1de09c47ec574ff", "searchTerms": [], + "slug": "tf-solar-7", "title": "so7", "where": "(chart.data->'tableFolders'->>'so') = '7'" }, @@ -3997,6 +4439,7 @@ "inactive": false, "legacyFolderID": "F8a247853ecc1deb3a3d858a4e4cc30fc8500eb47c16bc453c2d7430ee1c85f3f", "searchTerms": [], + "slug": "tf-solar-8", "title": "so8", "where": "(chart.data->'tableFolders'->>'so') = '8'" }, @@ -4006,6 +4449,7 @@ "inactive": false, "legacyFolderID": "F200ede1e5a592cfdd4dfee37f49d902e87404c0db5fef6e726081315c2ed2ad8", "searchTerms": [], + "slug": "tf-solar-9", "title": "so9", "where": "(chart.data->'tableFolders'->>'so') = '9'" }, @@ -4015,6 +4459,7 @@ "inactive": false, "legacyFolderID": "F731adc90fd77c9b2cd0e1761bd61c610fb250fe4bee5acfbeee6f69159e33abd", "searchTerms": ["solar"], + "slug": "tf-solar-present", "title": "Solar", "where": "(chart.data->'tableFolders') ? 'so'" }, @@ -4024,6 +4469,7 @@ "inactive": false, "legacyFolderID": "F0a7b34236ba40e0cde7426a5c6919c0b06866efe6fe3508b6d43a916ed50f1a7", "searchTerms": ["Starlight !"], + "slug": "tf-starlight-excl", "title": "sr!", "where": "(chart.data->'tableFolders'->>'sr') = '!'" }, @@ -4033,6 +4479,7 @@ "inactive": false, "legacyFolderID": "F45367aea9b30a92e1b861502b9a8f92edc0e6a6a1249649934e27c0e00a9ce3f", "searchTerms": ["Starlight ?"], + "slug": "tf-starlight-q", "title": "sr?", "where": "(chart.data->'tableFolders'->>'sr') = '?'" }, @@ -4042,6 +4489,7 @@ "inactive": false, "legacyFolderID": "F0f81b0a5f260520d372e2b66e429cfb213591c7bbad822530a7fa4ebf70465f0", "searchTerms": ["Starlight 0"], + "slug": "tf-starlight-0", "title": "sr0", "where": "(chart.data->'tableFolders'->>'sr') = '0'" }, @@ -4051,6 +4499,7 @@ "inactive": false, "legacyFolderID": "F03ef9202532ef8356111903fa372f2bf7d8311326b2fe01609f488633ac646be", "searchTerms": ["Starlight 1"], + "slug": "tf-starlight-1", "title": "sr1", "where": "(chart.data->'tableFolders'->>'sr') = '1'" }, @@ -4060,6 +4509,7 @@ "inactive": false, "legacyFolderID": "Fe9d2a0150cb7f5a3fec4b30fd187ccbc0be129ff82ad4007ee4ba8939f0545bb", "searchTerms": ["Starlight 10"], + "slug": "tf-starlight-1-0", "title": "sr10", "where": "(chart.data->'tableFolders'->>'sr') = '10'" }, @@ -4069,6 +4519,7 @@ "inactive": false, "legacyFolderID": "F9e8b25af2445a573e4c9231e21492231a4e10bccd0bc2fff239b7aa84e72f06b", "searchTerms": ["Starlight 11"], + "slug": "tf-starlight-1-1", "title": "sr11", "where": "(chart.data->'tableFolders'->>'sr') = '11'" }, @@ -4078,6 +4529,7 @@ "inactive": false, "legacyFolderID": "Fe3975b5c1010965519498224010171151bdaf8701d6eb8ce8cf2f25ab94cfb88", "searchTerms": ["Starlight 12"], + "slug": "tf-starlight-1-2", "title": "sr12", "where": "(chart.data->'tableFolders'->>'sr') = '12'" }, @@ -4087,6 +4539,7 @@ "inactive": false, "legacyFolderID": "Fac25b0061bf21b3ef209c4a3fae962a1ea6b54f31fa558387b65e5553595b3ca", "searchTerms": ["Starlight 13"], + "slug": "tf-starlight-1-3", "title": "sr13", "where": "(chart.data->'tableFolders'->>'sr') = '13'" }, @@ -4096,6 +4549,7 @@ "inactive": false, "legacyFolderID": "Fcc8476cf890d946e25d0b99547ddf4536801d7d52e8f484f4c52f90c2443910f", "searchTerms": ["Starlight 2"], + "slug": "tf-starlight-2", "title": "sr2", "where": "(chart.data->'tableFolders'->>'sr') = '2'" }, @@ -4105,6 +4559,7 @@ "inactive": false, "legacyFolderID": "F7ba4da78d9aae7ac365953a23c7da661f9217da63d3211c23c2fce8814b05529", "searchTerms": ["Starlight 3"], + "slug": "tf-starlight-3", "title": "sr3", "where": "(chart.data->'tableFolders'->>'sr') = '3'" }, @@ -4114,6 +4569,7 @@ "inactive": false, "legacyFolderID": "Fa85c796d58a0b7233c3bbd8eb729677910aed64c3fccf24f6fcad14ba36f0c6b", "searchTerms": ["Starlight 4"], + "slug": "tf-starlight-4", "title": "sr4", "where": "(chart.data->'tableFolders'->>'sr') = '4'" }, @@ -4123,6 +4579,7 @@ "inactive": false, "legacyFolderID": "F2f09d60ddc4e6d2a153bf64d29e041927c184523a04428d098e554babcaa03f9", "searchTerms": ["Starlight 5"], + "slug": "tf-starlight-5", "title": "sr5", "where": "(chart.data->'tableFolders'->>'sr') = '5'" }, @@ -4132,6 +4589,7 @@ "inactive": false, "legacyFolderID": "Fcd34366ee13b5339c690ff99f0ab001d6a50ea4ed357a4a5daee13a47d42934d", "searchTerms": ["Starlight 6"], + "slug": "tf-starlight-6", "title": "sr6", "where": "(chart.data->'tableFolders'->>'sr') = '6'" }, @@ -4141,6 +4599,7 @@ "inactive": false, "legacyFolderID": "F028a1f6c76b514c135b3d76bad2a9f6f31e9f886c366b2cbe3a98277ee2a166a", "searchTerms": ["Starlight 7"], + "slug": "tf-starlight-7", "title": "sr7", "where": "(chart.data->'tableFolders'->>'sr') = '7'" }, @@ -4150,6 +4609,7 @@ "inactive": false, "legacyFolderID": "Fca49c662ef26e51b802b3cd4a6d662ba065bd4dc7898816a5642987bd5c863a9", "searchTerms": ["Starlight 8"], + "slug": "tf-starlight-8", "title": "sr8", "where": "(chart.data->'tableFolders'->>'sr') = '8'" }, @@ -4159,6 +4619,7 @@ "inactive": false, "legacyFolderID": "F68f2b05383f4efe41315053bf07102e0b8c3030e2061ae6c66913225a6401a27", "searchTerms": ["Starlight 9"], + "slug": "tf-starlight-9", "title": "sr9", "where": "(chart.data->'tableFolders'->>'sr') = '9'" }, @@ -4168,6 +4629,7 @@ "inactive": false, "legacyFolderID": "Ffb7654a11bb534e600060c973b2e997c48e73adc62cca9d1a96a344cd5627ed3", "searchTerms": ["Stella 0"], + "slug": "tf-stella-0", "title": "st0", "where": "(chart.data->'tableFolders'->>'st') = '0'" }, @@ -4177,6 +4639,7 @@ "inactive": false, "legacyFolderID": "F323987c34aeadd83039bbae10ddff50f8c9613df8130692df14d6918ba817549", "searchTerms": ["Stella 1"], + "slug": "tf-stella-1", "title": "st1", "where": "(chart.data->'tableFolders'->>'st') = '1'" }, @@ -4186,6 +4649,7 @@ "inactive": false, "legacyFolderID": "Fb3776ab1f5b141ffddbeee6806a6894ba139e7d1874b7754bf5cea7543573bb3", "searchTerms": ["Stella 10"], + "slug": "tf-stella-1-0", "title": "st10", "where": "(chart.data->'tableFolders'->>'st') = '10'" }, @@ -4195,6 +4659,7 @@ "inactive": false, "legacyFolderID": "F1575f6a0291579bc1dd71def5c2d11a32bd5f9cc1348c71b5763bed88e2fcadc", "searchTerms": ["Stella 11"], + "slug": "tf-stella-1-1", "title": "st11", "where": "(chart.data->'tableFolders'->>'st') = '11'" }, @@ -4204,6 +4669,7 @@ "inactive": false, "legacyFolderID": "F5d93066203271bc3db84fb01f1adea605fac2792f7595db755e2c3b60f6baa8e", "searchTerms": ["Stella 12"], + "slug": "tf-stella-1-2", "title": "st12", "where": "(chart.data->'tableFolders'->>'st') = '12'" }, @@ -4213,6 +4679,7 @@ "inactive": false, "legacyFolderID": "Fbe7ecfb433bcd550a9238268899eeed8713524ae66924a0de5e4d805b8484a26", "searchTerms": ["Stella 2"], + "slug": "tf-stella-2", "title": "st2", "where": "(chart.data->'tableFolders'->>'st') = '2'" }, @@ -4222,6 +4689,7 @@ "inactive": false, "legacyFolderID": "F32b0edb7bb7bbd55cd277c24f4f401cacad902320f78e9c4125fee06c26eb483", "searchTerms": ["Stella 3"], + "slug": "tf-stella-3", "title": "st3", "where": "(chart.data->'tableFolders'->>'st') = '3'" }, @@ -4231,6 +4699,7 @@ "inactive": false, "legacyFolderID": "F56344e4e22442f0eea66807b202cc6cf374b4b49066f535dc02183f9c8470640", "searchTerms": ["Stella 4"], + "slug": "tf-stella-4", "title": "st4", "where": "(chart.data->'tableFolders'->>'st') = '4'" }, @@ -4240,6 +4709,7 @@ "inactive": false, "legacyFolderID": "Fcaf431597a465343d8a33c5426e23fcf23f44f9e473140f699fb30860047d94c", "searchTerms": ["Stella 5"], + "slug": "tf-stella-5", "title": "st5", "where": "(chart.data->'tableFolders'->>'st') = '5'" }, @@ -4249,6 +4719,7 @@ "inactive": false, "legacyFolderID": "F6f6b7143e0ad2c18f9fc29f30ad017d789fc9975b347e20463255a4c031eda4e", "searchTerms": ["Stella 6"], + "slug": "tf-stella-6", "title": "st6", "where": "(chart.data->'tableFolders'->>'st') = '6'" }, @@ -4258,6 +4729,7 @@ "inactive": false, "legacyFolderID": "Fa702ec79207f52bcf9c60bc907902227c7d205d1510c9bc83661be3ae3de3f48", "searchTerms": ["Stella 7"], + "slug": "tf-stella-7", "title": "st7", "where": "(chart.data->'tableFolders'->>'st') = '7'" }, @@ -4267,6 +4739,7 @@ "inactive": false, "legacyFolderID": "F02bfd6b27b37245b8022dee95141b34d0112dc697be058051d93b9c45386247f", "searchTerms": ["Stella 8"], + "slug": "tf-stella-8", "title": "st8", "where": "(chart.data->'tableFolders'->>'st') = '8'" }, @@ -4276,6 +4749,7 @@ "inactive": false, "legacyFolderID": "F8412dad28f77a6fdd8fa6cd3761040c25b91a79e5f49818d32533413b4b53fbe", "searchTerms": ["Stella 9"], + "slug": "tf-stella-9", "title": "st9", "where": "(chart.data->'tableFolders'->>'st') = '9'" }, @@ -4285,6 +4759,7 @@ "inactive": false, "legacyFolderID": "Fc307887b9958200a623b03fb2538f5b5ec8d2b951bf119edc6848ac255b335c4", "searchTerms": ["stardust"], + "slug": "tf-stardust-present", "title": "Stardust", "where": "(chart.data->'tableFolders') ? 'ξ'" }, @@ -4294,6 +4769,7 @@ "inactive": false, "legacyFolderID": "F3aacc76df2f4bc79e4d0944c4e528223e0ff9a8dbda88c07e8377d9c465c7aa0", "searchTerms": ["starlight"], + "slug": "tf-starlight-present", "title": "Starlight", "where": "(chart.data->'tableFolders') ? 'sr'" }, @@ -4303,6 +4779,7 @@ "inactive": false, "legacyFolderID": "F592f40c2a6fe1d9e6576af59d2a767f2a8298656b492e8598ff44a89880f0015", "searchTerms": [], + "slug": "tf-stella-present", "title": "Stella", "where": "(chart.data->'tableFolders') ? 'st'" }, @@ -4312,6 +4789,7 @@ "inactive": false, "legacyFolderID": "F4b69207ae5710ca30e42e1019093a71dbba15071d467361bf224a76687936c79", "searchTerms": ["supernova"], + "slug": "tf-supernova-present", "title": "Supernova", "where": "(chart.data->'tableFolders') ? 'sn'" }, @@ -4321,6 +4799,7 @@ "inactive": false, "legacyFolderID": "Fa19c8f49da25cd984fc90b9c8ced846dd7087ee4eee023302619c02b9ed038bf", "searchTerms": [], + "slug": "tf-armshougakkou-0", "title": "Ude0", "where": "(chart.data->'tableFolders'->>'Ude') = '0'" }, @@ -4330,6 +4809,7 @@ "inactive": false, "legacyFolderID": "Fd10c8dfa9ffb5fe7c5a9c2158c21680e72ddcfa4559c038ad9bef4f493cb6d5a", "searchTerms": [], + "slug": "tf-armshougakkou-1", "title": "Ude1", "where": "(chart.data->'tableFolders'->>'Ude') = '1'" }, @@ -4339,6 +4819,7 @@ "inactive": false, "legacyFolderID": "F0e1cc016f8b750b3722bb5df6d04d8cd2726d7d72bcc6b7ab8d3e5171d95cc36", "searchTerms": [], + "slug": "tf-armshougakkou-1-0", "title": "Ude10", "where": "(chart.data->'tableFolders'->>'Ude') = '10'" }, @@ -4348,6 +4829,7 @@ "inactive": false, "legacyFolderID": "F630da8b4f1c304483a48648384a69bdf150b4671592b58fa0d63b8c1f2fba209", "searchTerms": [], + "slug": "tf-armshougakkou-1-1", "title": "Ude11", "where": "(chart.data->'tableFolders'->>'Ude') = '11'" }, @@ -4357,6 +4839,7 @@ "inactive": false, "legacyFolderID": "Fa569cec329a1b377153529e33219dc349dd23ad7dc7838e05f8947ae8f6d4f1f", "searchTerms": [], + "slug": "tf-armshougakkou-1-2", "title": "Ude12", "where": "(chart.data->'tableFolders'->>'Ude') = '12'" }, @@ -4366,6 +4849,7 @@ "inactive": false, "legacyFolderID": "Fadc6e5213942fbec28020e75c1185dc6d9ea830a996a74b1fda94a14124d1849", "searchTerms": [], + "slug": "tf-armshougakkou-2", "title": "Ude2", "where": "(chart.data->'tableFolders'->>'Ude') = '2'" }, @@ -4375,6 +4859,7 @@ "inactive": false, "legacyFolderID": "Facef941687d59b487f927f98b985c498b81d3800c510284fbbf4284c7906678a", "searchTerms": [], + "slug": "tf-armshougakkou-3", "title": "Ude3", "where": "(chart.data->'tableFolders'->>'Ude') = '3'" }, @@ -4384,6 +4869,7 @@ "inactive": false, "legacyFolderID": "F224f57c953c30ba6e57842daff4f969c7819dc7d4a3b27e5aca1080573228d88", "searchTerms": [], + "slug": "tf-armshougakkou-4", "title": "Ude4", "where": "(chart.data->'tableFolders'->>'Ude') = '4'" }, @@ -4393,6 +4879,7 @@ "inactive": false, "legacyFolderID": "Fca0412781e575d596972704a92ea0020efbd6372a88b3cdac92b4f8af5d3947b", "searchTerms": [], + "slug": "tf-armshougakkou-5", "title": "Ude5", "where": "(chart.data->'tableFolders'->>'Ude') = '5'" }, @@ -4402,6 +4889,7 @@ "inactive": false, "legacyFolderID": "F623b9db1c661e7a5f39578412a82909337dd9736b8c444b27362bc9ceed87384", "searchTerms": [], + "slug": "tf-armshougakkou-6", "title": "Ude6", "where": "(chart.data->'tableFolders'->>'Ude') = '6'" }, @@ -4411,6 +4899,7 @@ "inactive": false, "legacyFolderID": "F4f75723af4737bf139d547898fe81a53c3a841d0a4ad2e8fd908c2e6f8877693", "searchTerms": [], + "slug": "tf-armshougakkou-7", "title": "Ude7", "where": "(chart.data->'tableFolders'->>'Ude') = '7'" }, @@ -4420,6 +4909,7 @@ "inactive": false, "legacyFolderID": "F436a1a97882fc38996c64917a883d74dac5d4a5f7c9378752c2d43c5c58ce6f8", "searchTerms": [], + "slug": "tf-armshougakkou-8", "title": "Ude8", "where": "(chart.data->'tableFolders'->>'Ude') = '8'" }, @@ -4429,6 +4919,7 @@ "inactive": false, "legacyFolderID": "F5f402055c27b371780b42a9a3972cec1c1ec5474f21fcc50f7badd21700a6b54", "searchTerms": [], + "slug": "tf-armshougakkou-9", "title": "Ude9", "where": "(chart.data->'tableFolders'->>'Ude') = '9'" }, @@ -4438,6 +4929,7 @@ "inactive": false, "legacyFolderID": "Fc484371ac8c5cec8f1a9cf2deab6e07a971df167fa58371921511a4254e72a3a", "searchTerms": ["Stardust ?"], + "slug": "tf-stardust-q", "title": "ξ?", "where": "(chart.data->'tableFolders'->>'ξ') = '?'" }, @@ -4447,6 +4939,7 @@ "inactive": false, "legacyFolderID": "F5223d2972604af59298db03e4bd4551624c5687ab8aa9e46640c4815f50c68a4", "searchTerms": ["Stardust 1"], + "slug": "tf-stardust-1", "title": "ξ1", "where": "(chart.data->'tableFolders'->>'ξ') = '1'" }, @@ -4456,6 +4949,7 @@ "inactive": false, "legacyFolderID": "F48f9f28b476ec0349fe808aab3b670cbb3603788274800a49ad536b16da026a7", "searchTerms": ["Stardust 10"], + "slug": "tf-stardust-1-0", "title": "ξ10", "where": "(chart.data->'tableFolders'->>'ξ') = '10'" }, @@ -4465,6 +4959,7 @@ "inactive": false, "legacyFolderID": "F259ce798b7244a27c47d5a95edb6f587364fecccbe15b59b744ab13d845fe469", "searchTerms": ["Stardust 11"], + "slug": "tf-stardust-1-1", "title": "ξ11", "where": "(chart.data->'tableFolders'->>'ξ') = '11'" }, @@ -4474,6 +4969,7 @@ "inactive": false, "legacyFolderID": "Fe377607434076c594e9be6c67c1c8f267e62830812bc77a10d27e1210af21449", "searchTerms": ["Stardust 12"], + "slug": "tf-stardust-1-2", "title": "ξ12", "where": "(chart.data->'tableFolders'->>'ξ') = '12'" }, @@ -4483,6 +4979,7 @@ "inactive": false, "legacyFolderID": "F906c3c9295ba33d8ce31f465104d843ad92c7c772ce2226342d775d34101b493", "searchTerms": ["Stardust 13"], + "slug": "tf-stardust-1-3", "title": "ξ13", "where": "(chart.data->'tableFolders'->>'ξ') = '13'" }, @@ -4492,6 +4989,7 @@ "inactive": false, "legacyFolderID": "Ffd24b409cdbe702194a0b9726123f52a288b4fbaa56fba65c8da83c332aeeba7", "searchTerms": ["Stardust 2"], + "slug": "tf-stardust-2", "title": "ξ2", "where": "(chart.data->'tableFolders'->>'ξ') = '2'" }, @@ -4501,6 +4999,7 @@ "inactive": false, "legacyFolderID": "F72c4e379e5a73cfbd7b175cd95a707925ffea809ef5f9ceeef1c733f58e89f1d", "searchTerms": ["Stardust 3"], + "slug": "tf-stardust-3", "title": "ξ3", "where": "(chart.data->'tableFolders'->>'ξ') = '3'" }, @@ -4510,6 +5009,7 @@ "inactive": false, "legacyFolderID": "Fdd8da30201d74d0d07bfbfe0ecbf3a56dcd50d9099b7532d82a1e9ef06ee2533", "searchTerms": ["Stardust 4"], + "slug": "tf-stardust-4", "title": "ξ4", "where": "(chart.data->'tableFolders'->>'ξ') = '4'" }, @@ -4519,6 +5019,7 @@ "inactive": false, "legacyFolderID": "F764475cae613244fc167da358708fc3f9d47eb87fc59f92f6124961a4a45b35a", "searchTerms": ["Stardust 5"], + "slug": "tf-stardust-5", "title": "ξ5", "where": "(chart.data->'tableFolders'->>'ξ') = '5'" }, @@ -4528,6 +5029,7 @@ "inactive": false, "legacyFolderID": "F737d0cd5c7e1b18ab85c56f9811119c315ad8f386a4babd59b1a95cf9f074004", "searchTerms": ["Stardust 6"], + "slug": "tf-stardust-6", "title": "ξ6", "where": "(chart.data->'tableFolders'->>'ξ') = '6'" }, @@ -4537,6 +5039,7 @@ "inactive": false, "legacyFolderID": "Faf3a9c4250dfa3ff84d8a493a28ba07e59fea23d823ae440815b870775a43ac4", "searchTerms": ["Stardust 7"], + "slug": "tf-stardust-7", "title": "ξ7", "where": "(chart.data->'tableFolders'->>'ξ') = '7'" }, @@ -4546,6 +5049,7 @@ "inactive": false, "legacyFolderID": "F90a61ff5baa0e827248f958bcc08d4fbdee064a3663e4c9e4b17ae74e3a0c7dc", "searchTerms": ["Stardust 8"], + "slug": "tf-stardust-8", "title": "ξ8", "where": "(chart.data->'tableFolders'->>'ξ') = '8'" }, @@ -4555,6 +5059,7 @@ "inactive": false, "legacyFolderID": "Feeda79fa0accf291bf9de608f364d82df40f2db13a1837197eb865a310f5b16e", "searchTerms": ["Stardust 9"], + "slug": "tf-stardust-9", "title": "ξ9", "where": "(chart.data->'tableFolders'->>'ξ') = '9'" }, @@ -4564,6 +5069,7 @@ "inactive": false, "legacyFolderID": "Fa746812d023b6cbba5daf77723a06f1b47b7d7be5d4b3db96fa0f81e4b790cdb", "searchTerms": [], + "slug": "tf-gachimjoy-0", "title": "双0", "where": "(chart.data->'tableFolders'->>'双') = '0'" }, @@ -4573,6 +5079,7 @@ "inactive": false, "legacyFolderID": "F37314803cacf7c7ee3f4f96079a07565ddefabf0ea966ed8c26965e099db50b9", "searchTerms": [], + "slug": "tf-gachimjoy-1", "title": "双1", "where": "(chart.data->'tableFolders'->>'双') = '1'" }, @@ -4582,6 +5089,7 @@ "inactive": false, "legacyFolderID": "Feb82b0fa59c843ce7e91c6ad5954bd22e73ce24689a716e0acc57f8c69eb2b3b", "searchTerms": [], + "slug": "tf-gachimjoy-2", "title": "双2", "where": "(chart.data->'tableFolders'->>'双') = '2'" }, @@ -4591,6 +5099,7 @@ "inactive": false, "legacyFolderID": "Ff1c99bead00d7aab743022a37043f9b961d2eaa662a3f0b64000f09f9e7e0b20", "searchTerms": [], + "slug": "tf-gachimjoy-3", "title": "双3", "where": "(chart.data->'tableFolders'->>'双') = '3'" }, @@ -4600,6 +5109,7 @@ "inactive": false, "legacyFolderID": "F76e24e985a4b2de782bdc99649ab7167e1b986f0d11655754837d8f6d6351cc5", "searchTerms": [], + "slug": "tf-gachimjoy-4", "title": "双4", "where": "(chart.data->'tableFolders'->>'双') = '4'" }, @@ -4609,6 +5119,7 @@ "inactive": false, "legacyFolderID": "F53f9921133229a0c978777f0f527650e23819e6cba8dd073c5d704448b6785e0", "searchTerms": [], + "slug": "tf-gachimjoy-5", "title": "双5", "where": "(chart.data->'tableFolders'->>'双') = '5'" }, @@ -4618,6 +5129,7 @@ "inactive": false, "legacyFolderID": "Fb1fd3357726d25373181d03b582f1a950ceb4cb7263b0390444b2269aec3e4cb", "searchTerms": [], + "slug": "tf-gachimjoy-6", "title": "双6", "where": "(chart.data->'tableFolders'->>'双') = '6'" }, @@ -4627,6 +5139,7 @@ "inactive": false, "legacyFolderID": "Fe849b3ebe1dfc41ea8ed2c26b6325dde3151ecba2b3472c9b9cfc58def3adfb2", "searchTerms": [], + "slug": "tf-gachimjoy-7", "title": "双7", "where": "(chart.data->'tableFolders'->>'双') = '7'" }, @@ -4636,6 +5149,7 @@ "inactive": false, "legacyFolderID": "F4b7802d608038b56177417fdb36b70aef4908f0935c1d9e9ab7e4613d76150b5", "searchTerms": [], + "slug": "tf-gachimjoy-8", "title": "双8", "where": "(chart.data->'tableFolders'->>'双') = '8'" }, @@ -4645,9 +5159,10 @@ "inactive": true, "legacyFolderID": "F7d28fb45afb4fa2c3afb890fa9ab5ed68680e9cbbc1be4cff4efd5a6883d9504", "searchTerms": [], + "slug": "advanced-luminous-intl", "title": "ADVANCED (LUMINOUS International)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4655,9 +5170,10 @@ "inactive": true, "legacyFolderID": "F7cc920e4f8378e26a0da99e1e1293a088a8fedb896cacef1c43434ab67cd74c2", "searchTerms": [], + "slug": "advanced-luminous-omni", "title": "ADVANCED (LUMINOUS Omnimix)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4665,9 +5181,10 @@ "inactive": true, "legacyFolderID": "F4ddc3cf363675b0b23408b3950c80c326eded2538772053e593578c6e8e15004", "searchTerms": [], + "slug": "advanced-luminousplus-intl", "title": "ADVANCED (LUMINOUS PLUS International)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4675,9 +5192,10 @@ "inactive": true, "legacyFolderID": "F5e157a27168bd0966071ba63388b2755626b503c3777f3709ca04dba4c51ec1b", "searchTerms": [], + "slug": "advanced-luminousplus-omni", "title": "ADVANCED (LUMINOUS PLUS Omnimix)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4685,9 +5203,10 @@ "inactive": true, "legacyFolderID": "F3f905a38a0b9f64afc304097cbfa7ba523015fbb81f25615a1cbd5405aa4ac32", "searchTerms": [], + "slug": "advanced-luminousplus", "title": "ADVANCED (LUMINOUS PLUS)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4695,9 +5214,10 @@ "inactive": true, "legacyFolderID": "F849d1219ea7da91e541fc5da62a78c2895f2276456a4de8e73428836b4bddc0c", "searchTerms": [], + "slug": "advanced-luminous", "title": "ADVANCED (LUMINOUS)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4705,9 +5225,10 @@ "inactive": true, "legacyFolderID": "Fbacc32764c26df97a99a3f18b49d285c3a5498038d2242639e337ce079d65a70", "searchTerms": [], + "slug": "advanced-newplus", "title": "ADVANCED (NEW PLUS)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4715,9 +5236,10 @@ "inactive": true, "legacyFolderID": "F7031914a25f787b3d14fe61beeca7d650216c0729f83af43026187e119cccaca", "searchTerms": [], + "slug": "advanced-new", "title": "ADVANCED (NEW)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4725,9 +5247,10 @@ "inactive": true, "legacyFolderID": "F48a64dd4f9898b515f9138d069e35c2108d773546e818f6d363653969248b127", "searchTerms": [], + "slug": "advanced-sun-omni", "title": "ADVANCED (SUN Omnimix)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4735,9 +5258,10 @@ "inactive": true, "legacyFolderID": "Fb091610afc5a5a6a1ae18d577561c669c178d58b7b689988f0cede31d65cc99e", "searchTerms": [], + "slug": "advanced-sunplus-intl", "title": "ADVANCED (SUN PLUS International)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4745,9 +5269,10 @@ "inactive": true, "legacyFolderID": "F5bb0b6c3869765177dc8270f26e8956e76ad61d24a9f97ea99ba224ef7baad72", "searchTerms": [], + "slug": "advanced-sunplus-omni", "title": "ADVANCED (SUN PLUS Omnimix)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4755,9 +5280,10 @@ "inactive": true, "legacyFolderID": "F68a48df00698b8ccbbc72dc830569b137c96a4315cc64e935e744d8a8b56d8ac", "searchTerms": [], + "slug": "advanced-sunplus", "title": "ADVANCED (SUN PLUS)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4765,9 +5291,10 @@ "inactive": true, "legacyFolderID": "F02b231e0004d400042db7e96528bdd58264983bc28fb59ab262c53b61b2085a9", "searchTerms": [], + "slug": "advanced-sun", "title": "ADVANCED (SUN)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4775,9 +5302,10 @@ "inactive": true, "legacyFolderID": "Fbde8d144ba9d0e46bcdc02d34e7f4417f87acc36ee7d2dbe7d33f95a01c2c720", "searchTerms": [], + "slug": "advanced-verse-intl", "title": "ADVANCED (VERSE International)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4785,9 +5313,10 @@ "inactive": false, "legacyFolderID": "F68fbcd566ffd50be0152f38b423ae4d758515313a2e787849161714389e59710", "searchTerms": [], + "slug": "advanced-verse-omni", "title": "ADVANCED (VERSE Omnimix)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4795,9 +5324,10 @@ "inactive": false, "legacyFolderID": "F9d742e64b9a92877c584a31422ddb655df2fa26e2be3ab5bc5e9a764211638a7", "searchTerms": [], + "slug": "advanced-verse", "title": "ADVANCED (VERSE)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4805,9 +5335,10 @@ "inactive": false, "legacyFolderID": "Facbfd1512cc2c53313e6fd7d92b6b8d861090a0747335f3e6add42a417590f0a", "searchTerms": [], + "slug": "advanced-xverse-intl", "title": "ADVANCED (X-VERSE International)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4815,9 +5346,10 @@ "inactive": false, "legacyFolderID": "F405274c8729a9606cfd80af8a257f03b6e2b3f10dc3b5c3894d5da71204d3f20", "searchTerms": [], + "slug": "advanced-xverse-omni", "title": "ADVANCED (X-VERSE Omnimix)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4825,9 +5357,10 @@ "inactive": false, "legacyFolderID": "Fc6fe5786e00a80396ddf1b2e5d8c935ff1599ea7f3dee623365f51fb2a14eab8", "searchTerms": [], + "slug": "advanced-xverse", "title": "ADVANCED (X-VERSE)", - "where": "chart.difficulty = 'ADVANCED'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.difficulty = 'ADVANCED'" }, { "game": "chunithm", @@ -4835,9 +5368,10 @@ "inactive": true, "legacyFolderID": "Fd62635ded9306c91f571d95c3179cf0087fdf642ab797c52facd8c84e970349d", "searchTerms": [], + "slug": "basic-luminous-intl", "title": "BASIC (LUMINOUS International)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4845,9 +5379,10 @@ "inactive": true, "legacyFolderID": "Fa343c8c7173f5a273aa097a0dd9e992a591a4d79b85a989dd0730cf525d8dc3a", "searchTerms": [], + "slug": "basic-luminous-omni", "title": "BASIC (LUMINOUS Omnimix)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4855,9 +5390,10 @@ "inactive": true, "legacyFolderID": "Fc755aba8f1fa91a490c56ca6efb6a33be312beb4b9be2b57cc99ac17731e62dd", "searchTerms": [], + "slug": "basic-luminousplus-intl", "title": "BASIC (LUMINOUS PLUS International)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4865,9 +5401,10 @@ "inactive": true, "legacyFolderID": "F5dae56bc2d0c202e7ac4ce7d7b251ab5a01ffa29b2916b6597f09ef17aae6e64", "searchTerms": [], + "slug": "basic-luminousplus-omni", "title": "BASIC (LUMINOUS PLUS Omnimix)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4875,9 +5412,10 @@ "inactive": true, "legacyFolderID": "F7fc7f6e9599e5c2476b0e2f71bd72386a6146295e25c974029199fa82b07065e", "searchTerms": [], + "slug": "basic-luminousplus", "title": "BASIC (LUMINOUS PLUS)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4885,9 +5423,10 @@ "inactive": true, "legacyFolderID": "F7793fa0dc580bc24b52b246775bba674028aab5857c190dad67f6224481c542e", "searchTerms": [], + "slug": "basic-luminous", "title": "BASIC (LUMINOUS)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4895,9 +5434,10 @@ "inactive": true, "legacyFolderID": "F5900484f6e911861f4a5e8d67c4a18f765f49eaec2ea13451dd796815b1b4849", "searchTerms": [], + "slug": "basic-newplus", "title": "BASIC (NEW PLUS)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4905,9 +5445,10 @@ "inactive": true, "legacyFolderID": "Fe568e9370c88cdeab88f42a0cfab2ebe8a06fd24c0046d7c0b753e4582253740", "searchTerms": [], + "slug": "basic-new", "title": "BASIC (NEW)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4915,9 +5456,10 @@ "inactive": true, "legacyFolderID": "F83348d50dd5cd8047e635c079c648c866f7be33c83f842ba20844ad61e0f2f74", "searchTerms": [], + "slug": "basic-sun-omni", "title": "BASIC (SUN Omnimix)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4925,9 +5467,10 @@ "inactive": true, "legacyFolderID": "F4bff342b9fb8322a65cab8f7f7a8923d1bc737ab3bee2dc48981dffc999767c1", "searchTerms": [], + "slug": "basic-sunplus-intl", "title": "BASIC (SUN PLUS International)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4935,9 +5478,10 @@ "inactive": true, "legacyFolderID": "F9991ac418b7e30c037a87916ca1a6332b4c5474f1c7046268b2c92aca8e7bc6c", "searchTerms": [], + "slug": "basic-sunplus-omni", "title": "BASIC (SUN PLUS Omnimix)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4945,9 +5489,10 @@ "inactive": true, "legacyFolderID": "Fb3c16745b5ca2cb6b8c1a737a6ca0e2a100468c990f26bd5a7247511cc49295f", "searchTerms": [], + "slug": "basic-sunplus", "title": "BASIC (SUN PLUS)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4955,9 +5500,10 @@ "inactive": true, "legacyFolderID": "F3e092fe1bdbff0b3e927324117fabe41ebf11e816d21baf368b118a64bd8e519", "searchTerms": [], + "slug": "basic-sun", "title": "BASIC (SUN)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4965,9 +5511,10 @@ "inactive": true, "legacyFolderID": "Fbb7dd8e5ece5ad364429a6510ca5d6bfd0e5421a5a3c57e7084d0f67f1660958", "searchTerms": [], + "slug": "basic-verse-intl", "title": "BASIC (VERSE International)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4975,9 +5522,10 @@ "inactive": false, "legacyFolderID": "F1e1c11d94aae70ef4982701adee2782047ad6703cdac5586fff8a772439d78e7", "searchTerms": [], + "slug": "basic-verse-omni", "title": "BASIC (VERSE Omnimix)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4985,9 +5533,10 @@ "inactive": false, "legacyFolderID": "Fc1ac7c0101b7a99e1d7b2d331da49d2dcad41c146ff8437e6033ed04612d56f6", "searchTerms": [], + "slug": "basic-verse", "title": "BASIC (VERSE)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -4995,9 +5544,10 @@ "inactive": false, "legacyFolderID": "F29cf75cc374f0bf012a8e8805293dfe7d7a1994fa225b9f70ed2a703234eaa2e", "searchTerms": [], + "slug": "basic-xverse-intl", "title": "BASIC (X-VERSE International)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -5005,9 +5555,10 @@ "inactive": false, "legacyFolderID": "Fa24a8ede359fbcc7def3b43277bdb9e652f70ffe3fc8a9b4f93dd79426cdb0da", "searchTerms": [], + "slug": "basic-xverse-omni", "title": "BASIC (X-VERSE Omnimix)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -5015,9 +5566,10 @@ "inactive": false, "legacyFolderID": "F0c3eaf9249fa04e8501d0680c4fdcfd53cc947ce14d8fd7ededd124633c4fd66", "searchTerms": [], + "slug": "basic-xverse", "title": "BASIC (X-VERSE)", - "where": "chart.difficulty = 'BASIC'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.difficulty = 'BASIC'" }, { "game": "chunithm", @@ -5025,9 +5577,10 @@ "inactive": true, "legacyFolderID": "F4075dccaebcd96c98a1a0956c19df5579f911a25d66c5a08b3d5a5cc5f0a7e78", "searchTerms": [], + "slug": "expert-luminous-intl", "title": "EXPERT (LUMINOUS International)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5035,9 +5588,10 @@ "inactive": true, "legacyFolderID": "F1fdd00a1e691c8ec75257a89636b92f3be0dc177b3f8340f206df826b9ec895e", "searchTerms": [], + "slug": "expert-luminous-omni", "title": "EXPERT (LUMINOUS Omnimix)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5045,9 +5599,10 @@ "inactive": true, "legacyFolderID": "F3f9be32435c7d30ac01177d1f1b0588da5273fe09c0303cacf8342a4ee9aaa6d", "searchTerms": [], + "slug": "expert-luminousplus-intl", "title": "EXPERT (LUMINOUS PLUS International)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5055,9 +5610,10 @@ "inactive": true, "legacyFolderID": "F77f852035c55c63a03864e3dab62ff747b704f2e6b69c2e02907773fca5ca688", "searchTerms": [], + "slug": "expert-luminousplus-omni", "title": "EXPERT (LUMINOUS PLUS Omnimix)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5065,9 +5621,10 @@ "inactive": true, "legacyFolderID": "Fc50ace09ee83e28115d1651ffd61f0b94ef6460e4b86838c5b38584de3985467", "searchTerms": [], + "slug": "expert-luminousplus", "title": "EXPERT (LUMINOUS PLUS)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5075,9 +5632,10 @@ "inactive": true, "legacyFolderID": "F3397558a8bc06de6c5d0ef0b1e266b57c8a7a471a5b65f50f1f12e2f884c64a4", "searchTerms": [], + "slug": "expert-luminous", "title": "EXPERT (LUMINOUS)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5085,9 +5643,10 @@ "inactive": true, "legacyFolderID": "F030f9e8f91f34439558fee25dae5704af704cfc453fab64ec17df348a12debaa", "searchTerms": [], + "slug": "expert-newplus", "title": "EXPERT (NEW PLUS)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5095,9 +5654,10 @@ "inactive": true, "legacyFolderID": "Feab929b5150b1089d428178fbd4a1eb1d677ad8e1c7a922579ef7da4077ed4e5", "searchTerms": [], + "slug": "expert-new", "title": "EXPERT (NEW)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5105,9 +5665,10 @@ "inactive": true, "legacyFolderID": "Fd5c4e8caf273eceb4eb202902846ac6a1dd00154c646b82f83c0034467b1f764", "searchTerms": [], + "slug": "expert-sun-omni", "title": "EXPERT (SUN Omnimix)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5115,9 +5676,10 @@ "inactive": true, "legacyFolderID": "F80f88570d7de2e4eb85914169bf2b9b61cf93b5da3c743fbaad57957a8961eb1", "searchTerms": [], + "slug": "expert-sunplus-intl", "title": "EXPERT (SUN PLUS International)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5125,9 +5687,10 @@ "inactive": true, "legacyFolderID": "Fa56f0e4d9dc089d940296844bd5378725e8b39abea331fd55ce2d997f3307628", "searchTerms": [], + "slug": "expert-sunplus-omni", "title": "EXPERT (SUN PLUS Omnimix)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5135,9 +5698,10 @@ "inactive": true, "legacyFolderID": "F7bb9d1ac6ed6ed93a03eebb3c617a9966f0e871f2f2ccff0f3289cfb2359c5fb", "searchTerms": [], + "slug": "expert-sunplus", "title": "EXPERT (SUN PLUS)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5145,9 +5709,10 @@ "inactive": true, "legacyFolderID": "F9a18f4367f30416d172b9e08c1924a90a8b8a88272a752811d2a5740413a6219", "searchTerms": [], + "slug": "expert-sun", "title": "EXPERT (SUN)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5155,9 +5720,10 @@ "inactive": true, "legacyFolderID": "F98f726ecb709c1749f5c846a2917c07c5f7005471c8a95a7e818080d148f5c6c", "searchTerms": [], + "slug": "expert-verse-intl", "title": "EXPERT (VERSE International)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5165,9 +5731,10 @@ "inactive": false, "legacyFolderID": "F16310d18c6785cb0911db787da4ecad69fba93d207082a95739f51898d42a9a2", "searchTerms": [], + "slug": "expert-verse-omni", "title": "EXPERT (VERSE Omnimix)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5175,9 +5742,10 @@ "inactive": false, "legacyFolderID": "F8a05dc919c85a6665d67a3d7579fb9fbccc077124f75b593a1be38d5ea109e26", "searchTerms": [], + "slug": "expert-verse", "title": "EXPERT (VERSE)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5185,9 +5753,10 @@ "inactive": false, "legacyFolderID": "F23fa4adf22e9a3124b98ced3bfab301e78a7d1e3f83908052e1291ec38d2251d", "searchTerms": [], + "slug": "expert-xverse-intl", "title": "EXPERT (X-VERSE International)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5195,9 +5764,10 @@ "inactive": false, "legacyFolderID": "F7b0ecb8b690df07129792d03848e456bb824c1545ba27c3993851fface4bf1a6", "searchTerms": [], + "slug": "expert-xverse-omni", "title": "EXPERT (X-VERSE Omnimix)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5205,9 +5775,10 @@ "inactive": false, "legacyFolderID": "F53b0ceccfe55fd580f515272c27e09213ae8d482733490ba953463d40509e51e", "searchTerms": [], + "slug": "expert-xverse", "title": "EXPERT (X-VERSE)", - "where": "chart.difficulty = 'EXPERT'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.difficulty = 'EXPERT'" }, { "game": "chunithm", @@ -5215,9 +5786,10 @@ "inactive": true, "legacyFolderID": "F0303ccceb032abc76256911f2b3cec64649920f4f22056651748e0e2b4305481", "searchTerms": [], + "slug": "1-luminous-intl", "title": "Level 1 (LUMINOUS International)", - "where": "chart.level = '1'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5225,9 +5797,10 @@ "inactive": true, "legacyFolderID": "Faf5e2ce79adb422c27209dfa69814bfe8aadb6b8d7bd8e201e0400b794a3f250", "searchTerms": [], + "slug": "1-luminous-omni", "title": "Level 1 (LUMINOUS Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5235,9 +5808,10 @@ "inactive": true, "legacyFolderID": "Faa250d84f92203ffa02522bc59c4c98a1429304de220f1abec7b2d923a026074", "searchTerms": [], + "slug": "1-luminousplus-intl", "title": "Level 1 (LUMINOUS PLUS International)", - "where": "chart.level = '1'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5245,9 +5819,10 @@ "inactive": true, "legacyFolderID": "F7e875668d847f93d97341ebfb5d24d5dccb90f9a06c3af0cfb38b8b1563add8f", "searchTerms": [], + "slug": "1-luminousplus-omni", "title": "Level 1 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5255,9 +5830,10 @@ "inactive": true, "legacyFolderID": "F5e6f84b3158d6e1a58be88901d1410bd2df343bee6fc6efea01736024f6a8df8", "searchTerms": [], + "slug": "1-luminousplus", "title": "Level 1 (LUMINOUS PLUS)", - "where": "chart.level = '1'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5265,9 +5841,10 @@ "inactive": true, "legacyFolderID": "F0e650704ddc8a045e5a944b660c9e16492b3d956891e8a3d1279af9648e37d62", "searchTerms": [], + "slug": "1-luminous", "title": "Level 1 (LUMINOUS)", - "where": "chart.level = '1'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5275,9 +5852,10 @@ "inactive": true, "legacyFolderID": "F8119e2745ef35932b68b1b4683d36006c5bf3ce5392f8e007f2b2ec4e088e7ac", "searchTerms": [], + "slug": "1-newplus", "title": "Level 1 (NEW PLUS)", - "where": "chart.level = '1'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5285,9 +5863,10 @@ "inactive": true, "legacyFolderID": "Faae366d77f525e3b8b4281d68b75cdfa084740189be666f270d5e91e62e4478d", "searchTerms": [], + "slug": "1-new", "title": "Level 1 (NEW)", - "where": "chart.level = '1'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5295,9 +5874,10 @@ "inactive": false, "legacyFolderID": "F7ab54c2dff3a2bcd7fa5129aae7e03d6c9b1998cfc06bf360cbcd9ac05653c71", "searchTerms": [], + "slug": "1-paradiselost", "title": "Level 1 (Paradise Lost)", - "where": "chart.level = '1'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5305,9 +5885,10 @@ "inactive": true, "legacyFolderID": "Fa912441b906993391a1d285ac085fc7debab18ef9c8beef5c61dbd272334aba8", "searchTerms": [], + "slug": "1-sun-omni", "title": "Level 1 (SUN Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5315,9 +5896,10 @@ "inactive": true, "legacyFolderID": "F34f99996cffce7a65daeac66af65b4a17d5a5dfea8b3bc4228389886b9b3645d", "searchTerms": [], + "slug": "1-sunplus-intl", "title": "Level 1 (SUN PLUS International)", - "where": "chart.level = '1'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5325,9 +5907,10 @@ "inactive": true, "legacyFolderID": "F768a5ace85fe08621523bc7479a195f6325e727b14103b0e6f1c91f177d80ec7", "searchTerms": [], + "slug": "1-sunplus-omni", "title": "Level 1 (SUN PLUS Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5335,9 +5918,10 @@ "inactive": true, "legacyFolderID": "F7c37ca6e3e26da2e368d967709bcbfd0a27fdeb4d9e69ad0d2caa8d03dc6b530", "searchTerms": [], + "slug": "1-sunplus", "title": "Level 1 (SUN PLUS)", - "where": "chart.level = '1'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5345,9 +5929,10 @@ "inactive": true, "legacyFolderID": "Ff3881c8525378f40166e20293df8c3ababb72c7ce8bd596e29b319c1856e3438", "searchTerms": [], + "slug": "1-sun", "title": "Level 1 (SUN)", - "where": "chart.level = '1'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5355,9 +5940,10 @@ "inactive": true, "legacyFolderID": "F3b70690a722d006da2afd326fab66a4bc47fd8cf16544277c985aab7c62aa0a0", "searchTerms": [], + "slug": "1-verse-intl", "title": "Level 1 (VERSE International)", - "where": "chart.level = '1'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5365,9 +5951,10 @@ "inactive": false, "legacyFolderID": "Fb8724b824df13f28a63ee26a5a864dffe1485843a4db09c7745b7c91023adcd1", "searchTerms": [], + "slug": "1-verse-omni", "title": "Level 1 (VERSE Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5375,9 +5962,10 @@ "inactive": false, "legacyFolderID": "Fcb7b909c3fc5eabb7211cc7e6c92b2f0af58f5d03d48f9b2d859acfe5510b20e", "searchTerms": [], + "slug": "1-verse", "title": "Level 1 (VERSE)", - "where": "chart.level = '1'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5385,9 +5973,10 @@ "inactive": false, "legacyFolderID": "Fe386543664924d2267528fa7603e4cb5b781a64741bf962ca57852592e5dc49e", "searchTerms": [], + "slug": "1-xverse-intl", "title": "Level 1 (X-VERSE International)", - "where": "chart.level = '1'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5395,9 +5984,10 @@ "inactive": false, "legacyFolderID": "Fc429784caeff000ae22fba7d484092b363198600704b0e259e6364b25a5881df", "searchTerms": [], + "slug": "1-xverse-omni", "title": "Level 1 (X-VERSE Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5405,9 +5995,10 @@ "inactive": false, "legacyFolderID": "F7b6305ea7ed5ec5bef5f980b1d012e61f195cda4bc9968918c303d864af1b1d6", "searchTerms": [], + "slug": "1-xverse", "title": "Level 1 (X-VERSE)", - "where": "chart.level = '1'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '1'" }, { "game": "chunithm", @@ -5415,9 +6006,10 @@ "inactive": true, "legacyFolderID": "Fd210c32fee193f55ac70d2224749d250f680765d44add1b97c3ffb4534a5f1d4", "searchTerms": [], + "slug": "10-luminous-intl", "title": "Level 10 (LUMINOUS International)", - "where": "chart.level = '10'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5425,9 +6017,10 @@ "inactive": true, "legacyFolderID": "Ff7c3063f18aeb643ab13fb9367397f1e6f06a70aa6cf29cef1c442ce9a3117e9", "searchTerms": [], + "slug": "10-luminous-omni", "title": "Level 10 (LUMINOUS Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5435,9 +6028,10 @@ "inactive": true, "legacyFolderID": "F34c156a92790842d57f10835d0b4ce4e2f9c548b657a41c628859c8cfaec502f", "searchTerms": [], + "slug": "10-luminousplus-intl", "title": "Level 10 (LUMINOUS PLUS International)", - "where": "chart.level = '10'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5445,9 +6039,10 @@ "inactive": true, "legacyFolderID": "F4a4614f8326d2921b7fa4d0bfa5ad4f36d09b33578bb82adc284b998fec0957a", "searchTerms": [], + "slug": "10-luminousplus-omni", "title": "Level 10 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5455,9 +6050,10 @@ "inactive": true, "legacyFolderID": "F56d7d1c08b8ef638a0e1fef1efa979d85184a102185087755bfcbeb0f3fd7c12", "searchTerms": [], + "slug": "10-luminousplus", "title": "Level 10 (LUMINOUS PLUS)", - "where": "chart.level = '10'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5465,9 +6061,10 @@ "inactive": true, "legacyFolderID": "F09f03f11dfc57e165dea49ef8e51cce80d95272c9f2ad160328c7f80a05a508d", "searchTerms": [], + "slug": "10-luminous", "title": "Level 10 (LUMINOUS)", - "where": "chart.level = '10'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5475,9 +6072,10 @@ "inactive": true, "legacyFolderID": "F90e5ce9ec06b9478662e66703b2cad426aba6689e4313c7359c6e5c0190d61d3", "searchTerms": [], + "slug": "10-newplus", "title": "Level 10 (NEW PLUS)", - "where": "chart.level = '10'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5485,9 +6083,10 @@ "inactive": true, "legacyFolderID": "Fe74f7735c3b2d527e8d0f29c7c2720efc10c3659caa33902089f5df1c9af4033", "searchTerms": [], + "slug": "10-new", "title": "Level 10 (NEW)", - "where": "chart.level = '10'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5495,9 +6094,10 @@ "inactive": false, "legacyFolderID": "Fe543868beccad30c99eb14d61d822ab621b42fb7cc38a25d6bd380d6a0310a71", "searchTerms": [], + "slug": "10-paradiselost", "title": "Level 10 (Paradise Lost)", - "where": "chart.level = '10'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5505,9 +6105,10 @@ "inactive": true, "legacyFolderID": "Fca2faf662ce0e51670fbceff985c995c631eb7db48750709ac965b4bf0e097a6", "searchTerms": [], + "slug": "10-sun-omni", "title": "Level 10 (SUN Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5515,9 +6116,10 @@ "inactive": true, "legacyFolderID": "Fb608b76e624702359004bc3814a93543f06e21409231355095b6384b320debce", "searchTerms": [], + "slug": "10-sunplus-intl", "title": "Level 10 (SUN PLUS International)", - "where": "chart.level = '10'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5525,9 +6127,10 @@ "inactive": true, "legacyFolderID": "F32db5db2cb255c9b40d2f07a833febf26a334aa91ccf09038c84c8535764c596", "searchTerms": [], + "slug": "10-sunplus-omni", "title": "Level 10 (SUN PLUS Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5535,9 +6138,10 @@ "inactive": true, "legacyFolderID": "F978f4cf3b26fc46e435a69d129e1cdb1a0fa8fe28b29588aa50cf033abddd45a", "searchTerms": [], + "slug": "10-sunplus", "title": "Level 10 (SUN PLUS)", - "where": "chart.level = '10'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5545,9 +6149,10 @@ "inactive": true, "legacyFolderID": "F8936ee78df7499a1d4eaae0ead5099eb2a867b9a8f6ec9c8f6068bd758ebb1d9", "searchTerms": [], + "slug": "10-sun", "title": "Level 10 (SUN)", - "where": "chart.level = '10'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5555,9 +6160,10 @@ "inactive": true, "legacyFolderID": "Ff952cb48eb97a303fdf2a222c85e3003ea09664187bc86b7423c44d19546cbdd", "searchTerms": [], + "slug": "10-verse-intl", "title": "Level 10 (VERSE International)", - "where": "chart.level = '10'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5565,9 +6171,10 @@ "inactive": false, "legacyFolderID": "Fe9b7c052a29d84284a23ed7e6d165983236a80aebf909bd8198ef663f8789a4e", "searchTerms": [], + "slug": "10-verse-omni", "title": "Level 10 (VERSE Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5575,9 +6182,10 @@ "inactive": false, "legacyFolderID": "F9ce9afb30ec683c2d1b27750c98550c9364aad6d7e3f4dd0517eefc00421bd7a", "searchTerms": [], + "slug": "10-verse", "title": "Level 10 (VERSE)", - "where": "chart.level = '10'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5585,9 +6193,10 @@ "inactive": false, "legacyFolderID": "Fb822c03e7c804b4cafa63fdd1748469a8e7f75cf2d87251e6457973622ed0b5f", "searchTerms": [], + "slug": "10-xverse-intl", "title": "Level 10 (X-VERSE International)", - "where": "chart.level = '10'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5595,9 +6204,10 @@ "inactive": false, "legacyFolderID": "Ffe61aa86aef86eea8269c184b63e817cadcab05489f649915e882bdd69c7cd4a", "searchTerms": [], + "slug": "10-xverse-omni", "title": "Level 10 (X-VERSE Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5605,9 +6215,10 @@ "inactive": false, "legacyFolderID": "F1f92fadd45d053f02da059ffef90bfae458c430b9be0f7be1bd98d99a8eaa727", "searchTerms": [], + "slug": "10-xverse", "title": "Level 10 (X-VERSE)", - "where": "chart.level = '10'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '10'" }, { "game": "chunithm", @@ -5615,9 +6226,10 @@ "inactive": true, "legacyFolderID": "F1218170f038fa6dad9ddd699fbea494d44e5dfd8e911c5413a893455422c87a3", "searchTerms": [], + "slug": "10p-luminous-intl", "title": "Level 10+ (LUMINOUS International)", - "where": "chart.level = '10+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5625,9 +6237,10 @@ "inactive": true, "legacyFolderID": "Fc75292c1eeb378f4348a4c3b8997adf91cc7a3dd6be0edd9129d5d148dede0a9", "searchTerms": [], + "slug": "10p-luminous-omni", "title": "Level 10+ (LUMINOUS Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5635,9 +6248,10 @@ "inactive": true, "legacyFolderID": "F9b32ffa90ca2374958d436464ec9c9c32a673908b22ae06d92d73dc3c851c400", "searchTerms": [], + "slug": "10p-luminousplus-intl", "title": "Level 10+ (LUMINOUS PLUS International)", - "where": "chart.level = '10+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5645,9 +6259,10 @@ "inactive": true, "legacyFolderID": "Fec6704b2b24fe1d569f1acf95ee0c2d303201ca4d2710633fb67324feeea97bf", "searchTerms": [], + "slug": "10p-luminousplus-omni", "title": "Level 10+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5655,9 +6270,10 @@ "inactive": true, "legacyFolderID": "F53b47ca1157f1dd495d3421c6555af9024992f80726e5d06a8ae2dac4dd137b9", "searchTerms": [], + "slug": "10p-luminousplus", "title": "Level 10+ (LUMINOUS PLUS)", - "where": "chart.level = '10+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5665,9 +6281,10 @@ "inactive": true, "legacyFolderID": "F5005170f4537eb4aeb68712d2cb5acf99ad2696afca1a0b5b554efaa7bb8dcf6", "searchTerms": [], + "slug": "10p-luminous", "title": "Level 10+ (LUMINOUS)", - "where": "chart.level = '10+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5675,9 +6292,10 @@ "inactive": true, "legacyFolderID": "F1cf51f3c6cd9aa844154e499b52799c35f5e3107f8ba27c4b7e7ef1c0f7c16e3", "searchTerms": [], + "slug": "10p-newplus", "title": "Level 10+ (NEW PLUS)", - "where": "chart.level = '10+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5685,9 +6303,10 @@ "inactive": true, "legacyFolderID": "F6a4008a2435693d9a602ce0567474e4d8e0fbd2296a475bddc59273a297711b9", "searchTerms": [], + "slug": "10p-new", "title": "Level 10+ (NEW)", - "where": "chart.level = '10+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5695,9 +6314,10 @@ "inactive": false, "legacyFolderID": "Fd58eab69698d06c996784cb22440646e1183e2e74d6b7b9d5f9128a875966dc6", "searchTerms": [], + "slug": "10p-paradiselost", "title": "Level 10+ (Paradise Lost)", - "where": "chart.level = '10+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5705,9 +6325,10 @@ "inactive": true, "legacyFolderID": "F9e1a5656702b1bacf406b903b8a3f8158fd2606fb4b2c6601a7f11febdee780f", "searchTerms": [], + "slug": "10p-sun-omni", "title": "Level 10+ (SUN Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5715,9 +6336,10 @@ "inactive": true, "legacyFolderID": "F1686921c6bc85f3821dc0347ddbe14f8d062523bd9578c669be8524729f89187", "searchTerms": [], + "slug": "10p-sunplus-intl", "title": "Level 10+ (SUN PLUS International)", - "where": "chart.level = '10+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5725,9 +6347,10 @@ "inactive": true, "legacyFolderID": "F00c1df0dbad9011cd8317e84da68dd25724be4df5f9b6ce38ad38eb8d03a2d7f", "searchTerms": [], + "slug": "10p-sunplus-omni", "title": "Level 10+ (SUN PLUS Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5735,9 +6358,10 @@ "inactive": true, "legacyFolderID": "Fe6d91d7d7311ada013f17558134bd52433b3fdf24994cfe7dcd995a02127d47a", "searchTerms": [], + "slug": "10p-sunplus", "title": "Level 10+ (SUN PLUS)", - "where": "chart.level = '10+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5745,9 +6369,10 @@ "inactive": true, "legacyFolderID": "F54253c39ace59ebdec44d2320bed3433d824f1300a441f9c202dc310d54d03e3", "searchTerms": [], + "slug": "10p-sun", "title": "Level 10+ (SUN)", - "where": "chart.level = '10+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5755,9 +6380,10 @@ "inactive": true, "legacyFolderID": "F26543bb04b9ce71973d860a5fa77d0045e574c581288775f325e8f9ee8f6d9d2", "searchTerms": [], + "slug": "10p-verse-intl", "title": "Level 10+ (VERSE International)", - "where": "chart.level = '10+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5765,9 +6391,10 @@ "inactive": false, "legacyFolderID": "Ff6e7fd0a13f817ffecd50354485e2cbcaba97f1fe4e7941f8532f90a2c1e8eee", "searchTerms": [], + "slug": "10p-verse-omni", "title": "Level 10+ (VERSE Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5775,9 +6402,10 @@ "inactive": false, "legacyFolderID": "Fa2eef49af6f43a6d8bb676a7542a44150f4156077654c4da69bb5ea597a790c3", "searchTerms": [], + "slug": "10p-verse", "title": "Level 10+ (VERSE)", - "where": "chart.level = '10+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5785,9 +6413,10 @@ "inactive": false, "legacyFolderID": "F250561198285f25db73753fe30f6f85cc4f417c1e238fb2fb82cdc181b9285f3", "searchTerms": [], + "slug": "10p-xverse-intl", "title": "Level 10+ (X-VERSE International)", - "where": "chart.level = '10+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5795,9 +6424,10 @@ "inactive": false, "legacyFolderID": "F88c97aa503fe954bdedb645e8255ffb0af21e7c384e8870a4ab157cba192fd44", "searchTerms": [], + "slug": "10p-xverse-omni", "title": "Level 10+ (X-VERSE Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5805,9 +6435,10 @@ "inactive": false, "legacyFolderID": "F3427c117fc3a0c77297f72e646480576ab6de2d6ee61290ebb348160a69091e2", "searchTerms": [], + "slug": "10p-xverse", "title": "Level 10+ (X-VERSE)", - "where": "chart.level = '10+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '10+'" }, { "game": "chunithm", @@ -5815,9 +6446,10 @@ "inactive": true, "legacyFolderID": "F12cef21470359825840f070b0f7294fe1e988d5b1b647ee9c57238dd7d4d5877", "searchTerms": [], + "slug": "11-luminous-intl", "title": "Level 11 (LUMINOUS International)", - "where": "chart.level = '11'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5825,9 +6457,10 @@ "inactive": true, "legacyFolderID": "F918ce47bf55db8fc35044893ac939fae0086acacc68a67ee484e829911e41ca1", "searchTerms": [], + "slug": "11-luminous-omni", "title": "Level 11 (LUMINOUS Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5835,9 +6468,10 @@ "inactive": true, "legacyFolderID": "Fb4e3433b47bbd96a40946ce34aef065fada071471ee34e994c17c9936768c389", "searchTerms": [], + "slug": "11-luminousplus-intl", "title": "Level 11 (LUMINOUS PLUS International)", - "where": "chart.level = '11'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5845,9 +6479,10 @@ "inactive": true, "legacyFolderID": "Fab4bbdf1da29793bff4597238344a275e0bd1f23c5579e4fb944886067f0511c", "searchTerms": [], + "slug": "11-luminousplus-omni", "title": "Level 11 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5855,9 +6490,10 @@ "inactive": true, "legacyFolderID": "F5ddd7b845ba850f0ef4b1cc87e925d8a58015341c996e9b6f0a5b454f1eb5386", "searchTerms": [], + "slug": "11-luminousplus", "title": "Level 11 (LUMINOUS PLUS)", - "where": "chart.level = '11'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5865,9 +6501,10 @@ "inactive": true, "legacyFolderID": "F82e46a87626ba99a85b484fafde7fdcd8de289c786d11d9a605bb48a989633fc", "searchTerms": [], + "slug": "11-luminous", "title": "Level 11 (LUMINOUS)", - "where": "chart.level = '11'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5875,9 +6512,10 @@ "inactive": true, "legacyFolderID": "F7e109ddc0297586b4e5655cda97c9e7bb50e855631d23cc3d0947c3721d1e382", "searchTerms": [], + "slug": "11-newplus", "title": "Level 11 (NEW PLUS)", - "where": "chart.level = '11'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5885,9 +6523,10 @@ "inactive": true, "legacyFolderID": "Faccdf5461485bf593bf920fa6cd9614838cd9400441342661323a119153e283e", "searchTerms": [], + "slug": "11-new", "title": "Level 11 (NEW)", - "where": "chart.level = '11'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5895,9 +6534,10 @@ "inactive": false, "legacyFolderID": "F4956b5a3f609ee8f61e67c4bb132bfe2334c7cd7289b4b6dfc23d7cbc78223ef", "searchTerms": [], + "slug": "11-paradiselost", "title": "Level 11 (Paradise Lost)", - "where": "chart.level = '11'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5905,9 +6545,10 @@ "inactive": true, "legacyFolderID": "F02d3c632c90080fd5cf423785f0f7a3f8309571a84078102c83001bd1b6fb1af", "searchTerms": [], + "slug": "11-sun-omni", "title": "Level 11 (SUN Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5915,9 +6556,10 @@ "inactive": true, "legacyFolderID": "F79ea343c148ddb2d801753b671337c14571265f67bc3c506036c45d4d985d810", "searchTerms": [], + "slug": "11-sunplus-intl", "title": "Level 11 (SUN PLUS International)", - "where": "chart.level = '11'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5925,9 +6567,10 @@ "inactive": true, "legacyFolderID": "Feb400e8264515d79ce2fa1c48cda20fcca1e79d6d29898122b2e026f62b862dd", "searchTerms": [], + "slug": "11-sunplus-omni", "title": "Level 11 (SUN PLUS Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5935,9 +6578,10 @@ "inactive": true, "legacyFolderID": "F0fc6d57ad73736db488b0c57e0902fcda27cba2798d51a5cdc84346e30d46cc1", "searchTerms": [], + "slug": "11-sunplus", "title": "Level 11 (SUN PLUS)", - "where": "chart.level = '11'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5945,9 +6589,10 @@ "inactive": true, "legacyFolderID": "Fd5f2980e30b5182bf07521cdb24c9c9a9017e790821d02763deff4eb2127ce4f", "searchTerms": [], + "slug": "11-sun", "title": "Level 11 (SUN)", - "where": "chart.level = '11'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5955,9 +6600,10 @@ "inactive": true, "legacyFolderID": "F030ccf2d81b27c1a11f94efa72c7f364da754f0cbab9553ea13a71cb882ef451", "searchTerms": [], + "slug": "11-verse-intl", "title": "Level 11 (VERSE International)", - "where": "chart.level = '11'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5965,9 +6611,10 @@ "inactive": false, "legacyFolderID": "Fe1cd47eb8c63a7d5730613a6ee2a22b19797a26b4dc5f56cb262bb3623f179d0", "searchTerms": [], + "slug": "11-verse-omni", "title": "Level 11 (VERSE Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5975,9 +6622,10 @@ "inactive": false, "legacyFolderID": "F633fb332687955d9d1491ae0e423b52d24d17bc94902d4e11b9cf4dfd2a5f86c", "searchTerms": [], + "slug": "11-verse", "title": "Level 11 (VERSE)", - "where": "chart.level = '11'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5985,9 +6633,10 @@ "inactive": false, "legacyFolderID": "Ffeb20fd25b71cda5f9de9006c504e6cb762474387f4d6769df87cf631a3edb0c", "searchTerms": [], + "slug": "11-xverse-intl", "title": "Level 11 (X-VERSE International)", - "where": "chart.level = '11'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -5995,9 +6644,10 @@ "inactive": false, "legacyFolderID": "F2e27a1b7cddb18fc51f2fac5823b62b16da16aa9096430cf412e09f0bb9bfbfa", "searchTerms": [], + "slug": "11-xverse-omni", "title": "Level 11 (X-VERSE Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -6005,9 +6655,10 @@ "inactive": false, "legacyFolderID": "Fa27715dd78044592f849dcebe62cbcdcc7705135bff3800118e8e597fb4a2209", "searchTerms": [], + "slug": "11-xverse", "title": "Level 11 (X-VERSE)", - "where": "chart.level = '11'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '11'" }, { "game": "chunithm", @@ -6015,9 +6666,10 @@ "inactive": true, "legacyFolderID": "F9a7e4eab2a0468c337076a1e27ddfaf0df380c6703404161bf3a704add772da6", "searchTerms": [], + "slug": "11p-luminous-intl", "title": "Level 11+ (LUMINOUS International)", - "where": "chart.level = '11+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6025,9 +6677,10 @@ "inactive": true, "legacyFolderID": "F81e9d0b0bb4d8a43560768742dda81b17bca2cb4b8cb95dbec7b6e49e0955222", "searchTerms": [], + "slug": "11p-luminous-omni", "title": "Level 11+ (LUMINOUS Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6035,9 +6688,10 @@ "inactive": true, "legacyFolderID": "F1e102f168e0f04c17f451411735570651e11e6e2b0686545c7c37b143c043896", "searchTerms": [], + "slug": "11p-luminousplus-intl", "title": "Level 11+ (LUMINOUS PLUS International)", - "where": "chart.level = '11+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6045,9 +6699,10 @@ "inactive": true, "legacyFolderID": "Fec2be89847b17b47dde2a2fb3ddeb6768df17198ae73280b940048ee9fd3d880", "searchTerms": [], + "slug": "11p-luminousplus-omni", "title": "Level 11+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6055,9 +6710,10 @@ "inactive": true, "legacyFolderID": "F2d8f570c83f92608598422094b58f614fea4c42524fb65b534d931f9940ff29f", "searchTerms": [], + "slug": "11p-luminousplus", "title": "Level 11+ (LUMINOUS PLUS)", - "where": "chart.level = '11+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6065,9 +6721,10 @@ "inactive": true, "legacyFolderID": "F25fe7023fb30ae2a400ef9cca5300fc9db11bee396936da41f2009ffa9e812fd", "searchTerms": [], + "slug": "11p-luminous", "title": "Level 11+ (LUMINOUS)", - "where": "chart.level = '11+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6075,9 +6732,10 @@ "inactive": true, "legacyFolderID": "F20a04e95f1671afb3c778d6cebba88febaf01a87f1a18d4a5857298d84483660", "searchTerms": [], + "slug": "11p-newplus", "title": "Level 11+ (NEW PLUS)", - "where": "chart.level = '11+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6085,9 +6743,10 @@ "inactive": true, "legacyFolderID": "F304021d9cd7cb629eaffde14209f612e49b79bfed16011ec74882558b65c223a", "searchTerms": [], + "slug": "11p-new", "title": "Level 11+ (NEW)", - "where": "chart.level = '11+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6095,9 +6754,10 @@ "inactive": false, "legacyFolderID": "F61ba8a4fdb8d4a0b4b76ead9ce1bdf6b44e14b36c14cc238f451017e3b301779", "searchTerms": [], + "slug": "11p-paradiselost", "title": "Level 11+ (Paradise Lost)", - "where": "chart.level = '11+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6105,9 +6765,10 @@ "inactive": true, "legacyFolderID": "Fc0976284ff39ed167af7f32e7ca1f9c1dc70b11befca05de4c64c3a730dcbfb6", "searchTerms": [], + "slug": "11p-sun-omni", "title": "Level 11+ (SUN Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6115,9 +6776,10 @@ "inactive": true, "legacyFolderID": "Fefee10da3a0d56db2aba704e0bb9c9f1d5cc8cb1383d70b0e82cacc8aecd792d", "searchTerms": [], + "slug": "11p-sunplus-intl", "title": "Level 11+ (SUN PLUS International)", - "where": "chart.level = '11+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6125,9 +6787,10 @@ "inactive": true, "legacyFolderID": "F2b0443f402a13b21ba8b92b9b51c31e49afbbbc0c30544c2ab0de53ecb65f4a8", "searchTerms": [], + "slug": "11p-sunplus-omni", "title": "Level 11+ (SUN PLUS Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6135,9 +6798,10 @@ "inactive": true, "legacyFolderID": "Fde0e3c03575b5bb001ae0f1d3a540ae6c06785c0dfbda233148aaa442101fc66", "searchTerms": [], + "slug": "11p-sunplus", "title": "Level 11+ (SUN PLUS)", - "where": "chart.level = '11+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6145,9 +6809,10 @@ "inactive": true, "legacyFolderID": "F4f403ef963db137b186438dc81bb133857abc73843a2157a60ffc2aab88cc4b7", "searchTerms": [], + "slug": "11p-sun", "title": "Level 11+ (SUN)", - "where": "chart.level = '11+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6155,9 +6820,10 @@ "inactive": true, "legacyFolderID": "F98e70ef40500aa3eb24bb5fa8b20540495ab4feb72cb420f4918f459daf973a2", "searchTerms": [], + "slug": "11p-verse-intl", "title": "Level 11+ (VERSE International)", - "where": "chart.level = '11+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6165,9 +6831,10 @@ "inactive": false, "legacyFolderID": "F90924b2c0f7d131ff95f579ce669fdb0bcf838bffa8bd85e18c3d035f45d24c4", "searchTerms": [], + "slug": "11p-verse-omni", "title": "Level 11+ (VERSE Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6175,9 +6842,10 @@ "inactive": false, "legacyFolderID": "F3efaf1bd5717a9100f621b008f2ce66ece8d4999b32b807762b0d4d768e30c5e", "searchTerms": [], + "slug": "11p-verse", "title": "Level 11+ (VERSE)", - "where": "chart.level = '11+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6185,9 +6853,10 @@ "inactive": false, "legacyFolderID": "F5c6deed5a11553470e27a8d2ec40de7af7fe37e3865a9ad2e40e051f93eb354c", "searchTerms": [], + "slug": "11p-xverse-intl", "title": "Level 11+ (X-VERSE International)", - "where": "chart.level = '11+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6195,9 +6864,10 @@ "inactive": false, "legacyFolderID": "Ffee9110d161fa009c01d9345ced29b250b3ba19b8a0290e87a0387a00e576b41", "searchTerms": [], + "slug": "11p-xverse-omni", "title": "Level 11+ (X-VERSE Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6205,9 +6875,10 @@ "inactive": false, "legacyFolderID": "F8904dc7b23bb9a3e00e02c7c85ca77db7554240b240da394862ea5d073b5001f", "searchTerms": [], + "slug": "11p-xverse", "title": "Level 11+ (X-VERSE)", - "where": "chart.level = '11+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '11+'" }, { "game": "chunithm", @@ -6215,9 +6886,10 @@ "inactive": true, "legacyFolderID": "Fb8cf6694e19a625caf1818a1e54b14fcd832586815c28d24041c472bb42948e1", "searchTerms": [], + "slug": "12-luminous-intl", "title": "Level 12 (LUMINOUS International)", - "where": "chart.level = '12'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6225,9 +6897,10 @@ "inactive": true, "legacyFolderID": "F6d211f506693f8d1837ff4ae91410845bb2c7fd0e9e3dfb27d7aa60976c9441c", "searchTerms": [], + "slug": "12-luminous-omni", "title": "Level 12 (LUMINOUS Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6235,9 +6908,10 @@ "inactive": true, "legacyFolderID": "F3947fa3ab08a26aacae7dedf60416d9ff4a477f406bbe983ab8478b7b39f544e", "searchTerms": [], + "slug": "12-luminousplus-intl", "title": "Level 12 (LUMINOUS PLUS International)", - "where": "chart.level = '12'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6245,9 +6919,10 @@ "inactive": true, "legacyFolderID": "F81a94a399f82c60ab2b63401b2cd904e9835e5b69a865a4414b0e5bf3d324541", "searchTerms": [], + "slug": "12-luminousplus-omni", "title": "Level 12 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6255,9 +6930,10 @@ "inactive": true, "legacyFolderID": "Fd3f59b425532d36e22ccf0a236c115953b103830644eb7a6ec515a48c35c9f85", "searchTerms": [], + "slug": "12-luminousplus", "title": "Level 12 (LUMINOUS PLUS)", - "where": "chart.level = '12'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6265,9 +6941,10 @@ "inactive": true, "legacyFolderID": "F50af106c804cae8655b64716baeb9d23ccc2866b940329a82a8e26ae90e9f1c4", "searchTerms": [], + "slug": "12-luminous", "title": "Level 12 (LUMINOUS)", - "where": "chart.level = '12'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6275,9 +6952,10 @@ "inactive": true, "legacyFolderID": "F397c2ebf9575ea38323bc27fe645f2bd2f3571808d90b0644059e9789a37e55d", "searchTerms": [], + "slug": "12-newplus", "title": "Level 12 (NEW PLUS)", - "where": "chart.level = '12'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6285,9 +6963,10 @@ "inactive": true, "legacyFolderID": "F17bc876c83c633877b2bc78bf43545a8f99cd9d16eabf944831485a8cf326404", "searchTerms": [], + "slug": "12-new", "title": "Level 12 (NEW)", - "where": "chart.level = '12'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6295,9 +6974,10 @@ "inactive": false, "legacyFolderID": "Fd84984975ceafd2636d334c8090863eeda7fac95ab09a2a839b5e730ccff8183", "searchTerms": [], + "slug": "12-paradiselost", "title": "Level 12 (Paradise Lost)", - "where": "chart.level = '12'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6305,9 +6985,10 @@ "inactive": true, "legacyFolderID": "F2d5c0c4600016f84cebd98372400dc7cc7033a3ec9c64398d115db10b6fbeb5d", "searchTerms": [], + "slug": "12-sun-omni", "title": "Level 12 (SUN Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6315,9 +6996,10 @@ "inactive": true, "legacyFolderID": "F883cabbb93cda209f78c57f447d1eecc10b7694a72ed7cdbf99c2321713bc4b2", "searchTerms": [], + "slug": "12-sunplus-intl", "title": "Level 12 (SUN PLUS International)", - "where": "chart.level = '12'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6325,9 +7007,10 @@ "inactive": true, "legacyFolderID": "F5f6f322352805b98be9b8c4dc43d49aaa2cb55fb2444997f939113da3358d870", "searchTerms": [], + "slug": "12-sunplus-omni", "title": "Level 12 (SUN PLUS Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6335,9 +7018,10 @@ "inactive": true, "legacyFolderID": "Fd7a7ebdb5c126a8957e59948f12ba481f784613e7b590db9d40befe3a1660202", "searchTerms": [], + "slug": "12-sunplus", "title": "Level 12 (SUN PLUS)", - "where": "chart.level = '12'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6345,9 +7029,10 @@ "inactive": true, "legacyFolderID": "Fd0e1a20941cb21a4e289bdeed849de2f772d0e00a8f0e8226ef3b96eb7c9c813", "searchTerms": [], + "slug": "12-sun", "title": "Level 12 (SUN)", - "where": "chart.level = '12'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6355,9 +7040,10 @@ "inactive": true, "legacyFolderID": "Fce00d38d8277ab601fac1e34c83f5058c98d6bc4f0ffc22dcd477877345d7b45", "searchTerms": [], + "slug": "12-verse-intl", "title": "Level 12 (VERSE International)", - "where": "chart.level = '12'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6365,9 +7051,10 @@ "inactive": false, "legacyFolderID": "Faebfa7e985a6f8e7f25ec43c1ed2fe85b4874df4611ba8859f6941f595b5d485", "searchTerms": [], + "slug": "12-verse-omni", "title": "Level 12 (VERSE Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6375,9 +7062,10 @@ "inactive": false, "legacyFolderID": "Ffc48aacabe9867e330d482dddc554b2c4d018141d93939dee98cad40b2185e1f", "searchTerms": [], + "slug": "12-verse", "title": "Level 12 (VERSE)", - "where": "chart.level = '12'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6385,9 +7073,10 @@ "inactive": false, "legacyFolderID": "Fd58e13b6bf2dd2024555112cac321e893757b8880fe8c24f35d2e3367800930b", "searchTerms": [], + "slug": "12-xverse-intl", "title": "Level 12 (X-VERSE International)", - "where": "chart.level = '12'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6395,9 +7084,10 @@ "inactive": false, "legacyFolderID": "F74e34a65b8067265eb6372ac22bfc47506db69a0d4d55aaaf7bd607bf04fbf57", "searchTerms": [], + "slug": "12-xverse-omni", "title": "Level 12 (X-VERSE Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6405,9 +7095,10 @@ "inactive": false, "legacyFolderID": "Fdb652b6a45268f1ecdbd64eb2c0ef59d3720dc2d39440a265cd02d424fb2cafd", "searchTerms": [], + "slug": "12-xverse", "title": "Level 12 (X-VERSE)", - "where": "chart.level = '12'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '12'" }, { "game": "chunithm", @@ -6415,9 +7106,10 @@ "inactive": true, "legacyFolderID": "F9249f3271348ba22cefb9367281113051124537bf269a4e09ed04c09c79d6ea2", "searchTerms": [], + "slug": "12p-luminous-intl", "title": "Level 12+ (LUMINOUS International)", - "where": "chart.level = '12+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6425,9 +7117,10 @@ "inactive": true, "legacyFolderID": "Fba3e91767658aa8bfb476c2cd99abbe4bf006d89957da70deb924871d81b3b41", "searchTerms": [], + "slug": "12p-luminous-omni", "title": "Level 12+ (LUMINOUS Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6435,9 +7128,10 @@ "inactive": true, "legacyFolderID": "Fad46bc10c51f6f21f41b440e3e560d14010dbfee5784fac7a0dc40f98ca2e50d", "searchTerms": [], + "slug": "12p-luminousplus-intl", "title": "Level 12+ (LUMINOUS PLUS International)", - "where": "chart.level = '12+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6445,9 +7139,10 @@ "inactive": true, "legacyFolderID": "F1b09424d71b2534437846a6bc39d9d449a34a39d3550c70197df269ef4e87557", "searchTerms": [], + "slug": "12p-luminousplus-omni", "title": "Level 12+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6455,9 +7150,10 @@ "inactive": true, "legacyFolderID": "F10d77863f08278032e97b85d7ed59345b8b3f568c404b3c288fdd78956a19f8c", "searchTerms": [], + "slug": "12p-luminousplus", "title": "Level 12+ (LUMINOUS PLUS)", - "where": "chart.level = '12+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6465,9 +7161,10 @@ "inactive": true, "legacyFolderID": "F0dcd2d1a882a8ff297b37c9a2a81acec7cae06bf098affd55724d44342ec3db2", "searchTerms": [], + "slug": "12p-luminous", "title": "Level 12+ (LUMINOUS)", - "where": "chart.level = '12+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6475,9 +7172,10 @@ "inactive": true, "legacyFolderID": "Fecd00e18b8c34e3808d221cd104434fdaef7748e1e99ff4cce8199d7bef2cabd", "searchTerms": [], + "slug": "12p-newplus", "title": "Level 12+ (NEW PLUS)", - "where": "chart.level = '12+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6485,9 +7183,10 @@ "inactive": true, "legacyFolderID": "F5a3d8f288c3b1f69ae03fbfaa9e63a58ab99b93e96a7a464f5b035765fe4085b", "searchTerms": [], + "slug": "12p-new", "title": "Level 12+ (NEW)", - "where": "chart.level = '12+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6495,9 +7194,10 @@ "inactive": false, "legacyFolderID": "F40ef585b0f469755f094caa3a8dd376bb6f75ef40d1f80acaa001ee573cb446c", "searchTerms": [], + "slug": "12p-paradiselost", "title": "Level 12+ (Paradise Lost)", - "where": "chart.level = '12+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6505,9 +7205,10 @@ "inactive": true, "legacyFolderID": "Fd2c6dab740da557b59260f9e06d519f84715c41382bf03a3609c8982283a0fc8", "searchTerms": [], + "slug": "12p-sun-omni", "title": "Level 12+ (SUN Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6515,9 +7216,10 @@ "inactive": true, "legacyFolderID": "F4d7672b6c11e8dcc44ab0871cbc4b0e506b06655451656bedcc9517c554faed2", "searchTerms": [], + "slug": "12p-sunplus-intl", "title": "Level 12+ (SUN PLUS International)", - "where": "chart.level = '12+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6525,9 +7227,10 @@ "inactive": true, "legacyFolderID": "Ff927fd7057244e772f5665d16afcad0a5ce2ff8d718b1080c1f5ecfb13b8a634", "searchTerms": [], + "slug": "12p-sunplus-omni", "title": "Level 12+ (SUN PLUS Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6535,9 +7238,10 @@ "inactive": true, "legacyFolderID": "F6f7b06aaec4d47a00ba9bd9623b50d66328ab590c60dbd1dfd0f2d10ec55c9ad", "searchTerms": [], + "slug": "12p-sunplus", "title": "Level 12+ (SUN PLUS)", - "where": "chart.level = '12+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6545,9 +7249,10 @@ "inactive": true, "legacyFolderID": "F9d8fc9832871db9cc6e0f1c55d3c2f832dd20433cacc8181073b20e133c0c935", "searchTerms": [], + "slug": "12p-sun", "title": "Level 12+ (SUN)", - "where": "chart.level = '12+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6555,9 +7260,10 @@ "inactive": true, "legacyFolderID": "Fee640e7a4b0f5c4c7aaa5eff2288655096ef18f27937848de3ce48859c90c9c5", "searchTerms": [], + "slug": "12p-verse-intl", "title": "Level 12+ (VERSE International)", - "where": "chart.level = '12+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6565,9 +7271,10 @@ "inactive": false, "legacyFolderID": "F1d3f2a455d9f37adc81fa15701efa1cdbc25e716f8fe94acb162e945b2e46478", "searchTerms": [], + "slug": "12p-verse-omni", "title": "Level 12+ (VERSE Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6575,9 +7282,10 @@ "inactive": false, "legacyFolderID": "Fa4d0dde1a2177112db95e0a726a677d5672f8e354a46e8a639265a872d3518be", "searchTerms": [], + "slug": "12p-verse", "title": "Level 12+ (VERSE)", - "where": "chart.level = '12+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6585,9 +7293,10 @@ "inactive": false, "legacyFolderID": "F99dfede2929d518e0f83f7bd7530cf486f2d24fab9aaf931140c8e8a143f5ae7", "searchTerms": [], + "slug": "12p-xverse-intl", "title": "Level 12+ (X-VERSE International)", - "where": "chart.level = '12+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6595,9 +7304,10 @@ "inactive": false, "legacyFolderID": "F25bc85d7a459a8d965a1f2e172470afe079bbc5334cb40aabfadb74923f16ad8", "searchTerms": [], + "slug": "12p-xverse-omni", "title": "Level 12+ (X-VERSE Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6605,9 +7315,10 @@ "inactive": false, "legacyFolderID": "Fb61d80c8da317a07ae548a04b354c9938d3ae3f61f7e327f826bb8386632032e", "searchTerms": [], + "slug": "12p-xverse", "title": "Level 12+ (X-VERSE)", - "where": "chart.level = '12+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '12+'" }, { "game": "chunithm", @@ -6615,9 +7326,10 @@ "inactive": true, "legacyFolderID": "F8266c80d7aa663d788146b0398d5a2b645d6aaf406d1c42cbe35c9aa8091ea98", "searchTerms": [], + "slug": "13-luminous-intl", "title": "Level 13 (LUMINOUS International)", - "where": "chart.level = '13'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6625,9 +7337,10 @@ "inactive": true, "legacyFolderID": "Fc1dfbc75b1e9f736d97b02af403e3bd1752474b0851b09a62da2951181a56b1f", "searchTerms": [], + "slug": "13-luminous-omni", "title": "Level 13 (LUMINOUS Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6635,9 +7348,10 @@ "inactive": true, "legacyFolderID": "F757860688134ba74a0695124c40d8965633365c5e37f171e1031a1566af1c04c", "searchTerms": [], + "slug": "13-luminousplus-intl", "title": "Level 13 (LUMINOUS PLUS International)", - "where": "chart.level = '13'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6645,9 +7359,10 @@ "inactive": true, "legacyFolderID": "Fff10bddd4fa79f4df04b0b56897cc5254317da53e58d6607cc593deb6ef49381", "searchTerms": [], + "slug": "13-luminousplus-omni", "title": "Level 13 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6655,9 +7370,10 @@ "inactive": true, "legacyFolderID": "F7b452f2dbf7db460a5a70ee2cbe7fc9fb533ff71ea3e1387681a069b34fa7f3c", "searchTerms": [], + "slug": "13-luminousplus", "title": "Level 13 (LUMINOUS PLUS)", - "where": "chart.level = '13'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6665,9 +7381,10 @@ "inactive": true, "legacyFolderID": "F305e2c7b26bbc1c60aa21c383b9eca8dd2934af6de4ad8ed23d3b5825201ee92", "searchTerms": [], + "slug": "13-luminous", "title": "Level 13 (LUMINOUS)", - "where": "chart.level = '13'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6675,9 +7392,10 @@ "inactive": true, "legacyFolderID": "F5bfc710e50887cec42f28fae695e917c5c313cc247b1531d241302623a7a85d6", "searchTerms": [], + "slug": "13-newplus", "title": "Level 13 (NEW PLUS)", - "where": "chart.level = '13'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6685,9 +7403,10 @@ "inactive": true, "legacyFolderID": "Fda006534224b1d87b8f1cb869c3fd5ecc5e9e657ea4854d1cf0bf66f2822effb", "searchTerms": [], + "slug": "13-new", "title": "Level 13 (NEW)", - "where": "chart.level = '13'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6695,9 +7414,10 @@ "inactive": false, "legacyFolderID": "Ff39a784a78c5391a8286267039bff61c04253abab3adfecbce833acb1707c030", "searchTerms": [], + "slug": "13-paradiselost", "title": "Level 13 (Paradise Lost)", - "where": "chart.level = '13'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6705,9 +7425,10 @@ "inactive": true, "legacyFolderID": "F8f6bc81e6d27023cfcac950c22653fbbda48503d90545802f9f6d56a800a1733", "searchTerms": [], + "slug": "13-sun-omni", "title": "Level 13 (SUN Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6715,9 +7436,10 @@ "inactive": true, "legacyFolderID": "Fb4203fb180969fa05e2f029ac17bd42dea25af18704830420cc7125de5da2942", "searchTerms": [], + "slug": "13-sunplus-intl", "title": "Level 13 (SUN PLUS International)", - "where": "chart.level = '13'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6725,9 +7447,10 @@ "inactive": true, "legacyFolderID": "F608c6c46be75234bfc68b971d2074c03095c01258572bb801a8607a3def1be36", "searchTerms": [], + "slug": "13-sunplus-omni", "title": "Level 13 (SUN PLUS Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6735,9 +7458,10 @@ "inactive": true, "legacyFolderID": "Fb23eac691300691f4a57806d20885a730b84050c0b0c5f7220d97429fbcb9b2a", "searchTerms": [], + "slug": "13-sunplus", "title": "Level 13 (SUN PLUS)", - "where": "chart.level = '13'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6745,9 +7469,10 @@ "inactive": true, "legacyFolderID": "Fd4ee105ce52bd11e9488fd80acae8ffdc20bdb4803e3095e1e9104c91a61332a", "searchTerms": [], + "slug": "13-sun", "title": "Level 13 (SUN)", - "where": "chart.level = '13'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6755,9 +7480,10 @@ "inactive": true, "legacyFolderID": "F1f301b905c32f302835eb3f6d79ab9eb319bd769aebaf8cba5ce33f2aa93648b", "searchTerms": [], + "slug": "13-verse-intl", "title": "Level 13 (VERSE International)", - "where": "chart.level = '13'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6765,9 +7491,10 @@ "inactive": false, "legacyFolderID": "Ff766fa43208aaf525c8be9a3b20d38e0bec61b51971c79f5e56e75a25b6756aa", "searchTerms": [], + "slug": "13-verse-omni", "title": "Level 13 (VERSE Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6775,9 +7502,10 @@ "inactive": false, "legacyFolderID": "Fafd97d37b04893445ab963e0abf9b3e31212545aa2d4f1e5697c2cc07d1eecaf", "searchTerms": [], + "slug": "13-verse", "title": "Level 13 (VERSE)", - "where": "chart.level = '13'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6785,9 +7513,10 @@ "inactive": false, "legacyFolderID": "F2534cfa94bc86823605828ab249738f4da2ee467b9a5f4da9726527ea4268db0", "searchTerms": [], + "slug": "13-xverse-intl", "title": "Level 13 (X-VERSE International)", - "where": "chart.level = '13'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6795,9 +7524,10 @@ "inactive": false, "legacyFolderID": "F5baf2261e0dd1951a23b829904e64b3432873d154f61997f7008bbff2c5ff2bd", "searchTerms": [], + "slug": "13-xverse-omni", "title": "Level 13 (X-VERSE Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6805,9 +7535,10 @@ "inactive": false, "legacyFolderID": "F15084c19e21332976e1f5faa8b6c2c94aeb8d37b9cc2880dd4828be5895e7b76", "searchTerms": [], + "slug": "13-xverse", "title": "Level 13 (X-VERSE)", - "where": "chart.level = '13'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '13'" }, { "game": "chunithm", @@ -6815,9 +7546,10 @@ "inactive": true, "legacyFolderID": "Facdb4143037ccfcc2afb57a62f96ab35220f201c454175bd1bdfe5d0418edd3f", "searchTerms": [], + "slug": "13p-luminous-intl", "title": "Level 13+ (LUMINOUS International)", - "where": "chart.level = '13+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6825,9 +7557,10 @@ "inactive": true, "legacyFolderID": "F61ca1fc711e7a3b31f687cea456fba37119ce036e15e4334fdfe5d3e1c634254", "searchTerms": [], + "slug": "13p-luminous-omni", "title": "Level 13+ (LUMINOUS Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6835,9 +7568,10 @@ "inactive": true, "legacyFolderID": "F999fb8ce60a68018725a30aa01ab988620fd4cc294e3fedc265fa4729cfb3239", "searchTerms": [], + "slug": "13p-luminousplus-intl", "title": "Level 13+ (LUMINOUS PLUS International)", - "where": "chart.level = '13+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6845,9 +7579,10 @@ "inactive": true, "legacyFolderID": "F8cd01aee1c8285b109a59499817bd229d7188f2fcc69e7e66511f1eeea2e225e", "searchTerms": [], + "slug": "13p-luminousplus-omni", "title": "Level 13+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6855,9 +7590,10 @@ "inactive": true, "legacyFolderID": "F4b28a9e418a4636652551dc974e80dee5df2536a7e4b7f63d7f81665be8b5274", "searchTerms": [], + "slug": "13p-luminousplus", "title": "Level 13+ (LUMINOUS PLUS)", - "where": "chart.level = '13+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6865,9 +7601,10 @@ "inactive": true, "legacyFolderID": "F91a3dfe0c0c43740095dc5a4d4881c3faa6b7075e969a7ccceacba56039b7b14", "searchTerms": [], + "slug": "13p-luminous", "title": "Level 13+ (LUMINOUS)", - "where": "chart.level = '13+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6875,9 +7612,10 @@ "inactive": true, "legacyFolderID": "Fcedf902212bfa0b9fa5e0687d2e6f4a0657554de42db7430368069851f030072", "searchTerms": [], + "slug": "13p-newplus", "title": "Level 13+ (NEW PLUS)", - "where": "chart.level = '13+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6885,9 +7623,10 @@ "inactive": true, "legacyFolderID": "F74940aa991fa7099b35ad512d79fa9c000cd3468d89ee80ca293be273672c5f3", "searchTerms": [], + "slug": "13p-new", "title": "Level 13+ (NEW)", - "where": "chart.level = '13+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6895,9 +7634,10 @@ "inactive": false, "legacyFolderID": "F4e788bbf78710a3972fec8fbd0029bccc6c166381a2667a3b2a26c025da6e286", "searchTerms": [], + "slug": "13p-paradiselost", "title": "Level 13+ (Paradise Lost)", - "where": "chart.level = '13+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6905,9 +7645,10 @@ "inactive": true, "legacyFolderID": "F006061d48893b76c0f8c68919639c7d77aa4588c551409375bff885acca1542f", "searchTerms": [], + "slug": "13p-sun-omni", "title": "Level 13+ (SUN Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6915,9 +7656,10 @@ "inactive": true, "legacyFolderID": "F4037c32849d28b13ccbbe0dd6be243c8cd14812f1ffbd934954c647930b331ac", "searchTerms": [], + "slug": "13p-sunplus-intl", "title": "Level 13+ (SUN PLUS International)", - "where": "chart.level = '13+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6925,9 +7667,10 @@ "inactive": true, "legacyFolderID": "F0cc1aaa34aed35ff7997c5440596447ecdc8f5fbb63c0c32d74db4e1b12f127f", "searchTerms": [], + "slug": "13p-sunplus-omni", "title": "Level 13+ (SUN PLUS Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6935,9 +7678,10 @@ "inactive": true, "legacyFolderID": "F70a443abc6611e00738097e41f90e99f15a8f65beaf0fa5bf284843ea317053c", "searchTerms": [], + "slug": "13p-sunplus", "title": "Level 13+ (SUN PLUS)", - "where": "chart.level = '13+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6945,9 +7689,10 @@ "inactive": true, "legacyFolderID": "Fc9e606ce11b1bc05540edfc5c73a4d889c8ca77e356d8f5868ab2bcef637f062", "searchTerms": [], + "slug": "13p-sun", "title": "Level 13+ (SUN)", - "where": "chart.level = '13+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6955,9 +7700,10 @@ "inactive": true, "legacyFolderID": "F0d83f9bc3c12c1dfeac9356658eda0bf9a2d9115a1aaa7c66409be1377af0620", "searchTerms": [], + "slug": "13p-verse-intl", "title": "Level 13+ (VERSE International)", - "where": "chart.level = '13+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6965,9 +7711,10 @@ "inactive": false, "legacyFolderID": "F9900795caec964cf16646e0286598dea5cf9f6e95e8bcf11b5f28539e00dd6a5", "searchTerms": [], + "slug": "13p-verse-omni", "title": "Level 13+ (VERSE Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6975,9 +7722,10 @@ "inactive": false, "legacyFolderID": "F9888445c0ef83aaf87b594942d4fcc5ced8034f071a026c5106d47cb8b4bb335", "searchTerms": [], + "slug": "13p-verse", "title": "Level 13+ (VERSE)", - "where": "chart.level = '13+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6985,9 +7733,10 @@ "inactive": false, "legacyFolderID": "F029015766d2bbe9af95f7421546fe6ceca070c81600999ab1d93905f1de8f494", "searchTerms": [], + "slug": "13p-xverse-intl", "title": "Level 13+ (X-VERSE International)", - "where": "chart.level = '13+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -6995,9 +7744,10 @@ "inactive": false, "legacyFolderID": "F4b84a2f010cbc4a5d4ea86ad8bc3d5044bded033886c4880e834d95d689077f3", "searchTerms": [], + "slug": "13p-xverse-omni", "title": "Level 13+ (X-VERSE Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -7005,9 +7755,10 @@ "inactive": false, "legacyFolderID": "F4edb32d69ea6044883da052dd8fdb79880b5145d152c3467b949c7e76326532f", "searchTerms": [], + "slug": "13p-xverse", "title": "Level 13+ (X-VERSE)", - "where": "chart.level = '13+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '13+'" }, { "game": "chunithm", @@ -7015,9 +7766,10 @@ "inactive": true, "legacyFolderID": "F00c78eb77972151749c4352b97b310c5eab807a890a333f2ce3957c6bc7dd552", "searchTerms": [], + "slug": "14-luminous-intl", "title": "Level 14 (LUMINOUS International)", - "where": "chart.level = '14'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7025,9 +7777,10 @@ "inactive": true, "legacyFolderID": "F83d1316896cb7acf36aefe5458e9269dbe4d415fc50224cdabe68f3cd40e8756", "searchTerms": [], + "slug": "14-luminous-omni", "title": "Level 14 (LUMINOUS Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7035,9 +7788,10 @@ "inactive": true, "legacyFolderID": "F7aae6c5575dda555ad16a857d9fbc57a5ce70629ed5db357534caba068642bce", "searchTerms": [], + "slug": "14-luminousplus-intl", "title": "Level 14 (LUMINOUS PLUS International)", - "where": "chart.level = '14'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7045,9 +7799,10 @@ "inactive": true, "legacyFolderID": "F89d71f6616a90fde8636b1c6ae71fdd98534ee9b40f59c08bfb9933fcc32badc", "searchTerms": [], + "slug": "14-luminousplus-omni", "title": "Level 14 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7055,9 +7810,10 @@ "inactive": true, "legacyFolderID": "Fabf60d5b531ddca17b8783bed9280e17e9140b9cb98495624893bef2569cbddc", "searchTerms": [], + "slug": "14-luminousplus", "title": "Level 14 (LUMINOUS PLUS)", - "where": "chart.level = '14'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7065,9 +7821,10 @@ "inactive": true, "legacyFolderID": "Fff5733595eaec1e0da26a8c32d083735a96f4c4650858a470df5a599ebc13bca", "searchTerms": [], + "slug": "14-luminous", "title": "Level 14 (LUMINOUS)", - "where": "chart.level = '14'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7075,9 +7832,10 @@ "inactive": true, "legacyFolderID": "Ff879b4e964eb2abb469bbb22b82434e52791b7e5ee792da27f29ffcb4b152f5a", "searchTerms": [], + "slug": "14-newplus", "title": "Level 14 (NEW PLUS)", - "where": "chart.level = '14'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7085,9 +7843,10 @@ "inactive": true, "legacyFolderID": "Fb86f5c491b769ac33025727e907713fd6866b0ec2b0142f0a0257580148ffa38", "searchTerms": [], + "slug": "14-new", "title": "Level 14 (NEW)", - "where": "chart.level = '14'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7095,9 +7854,10 @@ "inactive": false, "legacyFolderID": "F59b31a7f803e9dfa228b7ad22a6788a4736ccbdd276a0ec62a330643395c00dd", "searchTerms": [], + "slug": "14-paradiselost", "title": "Level 14 (Paradise Lost)", - "where": "chart.level = '14'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7105,9 +7865,10 @@ "inactive": true, "legacyFolderID": "F18426b36571b8ae93e0264ff4f81f35003fc7077323ba265514e7c7a546112f2", "searchTerms": [], + "slug": "14-sun-omni", "title": "Level 14 (SUN Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7115,9 +7876,10 @@ "inactive": true, "legacyFolderID": "F84dff6b29d8118085b2fdc39fe46ee685e32b9012a736e6daf4491b59e1ddbd3", "searchTerms": [], + "slug": "14-sunplus-intl", "title": "Level 14 (SUN PLUS International)", - "where": "chart.level = '14'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7125,9 +7887,10 @@ "inactive": true, "legacyFolderID": "F42fa6d6d2d92218397615c8ef4cf565076bdf5142f1dc868e12cb9d4849eca1a", "searchTerms": [], + "slug": "14-sunplus-omni", "title": "Level 14 (SUN PLUS Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7135,9 +7898,10 @@ "inactive": true, "legacyFolderID": "F7dc2f23552539ed34b812f475de7650dab3eaef88a36b737b08dad9d582a90d0", "searchTerms": [], + "slug": "14-sunplus", "title": "Level 14 (SUN PLUS)", - "where": "chart.level = '14'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7145,9 +7909,10 @@ "inactive": true, "legacyFolderID": "F2e39bdd8b9adf7c96f934f4db3c35f388ec5c52b0005b2b54f7463dcd328bb31", "searchTerms": [], + "slug": "14-sun", "title": "Level 14 (SUN)", - "where": "chart.level = '14'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7155,9 +7920,10 @@ "inactive": true, "legacyFolderID": "Fe460cfa2b20628c2c18a5e49e0c67b003d4a132e48f61ca702cdcecc7cf3d5c6", "searchTerms": [], + "slug": "14-verse-intl", "title": "Level 14 (VERSE International)", - "where": "chart.level = '14'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7165,9 +7931,10 @@ "inactive": false, "legacyFolderID": "F6e6c14ffff411a691681994c38e507f4f82d4ef5c6068fc08bec47fd4a1b9a0a", "searchTerms": [], + "slug": "14-verse-omni", "title": "Level 14 (VERSE Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7175,9 +7942,10 @@ "inactive": false, "legacyFolderID": "F0f224467c1b1ac0e577b2d21046f8785cbea3cbc22b9c5487f53d9353a88f802", "searchTerms": [], + "slug": "14-verse", "title": "Level 14 (VERSE)", - "where": "chart.level = '14'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7185,9 +7953,10 @@ "inactive": false, "legacyFolderID": "Fe06ee81e02d05cd626f5bd85f704168b7ba42dc3c128b77f0dd4b7ac882fd6d4", "searchTerms": [], + "slug": "14-xverse-intl", "title": "Level 14 (X-VERSE International)", - "where": "chart.level = '14'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7195,9 +7964,10 @@ "inactive": false, "legacyFolderID": "F14a30424b9c340ed14ddd1cfbb1d29a141c2d19f19b81ee72883315a6c76ba8c", "searchTerms": [], + "slug": "14-xverse-omni", "title": "Level 14 (X-VERSE Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7205,9 +7975,10 @@ "inactive": false, "legacyFolderID": "Fa7d0693da3b654e880c35dd390c367b80ff83e16f2057e02e1034d96370dbe30", "searchTerms": [], + "slug": "14-xverse", "title": "Level 14 (X-VERSE)", - "where": "chart.level = '14'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '14'" }, { "game": "chunithm", @@ -7215,9 +7986,10 @@ "inactive": true, "legacyFolderID": "F03fda48efbaff831d3f3a23666b59b4922b65a1ed6b937d4c9594263b18b9a15", "searchTerms": [], + "slug": "14p-luminous-intl", "title": "Level 14+ (LUMINOUS International)", - "where": "chart.level = '14+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7225,9 +7997,10 @@ "inactive": true, "legacyFolderID": "Fd4f0be51a94222cc136afb48f11b5ca8fd26dd982f7d7698c44736d15e054477", "searchTerms": [], + "slug": "14p-luminous-omni", "title": "Level 14+ (LUMINOUS Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7235,9 +8008,10 @@ "inactive": true, "legacyFolderID": "F1b8ee8afb684960a671309623329c972426924a7b72567292523609679ad5226", "searchTerms": [], + "slug": "14p-luminousplus-intl", "title": "Level 14+ (LUMINOUS PLUS International)", - "where": "chart.level = '14+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7245,9 +8019,10 @@ "inactive": true, "legacyFolderID": "Fd0fcee881f2c725b1a4ffeb604708dcd446fb057c3f1a534818c2135f5e69f06", "searchTerms": [], + "slug": "14p-luminousplus-omni", "title": "Level 14+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7255,9 +8030,10 @@ "inactive": true, "legacyFolderID": "F97b92feba00ef773f53d8d8ec93fe12e95b23efbb6a794cba76a891b0d54d58a", "searchTerms": [], + "slug": "14p-luminousplus", "title": "Level 14+ (LUMINOUS PLUS)", - "where": "chart.level = '14+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7265,9 +8041,10 @@ "inactive": true, "legacyFolderID": "F75dabdd8e04330d6d60836b96edba63c68cefe6164c7f609f6bf2027ce1984c0", "searchTerms": [], + "slug": "14p-luminous", "title": "Level 14+ (LUMINOUS)", - "where": "chart.level = '14+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7275,9 +8052,10 @@ "inactive": true, "legacyFolderID": "F17a6a205675610e2c287c113342bece8e5c28f2878800a7732d5f51ccabb5e2c", "searchTerms": [], + "slug": "14p-newplus", "title": "Level 14+ (NEW PLUS)", - "where": "chart.level = '14+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7285,9 +8063,10 @@ "inactive": true, "legacyFolderID": "F5a7a32fa22ae341c55f5a468d9ac021119deff1dded20ba8c539d424fe408774", "searchTerms": [], + "slug": "14p-new", "title": "Level 14+ (NEW)", - "where": "chart.level = '14+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7295,9 +8074,10 @@ "inactive": false, "legacyFolderID": "Fb138e45b7b5d571c85c7ae6393831d26970e8fbd074ba87738b3e452bef6316e", "searchTerms": [], + "slug": "14p-paradiselost", "title": "Level 14+ (Paradise Lost)", - "where": "chart.level = '14+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7305,9 +8085,10 @@ "inactive": true, "legacyFolderID": "Fe40992304d5598dae52cca23992c99e5665d1ea8ee7392193d1dded4af82cb90", "searchTerms": [], + "slug": "14p-sun-omni", "title": "Level 14+ (SUN Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7315,9 +8096,10 @@ "inactive": true, "legacyFolderID": "Fc033b474560eee48879a288c10219c35265b893151726288c8ae41a42559848d", "searchTerms": [], + "slug": "14p-sunplus-intl", "title": "Level 14+ (SUN PLUS International)", - "where": "chart.level = '14+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7325,9 +8107,10 @@ "inactive": true, "legacyFolderID": "F03c2d98c1d9b4cfaec94e30d91e9c350e52d69ac4a5fc9c225f5472e36ae6ccc", "searchTerms": [], + "slug": "14p-sunplus-omni", "title": "Level 14+ (SUN PLUS Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7335,9 +8118,10 @@ "inactive": true, "legacyFolderID": "F8746714de6357ceab3337cee5dadbe7362a6894517bf9b72c29776ee32602291", "searchTerms": [], + "slug": "14p-sunplus", "title": "Level 14+ (SUN PLUS)", - "where": "chart.level = '14+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7345,9 +8129,10 @@ "inactive": true, "legacyFolderID": "Fedd085e270284fbd3192477f4e536fe727f5e1c7b642e7335aa161ee67d2349a", "searchTerms": [], + "slug": "14p-sun", "title": "Level 14+ (SUN)", - "where": "chart.level = '14+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7355,9 +8140,10 @@ "inactive": true, "legacyFolderID": "Fba3bedbb9f339e325f7926461bd5ad071981882e8d4ed7fb946088a69075a0cd", "searchTerms": [], + "slug": "14p-verse-intl", "title": "Level 14+ (VERSE International)", - "where": "chart.level = '14+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7365,9 +8151,10 @@ "inactive": false, "legacyFolderID": "F8bdc76a09ea5a6a7c7e124142976e59557a94be51b0bafc84d23b8791e75a03d", "searchTerms": [], + "slug": "14p-verse-omni", "title": "Level 14+ (VERSE Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7375,9 +8162,10 @@ "inactive": false, "legacyFolderID": "Ff2943154ad00ded3d3959330fbf0473529f3630ec5d6b4e39155ff8cfd987a82", "searchTerms": [], + "slug": "14p-verse", "title": "Level 14+ (VERSE)", - "where": "chart.level = '14+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7385,9 +8173,10 @@ "inactive": false, "legacyFolderID": "F6dba9593f5a90a792b739fb749b156f6835054470c773ab7d7bef8dcf6069479", "searchTerms": [], + "slug": "14p-xverse-intl", "title": "Level 14+ (X-VERSE International)", - "where": "chart.level = '14+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7395,9 +8184,10 @@ "inactive": false, "legacyFolderID": "F245da7e6646724753e98d52b00c7d26a9ec23efe892a7b4f199f50ca7f8bd630", "searchTerms": [], + "slug": "14p-xverse-omni", "title": "Level 14+ (X-VERSE Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7405,9 +8195,10 @@ "inactive": false, "legacyFolderID": "Fb8dd20c5b26b296bab04c0ec33435cbae06eb073b78ead6270a9d037bb714cd8", "searchTerms": [], + "slug": "14p-xverse", "title": "Level 14+ (X-VERSE)", - "where": "chart.level = '14+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '14+'" }, { "game": "chunithm", @@ -7415,9 +8206,10 @@ "inactive": true, "legacyFolderID": "F29e9f4412272cfe2c485151879b9bf6576ff531c693971d185d8353264eae471", "searchTerms": [], + "slug": "15-luminous-intl", "title": "Level 15 (LUMINOUS International)", - "where": "chart.level = '15'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7425,9 +8217,10 @@ "inactive": true, "legacyFolderID": "F6a39f0d51982cfecea6fc8f9cb75e085f1f676f9f76872b0f9729c75fe4ae6b7", "searchTerms": [], + "slug": "15-luminous-omni", "title": "Level 15 (LUMINOUS Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7435,9 +8228,10 @@ "inactive": true, "legacyFolderID": "F2670896329a520a9449ac22ea408ff3b4d5d128f412e3470dcbdb65f7dce5461", "searchTerms": [], + "slug": "15-luminousplus-intl", "title": "Level 15 (LUMINOUS PLUS International)", - "where": "chart.level = '15'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7445,9 +8239,10 @@ "inactive": true, "legacyFolderID": "F247e4c376c8f4b475c99c480e4c24dc6b4dcf6ce34c0cd21d83b0b9227fc3325", "searchTerms": [], + "slug": "15-luminousplus-omni", "title": "Level 15 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7455,9 +8250,10 @@ "inactive": true, "legacyFolderID": "Fc57597ba4bdaa406377c442c511fd8f8f43a69b0d27e3154285e79a3e121786e", "searchTerms": [], + "slug": "15-luminousplus", "title": "Level 15 (LUMINOUS PLUS)", - "where": "chart.level = '15'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7465,9 +8261,10 @@ "inactive": true, "legacyFolderID": "Fbeb5d474cbf40cad4435ec535479e6da020ff0f316cc3250162cd71ee89d5bdb", "searchTerms": [], + "slug": "15-luminous", "title": "Level 15 (LUMINOUS)", - "where": "chart.level = '15'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7475,9 +8272,10 @@ "inactive": true, "legacyFolderID": "F5f260a335a1d003fc9be961463c6bc5ca1b841daeb6cb36b70bf21490c565cfb", "searchTerms": [], + "slug": "15-newplus", "title": "Level 15 (NEW PLUS)", - "where": "chart.level = '15'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7485,9 +8283,10 @@ "inactive": true, "legacyFolderID": "F4ae4ff28f75b1bc33c7f7c2d3c730bff99ba0e7299505b89ad927fb92b25d1e1", "searchTerms": [], + "slug": "15-new", "title": "Level 15 (NEW)", - "where": "chart.level = '15'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7495,9 +8294,10 @@ "inactive": false, "legacyFolderID": "F6b841d35bd4ded62327aca9a6374fc2ba7d69c98970e620bfc8d1db9545a0cb2", "searchTerms": [], + "slug": "15-paradiselost", "title": "Level 15 (Paradise Lost)", - "where": "chart.level = '15'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7505,9 +8305,10 @@ "inactive": true, "legacyFolderID": "F5e5636161b64bf379b8dcc64359a8fca187456d2e26e12d03a026aa4a50a4cdc", "searchTerms": [], + "slug": "15-sun-omni", "title": "Level 15 (SUN Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7515,9 +8316,10 @@ "inactive": true, "legacyFolderID": "Fb6ec940925c9ef2a598a5425293b1c30a1533a37ea3546c5672ef111944578b0", "searchTerms": [], + "slug": "15-sunplus-intl", "title": "Level 15 (SUN PLUS International)", - "where": "chart.level = '15'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7525,9 +8327,10 @@ "inactive": true, "legacyFolderID": "F78e12b6d6f218366399229fa6e0de86da2ee4e994a9a41bc4fbd0ffa2cc8512b", "searchTerms": [], + "slug": "15-sunplus-omni", "title": "Level 15 (SUN PLUS Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7535,9 +8338,10 @@ "inactive": true, "legacyFolderID": "F274a34226d167d9d8d51be0dcfd2c893f7051d964d80b232d3d021dc9aa9fdc7", "searchTerms": [], + "slug": "15-sunplus", "title": "Level 15 (SUN PLUS)", - "where": "chart.level = '15'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7545,9 +8349,10 @@ "inactive": true, "legacyFolderID": "F0dbda68e409a25d9ffe0db61ea2a51ee7692cef3f3ef71537c26f8f1e1eab311", "searchTerms": [], + "slug": "15-sun", "title": "Level 15 (SUN)", - "where": "chart.level = '15'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7555,9 +8360,10 @@ "inactive": true, "legacyFolderID": "F31b0be4fefc27749651de74dac4b681a2995365cd31833d6b1bf523d0a8a048a", "searchTerms": [], + "slug": "15-verse-intl", "title": "Level 15 (VERSE International)", - "where": "chart.level = '15'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7565,9 +8371,10 @@ "inactive": false, "legacyFolderID": "Fa140add8a4a8233ef273b807347f76ed184d2d2d7d8593d3a932d6d31e765e77", "searchTerms": [], + "slug": "15-verse-omni", "title": "Level 15 (VERSE Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7575,9 +8382,10 @@ "inactive": false, "legacyFolderID": "Feab1396f47dd8be4298e45d6f7025397d78372da65b2156d33f3a9b45455d21b", "searchTerms": [], + "slug": "15-verse", "title": "Level 15 (VERSE)", - "where": "chart.level = '15'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7585,9 +8393,10 @@ "inactive": false, "legacyFolderID": "Fed82b94e4d830a40894070d5904c38c46815b2d9e4455b8a2254fcd1cb735161", "searchTerms": [], + "slug": "15-xverse-intl", "title": "Level 15 (X-VERSE International)", - "where": "chart.level = '15'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7595,9 +8404,10 @@ "inactive": false, "legacyFolderID": "F0d0d4f8730f76190fb730075affaa7a5b8276e09a93435d81acacb92393a1ca6", "searchTerms": [], + "slug": "15-xverse-omni", "title": "Level 15 (X-VERSE Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7605,9 +8415,10 @@ "inactive": false, "legacyFolderID": "Fb838946527662355bfdf9f5ccf446db994f4eb0945e46a7098e41c31e2cb23c3", "searchTerms": [], + "slug": "15-xverse", "title": "Level 15 (X-VERSE)", - "where": "chart.level = '15'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '15'" }, { "game": "chunithm", @@ -7615,9 +8426,10 @@ "inactive": true, "legacyFolderID": "F086b755e3b9bafd1feef8f4bfa18b80dcc6ae1ed75bde059aa26c4d7c05b7c38", "searchTerms": [], + "slug": "15p-luminous-intl", "title": "Level 15+ (LUMINOUS International)", - "where": "chart.level = '15+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7625,9 +8437,10 @@ "inactive": true, "legacyFolderID": "F510f6d6507c82f94bdf79c39c6fdfef5089613419425088b555329dd0d19e1b1", "searchTerms": [], + "slug": "15p-luminous-omni", "title": "Level 15+ (LUMINOUS Omnimix)", - "where": "chart.level = '15+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7635,9 +8448,10 @@ "inactive": true, "legacyFolderID": "Fc6f5cf264a1ced8f937ec301bb5e11c43f0bfb0d48103e1486714b259b5501e6", "searchTerms": [], + "slug": "15p-luminousplus-intl", "title": "Level 15+ (LUMINOUS PLUS International)", - "where": "chart.level = '15+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7645,9 +8459,10 @@ "inactive": true, "legacyFolderID": "F037e606c874253d9825e2e404738bd13dae384a50e9591ce1fd62912ab00df92", "searchTerms": [], + "slug": "15p-luminousplus-omni", "title": "Level 15+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '15+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7655,9 +8470,10 @@ "inactive": true, "legacyFolderID": "F1e2dad337f35f872fcb9d9726bd4012570c5ce0c644c2d68f97f2f3c9c884d76", "searchTerms": [], + "slug": "15p-luminousplus", "title": "Level 15+ (LUMINOUS PLUS)", - "where": "chart.level = '15+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7665,9 +8481,10 @@ "inactive": true, "legacyFolderID": "F0e506601a0a0bc2be88650efd5c5216c535166a4cd14abbd0afe9840dcc2a9f5", "searchTerms": [], + "slug": "15p-luminous", "title": "Level 15+ (LUMINOUS)", - "where": "chart.level = '15+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7675,9 +8492,10 @@ "inactive": true, "legacyFolderID": "Fd268425c7ed207ebe4082f5e4f25f4c62ce65d22f457fcb3559f13f9e40a0dd2", "searchTerms": [], + "slug": "15p-newplus", "title": "Level 15+ (NEW PLUS)", - "where": "chart.level = '15+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7685,9 +8503,10 @@ "inactive": true, "legacyFolderID": "F0f888f5afa16921cd8355deb7e88fc7d3bff861ba97541e62ebea40b376db34e", "searchTerms": [], + "slug": "15p-new", "title": "Level 15+ (NEW)", - "where": "chart.level = '15+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7695,9 +8514,10 @@ "inactive": false, "legacyFolderID": "F7a8e094af89165a8ef852a53adf2d9bf7662f9e3b18b4865da687223041e74e4", "searchTerms": [], + "slug": "15p-paradiselost", "title": "Level 15+ (Paradise Lost)", - "where": "chart.level = '15+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7705,9 +8525,10 @@ "inactive": true, "legacyFolderID": "Fcf9f50152db2e2f21c0357d776ab9b1717a8410b7817ffdae8a214b67c6e656d", "searchTerms": [], + "slug": "15p-sun-omni", "title": "Level 15+ (SUN Omnimix)", - "where": "chart.level = '15+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7715,9 +8536,10 @@ "inactive": true, "legacyFolderID": "Feb7414739f43785ae2dc1bd280fcc36a1733cfdfbdac31a216c69a6abcc86496", "searchTerms": [], + "slug": "15p-sunplus-intl", "title": "Level 15+ (SUN PLUS International)", - "where": "chart.level = '15+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7725,9 +8547,10 @@ "inactive": true, "legacyFolderID": "Fb01b7757746ca8ed30c9d73ff87134e4708a7c9de8217ba6e355732f784b6ed7", "searchTerms": [], + "slug": "15p-sunplus-omni", "title": "Level 15+ (SUN PLUS Omnimix)", - "where": "chart.level = '15+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7735,9 +8558,10 @@ "inactive": true, "legacyFolderID": "F5084aec9b8037b717571862c1b03fdcc6808e9d049163bfb85282c708eade31f", "searchTerms": [], + "slug": "15p-sunplus", "title": "Level 15+ (SUN PLUS)", - "where": "chart.level = '15+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7745,9 +8569,10 @@ "inactive": true, "legacyFolderID": "F48bb2aff46315b47c196df6cb92d19d48ff9b2c735a76918c5872d568e2d7a60", "searchTerms": [], + "slug": "15p-sun", "title": "Level 15+ (SUN)", - "where": "chart.level = '15+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7755,9 +8580,10 @@ "inactive": true, "legacyFolderID": "F6e3bab8efa50af337873432c574f96c039e8b7ac678632d8ff4fc4cd1fc5f1b2", "searchTerms": [], + "slug": "15p-verse-intl", "title": "Level 15+ (VERSE International)", - "where": "chart.level = '15+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7765,9 +8591,10 @@ "inactive": false, "legacyFolderID": "F5a9f10ba46112815e2a16e6732e412e7af9ebc2cc4e2404e2cc1114fc0b537e2", "searchTerms": [], + "slug": "15p-verse-omni", "title": "Level 15+ (VERSE Omnimix)", - "where": "chart.level = '15+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7775,9 +8602,10 @@ "inactive": false, "legacyFolderID": "F46c1359635eb10809bed8eba53cd0da7fa0b09abe5caa31f195096c12d6a71f4", "searchTerms": [], + "slug": "15p-verse", "title": "Level 15+ (VERSE)", - "where": "chart.level = '15+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7785,9 +8613,10 @@ "inactive": false, "legacyFolderID": "Fe008404ed076db79c3ea8a249456ca9f8b493bb55b0b9e32e646e3c8ab7f903d", "searchTerms": [], + "slug": "15p-xverse-intl", "title": "Level 15+ (X-VERSE International)", - "where": "chart.level = '15+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7795,9 +8624,10 @@ "inactive": false, "legacyFolderID": "F124173f84369a68925522f74bd8a0355c04a87a0826f7f91d9ce1c21d498b436", "searchTerms": [], + "slug": "15p-xverse-omni", "title": "Level 15+ (X-VERSE Omnimix)", - "where": "chart.level = '15+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7805,9 +8635,10 @@ "inactive": false, "legacyFolderID": "F5f1f2f243c69ac61afcc2fca47dac27b18017882c9b965bab7c420c0cc6e23e0", "searchTerms": [], + "slug": "15p-xverse", "title": "Level 15+ (X-VERSE)", - "where": "chart.level = '15+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '15+'" }, { "game": "chunithm", @@ -7815,9 +8646,10 @@ "inactive": true, "legacyFolderID": "F3eacd6abcbf65ee2d13f4793a47c5fdc28e705187d68216dd307918f05ef5d63", "searchTerms": [], + "slug": "2-luminous-intl", "title": "Level 2 (LUMINOUS International)", - "where": "chart.level = '2'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7825,9 +8657,10 @@ "inactive": true, "legacyFolderID": "F9c095f40f1a5cba7482e0f2c8d44da539b1dda1e2692bd5219b8dc52c24f6fbf", "searchTerms": [], + "slug": "2-luminous-omni", "title": "Level 2 (LUMINOUS Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7835,9 +8668,10 @@ "inactive": true, "legacyFolderID": "Fa2450d6aa9ddd2ecb25ed04d7e557578bb94346984f018fdd652ec61f36bea3c", "searchTerms": [], + "slug": "2-luminousplus-intl", "title": "Level 2 (LUMINOUS PLUS International)", - "where": "chart.level = '2'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7845,9 +8679,10 @@ "inactive": true, "legacyFolderID": "F430d4b1a0eefc1ee71758a622796d14b7aa463de2059c6fbfb5e0b1ea485c7db", "searchTerms": [], + "slug": "2-luminousplus-omni", "title": "Level 2 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7855,9 +8690,10 @@ "inactive": true, "legacyFolderID": "Fa6f8e5fdfcb19343dfb3c05ffa3dcf07ff472c42c0404b95dc0e07c279fa46a2", "searchTerms": [], + "slug": "2-luminousplus", "title": "Level 2 (LUMINOUS PLUS)", - "where": "chart.level = '2'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7865,9 +8701,10 @@ "inactive": true, "legacyFolderID": "Fed1f58125c7792fabc73afa21c98558c42ab077ccbad54464b575493a6b064db", "searchTerms": [], + "slug": "2-luminous", "title": "Level 2 (LUMINOUS)", - "where": "chart.level = '2'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7875,9 +8712,10 @@ "inactive": true, "legacyFolderID": "F11b28ad6bd1dd7871f5302f94c4c19983948ac4008615ccbefe5de760c9a5511", "searchTerms": [], + "slug": "2-newplus", "title": "Level 2 (NEW PLUS)", - "where": "chart.level = '2'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7885,9 +8723,10 @@ "inactive": true, "legacyFolderID": "Fd6040b233f17fce9da86190238b05ef5b296f93eeff64fe62983d44d09dfe216", "searchTerms": [], + "slug": "2-new", "title": "Level 2 (NEW)", - "where": "chart.level = '2'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7895,9 +8734,10 @@ "inactive": false, "legacyFolderID": "F5877dced3e7b942eb91d4595a7543475a9afd68bd978de70a52224cc1e1981d3", "searchTerms": [], + "slug": "2-paradiselost", "title": "Level 2 (Paradise Lost)", - "where": "chart.level = '2'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7905,9 +8745,10 @@ "inactive": true, "legacyFolderID": "F54a007227353ee72f435f9efce665a2cf18c881c46829a8dfa8bb114ee890e27", "searchTerms": [], + "slug": "2-sun-omni", "title": "Level 2 (SUN Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7915,9 +8756,10 @@ "inactive": true, "legacyFolderID": "F290bac30687512c433a68bf632178d82c7f361b22dbe11891d0d04a69dbd3973", "searchTerms": [], + "slug": "2-sunplus-intl", "title": "Level 2 (SUN PLUS International)", - "where": "chart.level = '2'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7925,9 +8767,10 @@ "inactive": true, "legacyFolderID": "F6a68852ec39d6043d5965809817c4afae3cf83e54c4a39c09f368c3af9143c12", "searchTerms": [], + "slug": "2-sunplus-omni", "title": "Level 2 (SUN PLUS Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7935,9 +8778,10 @@ "inactive": true, "legacyFolderID": "Fa6b606be256c6c762e6a8589dafcc5712d7fda06e69ddd66d8aba24c9157b57c", "searchTerms": [], + "slug": "2-sunplus", "title": "Level 2 (SUN PLUS)", - "where": "chart.level = '2'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7945,9 +8789,10 @@ "inactive": true, "legacyFolderID": "Fca334aec18ecddf9fe69e4fab40619a4ee90448e442ea7d467d8837eb24dd840", "searchTerms": [], + "slug": "2-sun", "title": "Level 2 (SUN)", - "where": "chart.level = '2'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7955,9 +8800,10 @@ "inactive": true, "legacyFolderID": "F06b7f101ca20476fa91e2f69e71d271fb204381da101acb1afe29dea1198d8ae", "searchTerms": [], + "slug": "2-verse-intl", "title": "Level 2 (VERSE International)", - "where": "chart.level = '2'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7965,9 +8811,10 @@ "inactive": false, "legacyFolderID": "Fd1ce83305bdc9e32c5ea0602f8d1d995989aad75ae0af009633a3275147f6caf", "searchTerms": [], + "slug": "2-verse-omni", "title": "Level 2 (VERSE Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7975,9 +8822,10 @@ "inactive": false, "legacyFolderID": "F409ab0da0dd0a8f4397bf007e13eb6bd3f1a874fdc9cff9bd1d2095877df805c", "searchTerms": [], + "slug": "2-verse", "title": "Level 2 (VERSE)", - "where": "chart.level = '2'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7985,9 +8833,10 @@ "inactive": false, "legacyFolderID": "Fd6dca35c400eac880e8f8ee998c3d1ec7d6076f8296da1b481aaaaed575b1595", "searchTerms": [], + "slug": "2-xverse-intl", "title": "Level 2 (X-VERSE International)", - "where": "chart.level = '2'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -7995,9 +8844,10 @@ "inactive": false, "legacyFolderID": "Fe006089cfc89eb7b2b1fe2c6342e3d9200c43817c846d93091898b29334aeec1", "searchTerms": [], + "slug": "2-xverse-omni", "title": "Level 2 (X-VERSE Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -8005,9 +8855,10 @@ "inactive": false, "legacyFolderID": "F139404c3e1e346510c9b10389e532caef17d03a4b94d657d989a357a99e78236", "searchTerms": [], + "slug": "2-xverse", "title": "Level 2 (X-VERSE)", - "where": "chart.level = '2'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '2'" }, { "game": "chunithm", @@ -8015,9 +8866,10 @@ "inactive": true, "legacyFolderID": "F0f360e6e764207b4e76c0a6070cc2091c92a02fcec7c47a0cf528e3ad832066d", "searchTerms": [], + "slug": "3-luminous-intl", "title": "Level 3 (LUMINOUS International)", - "where": "chart.level = '3'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8025,9 +8877,10 @@ "inactive": true, "legacyFolderID": "Fda43f526693bdb9901d3b84a4bc6b036e3555b37d7655de9eb1c308b768eb2f0", "searchTerms": [], + "slug": "3-luminous-omni", "title": "Level 3 (LUMINOUS Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8035,9 +8888,10 @@ "inactive": true, "legacyFolderID": "Fa9abd6931747a150847f4bdf8bfc351924120873c123f25a9b898ea9ba011d84", "searchTerms": [], + "slug": "3-luminousplus-intl", "title": "Level 3 (LUMINOUS PLUS International)", - "where": "chart.level = '3'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8045,9 +8899,10 @@ "inactive": true, "legacyFolderID": "Ff0c4b52da89a071f50bbab803e21524d4943d4bef62a589e089109400e736996", "searchTerms": [], + "slug": "3-luminousplus-omni", "title": "Level 3 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8055,9 +8910,10 @@ "inactive": true, "legacyFolderID": "Fff2759f683bf6232fd49280c6ae954622f35db575848ec5b59c3725252515227", "searchTerms": [], + "slug": "3-luminousplus", "title": "Level 3 (LUMINOUS PLUS)", - "where": "chart.level = '3'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8065,9 +8921,10 @@ "inactive": true, "legacyFolderID": "Fa0d74e202f1349cc1d8bc8bc660026b6ae450530a4ec97468d616cfa2af4d1fc", "searchTerms": [], + "slug": "3-luminous", "title": "Level 3 (LUMINOUS)", - "where": "chart.level = '3'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8075,9 +8932,10 @@ "inactive": true, "legacyFolderID": "Ffdc92de07754c065e578e5f88c94fd5d0ae3d13c177d2b1e499d75dfe8909176", "searchTerms": [], + "slug": "3-newplus", "title": "Level 3 (NEW PLUS)", - "where": "chart.level = '3'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8085,9 +8943,10 @@ "inactive": true, "legacyFolderID": "F1bcd95f170d25ff230693d03e365db1b46507bd2bd421d47abfbc7e298004e4a", "searchTerms": [], + "slug": "3-new", "title": "Level 3 (NEW)", - "where": "chart.level = '3'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8095,9 +8954,10 @@ "inactive": false, "legacyFolderID": "F897a7ed2429917a5d1f87913eeb36f08e4e57919fd9b1336e319b7b836fc7b60", "searchTerms": [], + "slug": "3-paradiselost", "title": "Level 3 (Paradise Lost)", - "where": "chart.level = '3'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8105,9 +8965,10 @@ "inactive": true, "legacyFolderID": "F51635f3eaa05cb99797c73ed7d6c6be82084ccacdc412dbe05983686ad15f5ee", "searchTerms": [], + "slug": "3-sun-omni", "title": "Level 3 (SUN Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8115,9 +8976,10 @@ "inactive": true, "legacyFolderID": "F69909ffc7fe2ca7ba90d4c523bd46ade84fdb5bfecb2c3e6870b1a3f76f268de", "searchTerms": [], + "slug": "3-sunplus-intl", "title": "Level 3 (SUN PLUS International)", - "where": "chart.level = '3'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8125,9 +8987,10 @@ "inactive": true, "legacyFolderID": "F7390c6072a266a40d452b02dad11f0477739628e3141215b85595f1b8ae3b9ec", "searchTerms": [], + "slug": "3-sunplus-omni", "title": "Level 3 (SUN PLUS Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8135,9 +8998,10 @@ "inactive": true, "legacyFolderID": "F7c6a8542c135a9c031fe3a152be9e4938b6f541860f5ea71c074aca3aa11c151", "searchTerms": [], + "slug": "3-sunplus", "title": "Level 3 (SUN PLUS)", - "where": "chart.level = '3'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8145,9 +9009,10 @@ "inactive": true, "legacyFolderID": "F60e8098e5bb3f27b350a27b96700a6b944b5932b10299771eb9b39eea5ca30dd", "searchTerms": [], + "slug": "3-sun", "title": "Level 3 (SUN)", - "where": "chart.level = '3'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8155,9 +9020,10 @@ "inactive": true, "legacyFolderID": "F9e20358e4b0ad68b7d1ced72139737e77e4d12d01c7f19a5166d7ba1ba2d7480", "searchTerms": [], + "slug": "3-verse-intl", "title": "Level 3 (VERSE International)", - "where": "chart.level = '3'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8165,9 +9031,10 @@ "inactive": false, "legacyFolderID": "Ff40723529cf4d55d7adcce78c6362b78d3d90408b41d1bcfef005ee149c08982", "searchTerms": [], + "slug": "3-verse-omni", "title": "Level 3 (VERSE Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8175,9 +9042,10 @@ "inactive": false, "legacyFolderID": "F2b7ccdd434e7031f7d0089fb2322e205cac7f2ed3aae520c86caa330ae7417b7", "searchTerms": [], + "slug": "3-verse", "title": "Level 3 (VERSE)", - "where": "chart.level = '3'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8185,9 +9053,10 @@ "inactive": false, "legacyFolderID": "Ff5aa7146c9cc572c5be0bc6c6d3226aadcb9f5fe4e00cec13098b0f371648627", "searchTerms": [], + "slug": "3-xverse-intl", "title": "Level 3 (X-VERSE International)", - "where": "chart.level = '3'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8195,9 +9064,10 @@ "inactive": false, "legacyFolderID": "F26199ec75c91707bdf2c1bf6e99d3e028f9e2a2d130ca0868b703c92028def59", "searchTerms": [], + "slug": "3-xverse-omni", "title": "Level 3 (X-VERSE Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8205,9 +9075,10 @@ "inactive": false, "legacyFolderID": "F0a416172c558455b1eca4f40d19e3fb3b19c4a4d119eeb36bed0debb00944567", "searchTerms": [], + "slug": "3-xverse", "title": "Level 3 (X-VERSE)", - "where": "chart.level = '3'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '3'" }, { "game": "chunithm", @@ -8215,9 +9086,10 @@ "inactive": true, "legacyFolderID": "F038978baa51b978ab3fb574db01e3a7abfe2a59d6b617103b98b17a7dcc0a461", "searchTerms": [], + "slug": "4-luminous-intl", "title": "Level 4 (LUMINOUS International)", - "where": "chart.level = '4'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8225,9 +9097,10 @@ "inactive": true, "legacyFolderID": "Fbc803cf14a72b460a5d1d122ab6eaf50e6aea183180271c6ce4a9c3c6f1e8dd6", "searchTerms": [], + "slug": "4-luminous-omni", "title": "Level 4 (LUMINOUS Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8235,9 +9108,10 @@ "inactive": true, "legacyFolderID": "F336021ea1d4434aa65f28a74963d07bcdc360f8b4e36b945afc578b6e70397f3", "searchTerms": [], + "slug": "4-luminousplus-intl", "title": "Level 4 (LUMINOUS PLUS International)", - "where": "chart.level = '4'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8245,9 +9119,10 @@ "inactive": true, "legacyFolderID": "F3878605a7bd226c24af6008daafe63bbc071a36137f6d40982b8dcc54ab9a0a4", "searchTerms": [], + "slug": "4-luminousplus-omni", "title": "Level 4 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8255,9 +9130,10 @@ "inactive": true, "legacyFolderID": "Fff53f98069409423ba8ae6d0db8bfd5245d57d1280fc9039bccd0152118cfaa4", "searchTerms": [], + "slug": "4-luminousplus", "title": "Level 4 (LUMINOUS PLUS)", - "where": "chart.level = '4'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8265,9 +9141,10 @@ "inactive": true, "legacyFolderID": "F80e466f00513a9d7ed139f7ed3c48f33ca712f327c72d4fec6cefaf4e70a7ba9", "searchTerms": [], + "slug": "4-luminous", "title": "Level 4 (LUMINOUS)", - "where": "chart.level = '4'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8275,9 +9152,10 @@ "inactive": true, "legacyFolderID": "F5806ad4b353ef6571ee7e899ddf8d1a68d1bb9cbadc4423f60524a945a2f0bec", "searchTerms": [], + "slug": "4-newplus", "title": "Level 4 (NEW PLUS)", - "where": "chart.level = '4'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8285,9 +9163,10 @@ "inactive": true, "legacyFolderID": "F475898799639ed5e94eae173e52064b4ca194186e4152ee50ed8bca78b823e6b", "searchTerms": [], + "slug": "4-new", "title": "Level 4 (NEW)", - "where": "chart.level = '4'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8295,9 +9174,10 @@ "inactive": false, "legacyFolderID": "F72c6ceedb033e71f60ef0daf772eb2cb76f3bba268386d4bdb8a07a8693d1be8", "searchTerms": [], + "slug": "4-paradiselost", "title": "Level 4 (Paradise Lost)", - "where": "chart.level = '4'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8305,9 +9185,10 @@ "inactive": true, "legacyFolderID": "Fb10e04c260027eed8cf04679b709c9defccf4627a67bc67137ad3d4af0f8ff05", "searchTerms": [], + "slug": "4-sun-omni", "title": "Level 4 (SUN Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8315,9 +9196,10 @@ "inactive": true, "legacyFolderID": "Fda7c6fce5a1d5543640744fce34e1bf5a256ae7e8722406ad7487f9863f5d9e2", "searchTerms": [], + "slug": "4-sunplus-intl", "title": "Level 4 (SUN PLUS International)", - "where": "chart.level = '4'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8325,9 +9207,10 @@ "inactive": true, "legacyFolderID": "F58ce865afdd606dbe060e11ef024af87112e30476d2c2d84374e8d605e53eda7", "searchTerms": [], + "slug": "4-sunplus-omni", "title": "Level 4 (SUN PLUS Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8335,9 +9218,10 @@ "inactive": true, "legacyFolderID": "Ff2bca9a20fb13631aacf33109d950aed2ffb41826bf6b2dc47eeb197e0ba8b1e", "searchTerms": [], + "slug": "4-sunplus", "title": "Level 4 (SUN PLUS)", - "where": "chart.level = '4'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8345,9 +9229,10 @@ "inactive": true, "legacyFolderID": "Fd8e83a053e783202a91582508fc119077f2434b4ea19fc60d2fd7c7f67d7c845", "searchTerms": [], + "slug": "4-sun", "title": "Level 4 (SUN)", - "where": "chart.level = '4'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8355,9 +9240,10 @@ "inactive": true, "legacyFolderID": "Fa9ea16a63cbb5449c8ac33c5b0a8ab4993265ea389125a62983c14495033a8e5", "searchTerms": [], + "slug": "4-verse-intl", "title": "Level 4 (VERSE International)", - "where": "chart.level = '4'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8365,9 +9251,10 @@ "inactive": false, "legacyFolderID": "Fc51bce45ec2030f42a6184fc5bcb66f76dd0ab6885ced4ff74a01de5db3d5752", "searchTerms": [], + "slug": "4-verse-omni", "title": "Level 4 (VERSE Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8375,9 +9262,10 @@ "inactive": false, "legacyFolderID": "Fa8608951f672e499428a98369b7f09d031ff027a4008a3666f67a8efb5eff276", "searchTerms": [], + "slug": "4-verse", "title": "Level 4 (VERSE)", - "where": "chart.level = '4'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8385,9 +9273,10 @@ "inactive": false, "legacyFolderID": "F57dade5c85231209d0b336c89dfa84ae6ac5284b6e92f3d21087cfb89a49f0de", "searchTerms": [], + "slug": "4-xverse-intl", "title": "Level 4 (X-VERSE International)", - "where": "chart.level = '4'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8395,9 +9284,10 @@ "inactive": false, "legacyFolderID": "F5377638cdb94810566c0997a7dc9017f08f9ff717037c9d097296bfa19663f8f", "searchTerms": [], + "slug": "4-xverse-omni", "title": "Level 4 (X-VERSE Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8405,9 +9295,10 @@ "inactive": false, "legacyFolderID": "F50e5a11bf7d0cbea739ed30bef9094c9e24a34043e509eda4d13f68f7e250529", "searchTerms": [], + "slug": "4-xverse", "title": "Level 4 (X-VERSE)", - "where": "chart.level = '4'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '4'" }, { "game": "chunithm", @@ -8415,9 +9306,10 @@ "inactive": true, "legacyFolderID": "F1f242538a41d58fb5b8eb8413a8d41fb524fc536be2f858fff19756342bda248", "searchTerms": [], + "slug": "5-luminous-intl", "title": "Level 5 (LUMINOUS International)", - "where": "chart.level = '5'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8425,9 +9317,10 @@ "inactive": true, "legacyFolderID": "F1874ed1184709f7ec7ffd9e1614c9070cae8e237ec1628ad53b65857bac63466", "searchTerms": [], + "slug": "5-luminous-omni", "title": "Level 5 (LUMINOUS Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8435,9 +9328,10 @@ "inactive": true, "legacyFolderID": "Fc62548d392fe77c11004457648240aabd3fbec40efde7fc8c907b9be72dabf44", "searchTerms": [], + "slug": "5-luminousplus-intl", "title": "Level 5 (LUMINOUS PLUS International)", - "where": "chart.level = '5'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8445,9 +9339,10 @@ "inactive": true, "legacyFolderID": "F7a6f82598b75165bd261fa1cafeb8f9fb043a16c2850e0d3271629520f52c2fa", "searchTerms": [], + "slug": "5-luminousplus-omni", "title": "Level 5 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8455,9 +9350,10 @@ "inactive": true, "legacyFolderID": "Fe25f5af40c59e404a79bb164a8ae93f960665d2f915ff9007464b82fb1ce7445", "searchTerms": [], + "slug": "5-luminousplus", "title": "Level 5 (LUMINOUS PLUS)", - "where": "chart.level = '5'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8465,9 +9361,10 @@ "inactive": true, "legacyFolderID": "F36a80e458587babf6792423ff21bd4632634c674d52e58471021e7c6e06a92bc", "searchTerms": [], + "slug": "5-luminous", "title": "Level 5 (LUMINOUS)", - "where": "chart.level = '5'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8475,9 +9372,10 @@ "inactive": true, "legacyFolderID": "Fb9543aef95418b601353953ccca0f809a0b8e05c8f63cd80987a0315de0ecb27", "searchTerms": [], + "slug": "5-newplus", "title": "Level 5 (NEW PLUS)", - "where": "chart.level = '5'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8485,9 +9383,10 @@ "inactive": true, "legacyFolderID": "Fa25a21341c7a05955bed151f74b3f7cc525f6b7a1b94ae6b9127d1689e35654c", "searchTerms": [], + "slug": "5-new", "title": "Level 5 (NEW)", - "where": "chart.level = '5'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8495,9 +9394,10 @@ "inactive": false, "legacyFolderID": "Fe8a7e436f0da7a3ac2529476706192f82bdb96e15491ed2cdde6860dc1019018", "searchTerms": [], + "slug": "5-paradiselost", "title": "Level 5 (Paradise Lost)", - "where": "chart.level = '5'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8505,9 +9405,10 @@ "inactive": true, "legacyFolderID": "Facbd0324726bc81b7eb22264068502f492bf5ee33c6585842043b1d969618e5c", "searchTerms": [], + "slug": "5-sun-omni", "title": "Level 5 (SUN Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8515,9 +9416,10 @@ "inactive": true, "legacyFolderID": "F9755624cdf1ec744a4b8b82187c8c45881377a4f5e86eddbcdafc5d74957de10", "searchTerms": [], + "slug": "5-sunplus-intl", "title": "Level 5 (SUN PLUS International)", - "where": "chart.level = '5'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8525,9 +9427,10 @@ "inactive": true, "legacyFolderID": "F635f80225dff7d9d49e1f2dfc197ad5a5c6997e1a6d99d50666f001a6252d2b1", "searchTerms": [], + "slug": "5-sunplus-omni", "title": "Level 5 (SUN PLUS Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8535,9 +9438,10 @@ "inactive": true, "legacyFolderID": "F7b4a41e7dc83b33b39987be7f9396351eea98ef7e529e820241e79c34d3b67e9", "searchTerms": [], + "slug": "5-sunplus", "title": "Level 5 (SUN PLUS)", - "where": "chart.level = '5'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8545,9 +9449,10 @@ "inactive": true, "legacyFolderID": "Fde63735793e4434f42b6ec24c1868a462e0364affbb8c3865eca10ca1919b39a", "searchTerms": [], + "slug": "5-sun", "title": "Level 5 (SUN)", - "where": "chart.level = '5'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8555,9 +9460,10 @@ "inactive": true, "legacyFolderID": "F3c1ab9430b7d13421f139a06c737935a8d5ca1fe1d437b59460edbb30eace415", "searchTerms": [], + "slug": "5-verse-intl", "title": "Level 5 (VERSE International)", - "where": "chart.level = '5'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8565,9 +9471,10 @@ "inactive": false, "legacyFolderID": "Fcd427f8b7dd133f31ee0730bc4357928d12387ae47e81ceb851382614a458dc1", "searchTerms": [], + "slug": "5-verse-omni", "title": "Level 5 (VERSE Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8575,9 +9482,10 @@ "inactive": false, "legacyFolderID": "F486a93b3aba7d49560c4ab81c91ca42417ec17a3127102722930092db42c1967", "searchTerms": [], + "slug": "5-verse", "title": "Level 5 (VERSE)", - "where": "chart.level = '5'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8585,9 +9493,10 @@ "inactive": false, "legacyFolderID": "F02a445f05d18b237e8adae4b4aabb7693b383fec78aaa4e80ff328c188beb933", "searchTerms": [], + "slug": "5-xverse-intl", "title": "Level 5 (X-VERSE International)", - "where": "chart.level = '5'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8595,9 +9504,10 @@ "inactive": false, "legacyFolderID": "F5c13a20a45a523bbb351ddffeb08ba66199f4479118e23896969584f038ea119", "searchTerms": [], + "slug": "5-xverse-omni", "title": "Level 5 (X-VERSE Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8605,9 +9515,10 @@ "inactive": false, "legacyFolderID": "F06bddb855c74f0f0b06ab9623a86521104f7b6918b1416bf83927490c5e3f6d9", "searchTerms": [], + "slug": "5-xverse", "title": "Level 5 (X-VERSE)", - "where": "chart.level = '5'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '5'" }, { "game": "chunithm", @@ -8615,9 +9526,10 @@ "inactive": true, "legacyFolderID": "Fd14ec840e33852e0c346ef82fad6c8b177f13db523f3829cb6e60409aca32aaa", "searchTerms": [], + "slug": "6-luminous-intl", "title": "Level 6 (LUMINOUS International)", - "where": "chart.level = '6'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8625,9 +9537,10 @@ "inactive": true, "legacyFolderID": "F9cd780b83d21ec41039f8660afdd96947abdbf397138c9debda1d53e59137f67", "searchTerms": [], + "slug": "6-luminous-omni", "title": "Level 6 (LUMINOUS Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8635,9 +9548,10 @@ "inactive": true, "legacyFolderID": "F0695258b1e9e60d4b79465ce54aa99a49c5c8aa3dfb85c91f352744ebb44490b", "searchTerms": [], + "slug": "6-luminousplus-intl", "title": "Level 6 (LUMINOUS PLUS International)", - "where": "chart.level = '6'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8645,9 +9559,10 @@ "inactive": true, "legacyFolderID": "F5c49dc35158184f0a34245143df319fae394f53c6f21b78e7a20567425001850", "searchTerms": [], + "slug": "6-luminousplus-omni", "title": "Level 6 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8655,9 +9570,10 @@ "inactive": true, "legacyFolderID": "Fea8b433664f69404d3a56979b620ae3691c24728dee9a382580342bb75919931", "searchTerms": [], + "slug": "6-luminousplus", "title": "Level 6 (LUMINOUS PLUS)", - "where": "chart.level = '6'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8665,9 +9581,10 @@ "inactive": true, "legacyFolderID": "F8dafcd7ee52c08cd4f33a0e570289e94406c493b9a9430470df312dd6945e565", "searchTerms": [], + "slug": "6-luminous", "title": "Level 6 (LUMINOUS)", - "where": "chart.level = '6'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8675,9 +9592,10 @@ "inactive": true, "legacyFolderID": "F0f9cab08b085ea4690834ddb8939f3d745e0119f0f2141d7ca0e147c209b1a34", "searchTerms": [], + "slug": "6-newplus", "title": "Level 6 (NEW PLUS)", - "where": "chart.level = '6'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8685,9 +9603,10 @@ "inactive": true, "legacyFolderID": "Fe2bebbe2c2e58675d5998c097758ca66b293440d49da791729adc40bdf814b07", "searchTerms": [], + "slug": "6-new", "title": "Level 6 (NEW)", - "where": "chart.level = '6'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8695,9 +9614,10 @@ "inactive": false, "legacyFolderID": "F553100a9eabbf0e0511f9887edefe84413e21545221d968593f4de827e656960", "searchTerms": [], + "slug": "6-paradiselost", "title": "Level 6 (Paradise Lost)", - "where": "chart.level = '6'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8705,9 +9625,10 @@ "inactive": true, "legacyFolderID": "F35efabd7daa1cfdea362dbf13cc77fb3cd6843b17552b8af5f65cddfb98898fe", "searchTerms": [], + "slug": "6-sun-omni", "title": "Level 6 (SUN Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8715,9 +9636,10 @@ "inactive": true, "legacyFolderID": "Fc5d4b036eb8f4b012572b4edeea0cc834388ad4e7ab3fbb31da7cccfa6ba2190", "searchTerms": [], + "slug": "6-sunplus-intl", "title": "Level 6 (SUN PLUS International)", - "where": "chart.level = '6'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8725,9 +9647,10 @@ "inactive": true, "legacyFolderID": "F5b4bbaa66139d1b7bea93e09ca278d46111efb3f0b411cd598e64a87a2bdb657", "searchTerms": [], + "slug": "6-sunplus-omni", "title": "Level 6 (SUN PLUS Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8735,9 +9658,10 @@ "inactive": true, "legacyFolderID": "Fb154d7213e7f35dae86709a053bf51a0c66f243927fe190db1f221993b0e95bf", "searchTerms": [], + "slug": "6-sunplus", "title": "Level 6 (SUN PLUS)", - "where": "chart.level = '6'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8745,9 +9669,10 @@ "inactive": true, "legacyFolderID": "Fcf51860c4ccdd06ddc9637336a793cde802de72d886affdbc1c2b44ef7ea2bbc", "searchTerms": [], + "slug": "6-sun", "title": "Level 6 (SUN)", - "where": "chart.level = '6'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8755,9 +9680,10 @@ "inactive": true, "legacyFolderID": "F0524c09f14047070b26400b07cbd89d932c47883ee05c1447fa9e855d62103e8", "searchTerms": [], + "slug": "6-verse-intl", "title": "Level 6 (VERSE International)", - "where": "chart.level = '6'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8765,9 +9691,10 @@ "inactive": false, "legacyFolderID": "Fdf276e950b1666ef413e2b6e138768651aa1fa2a4d570d694d598712becc10a4", "searchTerms": [], + "slug": "6-verse-omni", "title": "Level 6 (VERSE Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8775,9 +9702,10 @@ "inactive": false, "legacyFolderID": "F6c113f868868dba2b6b071c4fe9b3f2a2e57a2bd408201b79ff8064dabf49100", "searchTerms": [], + "slug": "6-verse", "title": "Level 6 (VERSE)", - "where": "chart.level = '6'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8785,9 +9713,10 @@ "inactive": false, "legacyFolderID": "Fdb566a07fc91fd63990fa1dd6864c1fc70976ae7093b486bcba7473e7992e221", "searchTerms": [], + "slug": "6-xverse-intl", "title": "Level 6 (X-VERSE International)", - "where": "chart.level = '6'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8795,9 +9724,10 @@ "inactive": false, "legacyFolderID": "F54735bff37cad7724f519551a2ee08bebff164f2b07f767c9e158eafc1ab33af", "searchTerms": [], + "slug": "6-xverse-omni", "title": "Level 6 (X-VERSE Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8805,9 +9735,10 @@ "inactive": false, "legacyFolderID": "Fd38afe63b64bcfe163f92900fe3b98904fd65c457e209207a5dae4e9058e4283", "searchTerms": [], + "slug": "6-xverse", "title": "Level 6 (X-VERSE)", - "where": "chart.level = '6'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '6'" }, { "game": "chunithm", @@ -8815,9 +9746,10 @@ "inactive": true, "legacyFolderID": "F65efe4ddd650841f0ae8da7d29cb5c8a76fbe9aa05568c2e48f9987bb6bd3b11", "searchTerms": [], + "slug": "7-luminous-intl", "title": "Level 7 (LUMINOUS International)", - "where": "chart.level = '7'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8825,9 +9757,10 @@ "inactive": true, "legacyFolderID": "F460c4c6fb82e696d601785b06b1fff31a27ddadca69f4d5757d124c7c2e72964", "searchTerms": [], + "slug": "7-luminous-omni", "title": "Level 7 (LUMINOUS Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8835,9 +9768,10 @@ "inactive": true, "legacyFolderID": "Fcdb4c59d93c9106a245990eb2f0940729172f3418753488d0670983fcf96da47", "searchTerms": [], + "slug": "7-luminousplus-intl", "title": "Level 7 (LUMINOUS PLUS International)", - "where": "chart.level = '7'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8845,9 +9779,10 @@ "inactive": true, "legacyFolderID": "Ffb812d95d35bc15c343bcdb7f2828a144ea5d9d5430e97daca2ceda4c82905f0", "searchTerms": [], + "slug": "7-luminousplus-omni", "title": "Level 7 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8855,9 +9790,10 @@ "inactive": true, "legacyFolderID": "Fabe8788e27cb6307e556a6fc010102278401ded52a03f1b216e4d2f145784e11", "searchTerms": [], + "slug": "7-luminousplus", "title": "Level 7 (LUMINOUS PLUS)", - "where": "chart.level = '7'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8865,9 +9801,10 @@ "inactive": true, "legacyFolderID": "Fcc65c9348c52f250fefea82fd44fc73e4b426b1c99af52485f64a7a23203fd70", "searchTerms": [], + "slug": "7-luminous", "title": "Level 7 (LUMINOUS)", - "where": "chart.level = '7'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8875,9 +9812,10 @@ "inactive": true, "legacyFolderID": "F9473dbc87b2262d310a773969dfd6653b578e910fbf6c81cbf9be54b4156dd2e", "searchTerms": [], + "slug": "7-newplus", "title": "Level 7 (NEW PLUS)", - "where": "chart.level = '7'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8885,9 +9823,10 @@ "inactive": true, "legacyFolderID": "Fc413068925db3f5a5632bc6ae4c1c38c4bed6e331e3047658fe16ecd115484c7", "searchTerms": [], + "slug": "7-new", "title": "Level 7 (NEW)", - "where": "chart.level = '7'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8895,9 +9834,10 @@ "inactive": false, "legacyFolderID": "Fe30d512e03ec5836ebbd608a0528d823c0b08d6d5ee803095a4e716d9890b3cc", "searchTerms": [], + "slug": "7-paradiselost", "title": "Level 7 (Paradise Lost)", - "where": "chart.level = '7'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8905,9 +9845,10 @@ "inactive": true, "legacyFolderID": "F8f702b1c93cf393b37dbdffef7da6ba40b197db91f0a39d53835d3fc4bdca7f2", "searchTerms": [], + "slug": "7-sun-omni", "title": "Level 7 (SUN Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8915,9 +9856,10 @@ "inactive": true, "legacyFolderID": "Fb12d1177e28ab59251adf3aafa65cc045230e1605cff7be4cb6f20caa4433615", "searchTerms": [], + "slug": "7-sunplus-intl", "title": "Level 7 (SUN PLUS International)", - "where": "chart.level = '7'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8925,9 +9867,10 @@ "inactive": true, "legacyFolderID": "F4dd7fa4b306aaefd50fd3df826cb4761c13f8f3048e51f35bab6a69fe168d3be", "searchTerms": [], + "slug": "7-sunplus-omni", "title": "Level 7 (SUN PLUS Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8935,9 +9878,10 @@ "inactive": true, "legacyFolderID": "F52032389bfff41084d2e065612caf1d93f711874f5224a22d2853d8cb03712cf", "searchTerms": [], + "slug": "7-sunplus", "title": "Level 7 (SUN PLUS)", - "where": "chart.level = '7'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8945,9 +9889,10 @@ "inactive": true, "legacyFolderID": "F72443d44c04aaf8a2f1e8aded5776c5a5fb11189c7baa0378684460cdd8a0567", "searchTerms": [], + "slug": "7-sun", "title": "Level 7 (SUN)", - "where": "chart.level = '7'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8955,9 +9900,10 @@ "inactive": true, "legacyFolderID": "F0e4967b94a69a3c9095d201f92c15fff2f5e905b3ac0c08874f0807921f9da48", "searchTerms": [], + "slug": "7-verse-intl", "title": "Level 7 (VERSE International)", - "where": "chart.level = '7'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8965,9 +9911,10 @@ "inactive": false, "legacyFolderID": "F890017677653162f699190d7c24f186893a1569d5651204de85dc4bf841ab39e", "searchTerms": [], + "slug": "7-verse-omni", "title": "Level 7 (VERSE Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8975,9 +9922,10 @@ "inactive": false, "legacyFolderID": "F97c6b250c2a9c1332b95cdd519a55e0eab2f82e5c2c07eeb4c0bc016af665019", "searchTerms": [], + "slug": "7-verse", "title": "Level 7 (VERSE)", - "where": "chart.level = '7'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8985,9 +9933,10 @@ "inactive": false, "legacyFolderID": "F09652e226a90238fafbf488bd1a86a8f106d4c24490ea0847d3c792e274ccfe2", "searchTerms": [], + "slug": "7-xverse-intl", "title": "Level 7 (X-VERSE International)", - "where": "chart.level = '7'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -8995,9 +9944,10 @@ "inactive": false, "legacyFolderID": "F24633285c3dfadf192819fbe24510d30e570910fecb89d84d0be1f443b8baee6", "searchTerms": [], + "slug": "7-xverse-omni", "title": "Level 7 (X-VERSE Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -9005,9 +9955,10 @@ "inactive": false, "legacyFolderID": "F4d0778cfb84315310c148b546b9e3407b9c9ba824e490761cccc7320867c927b", "searchTerms": [], + "slug": "7-xverse", "title": "Level 7 (X-VERSE)", - "where": "chart.level = '7'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '7'" }, { "game": "chunithm", @@ -9015,9 +9966,10 @@ "inactive": true, "legacyFolderID": "F6ba6bf57291e9a13080b23ee6f9c8f105fe94282b1cb7d7dc4314e5938c737d4", "searchTerms": [], + "slug": "7p-luminous-intl", "title": "Level 7+ (LUMINOUS International)", - "where": "chart.level = '7+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9025,9 +9977,10 @@ "inactive": true, "legacyFolderID": "F14ef49f852bdb41eb37bece32e63a49b93682bee9327ea63162decac7d7b0121", "searchTerms": [], + "slug": "7p-luminous-omni", "title": "Level 7+ (LUMINOUS Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9035,9 +9988,10 @@ "inactive": true, "legacyFolderID": "F870f85275946a027c43cb3e2788e4c178b81ee178c98ee687ef4a8246554ef39", "searchTerms": [], + "slug": "7p-luminousplus-intl", "title": "Level 7+ (LUMINOUS PLUS International)", - "where": "chart.level = '7+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9045,9 +9999,10 @@ "inactive": true, "legacyFolderID": "F4081c4180e46b45ccc3580758e14629b8815dfe7de8de29cb81c0cf29a1a7fc2", "searchTerms": [], + "slug": "7p-luminousplus-omni", "title": "Level 7+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9055,9 +10010,10 @@ "inactive": true, "legacyFolderID": "Fe9dbbfe99163063d9eddc1bed0985161df9905569d9729ed5c2fe107ac97f219", "searchTerms": [], + "slug": "7p-luminousplus", "title": "Level 7+ (LUMINOUS PLUS)", - "where": "chart.level = '7+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9065,9 +10021,10 @@ "inactive": true, "legacyFolderID": "Ff7f5a11995c904bb2c64f8283115944a3b8e48d0a69fcae17d9730657a23bca4", "searchTerms": [], + "slug": "7p-luminous", "title": "Level 7+ (LUMINOUS)", - "where": "chart.level = '7+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9075,9 +10032,10 @@ "inactive": true, "legacyFolderID": "Fd4bbcbe48640317cfb159e45e23aef5d4a811194c672d4c77f163f2952a643cc", "searchTerms": [], + "slug": "7p-newplus", "title": "Level 7+ (NEW PLUS)", - "where": "chart.level = '7+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9085,9 +10043,10 @@ "inactive": true, "legacyFolderID": "Ff2d439d0d1aaedb6a960bf9c706b04e9cab6cbb1d1ca49971c4e73dcbd81ebbd", "searchTerms": [], + "slug": "7p-new", "title": "Level 7+ (NEW)", - "where": "chart.level = '7+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9095,9 +10054,10 @@ "inactive": false, "legacyFolderID": "Fe02e4f35e878508c5b1dcb24e7c13e74f840ccd8762341d8348c7a23550d6fa3", "searchTerms": [], + "slug": "7p-paradiselost", "title": "Level 7+ (Paradise Lost)", - "where": "chart.level = '7+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9105,9 +10065,10 @@ "inactive": true, "legacyFolderID": "F3267cfb34b3a885318146edccd3811d0aca519f9f06aa261d8a38fd0e59cba30", "searchTerms": [], + "slug": "7p-sun-omni", "title": "Level 7+ (SUN Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9115,9 +10076,10 @@ "inactive": true, "legacyFolderID": "F7e5ae76f52045e09f53efa3dd048a0f9a5c142af3d323cedd59eac06d52d1824", "searchTerms": [], + "slug": "7p-sunplus-intl", "title": "Level 7+ (SUN PLUS International)", - "where": "chart.level = '7+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9125,9 +10087,10 @@ "inactive": true, "legacyFolderID": "Fd4bc9a1731f2e20a525aba0931afd675ad9af8a46e0bf3a4f9dcb8f74503a75d", "searchTerms": [], + "slug": "7p-sunplus-omni", "title": "Level 7+ (SUN PLUS Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9135,9 +10098,10 @@ "inactive": true, "legacyFolderID": "Fb75347ab031c47cb0593c593d7a6497ab12e24b8968191205db544da71b6314f", "searchTerms": [], + "slug": "7p-sunplus", "title": "Level 7+ (SUN PLUS)", - "where": "chart.level = '7+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9145,9 +10109,10 @@ "inactive": true, "legacyFolderID": "Ff8b6d166d4779f98d5fd820ebe14bb4c941a70cfbac7f19e206aa58ce61cf598", "searchTerms": [], + "slug": "7p-sun", "title": "Level 7+ (SUN)", - "where": "chart.level = '7+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9155,9 +10120,10 @@ "inactive": true, "legacyFolderID": "F20996cb23ed6275e54108dbae480a729fdcce1e539c3309ba32476eddd0fd677", "searchTerms": [], + "slug": "7p-verse-intl", "title": "Level 7+ (VERSE International)", - "where": "chart.level = '7+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9165,9 +10131,10 @@ "inactive": false, "legacyFolderID": "F0bda1b95ee8e7f038e6ec6f0fb0eab06a65c22fe6ce4482612fd5426dbe4612c", "searchTerms": [], + "slug": "7p-verse-omni", "title": "Level 7+ (VERSE Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9175,9 +10142,10 @@ "inactive": false, "legacyFolderID": "Fe6163698276b1761318029526a258c86c08b411f1ca97f79a22e97ba8b79e03a", "searchTerms": [], + "slug": "7p-verse", "title": "Level 7+ (VERSE)", - "where": "chart.level = '7+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9185,9 +10153,10 @@ "inactive": false, "legacyFolderID": "F553d9f51b2546df599ab2ada36c2362db39244344693962919f742bc3fa083a2", "searchTerms": [], + "slug": "7p-xverse-intl", "title": "Level 7+ (X-VERSE International)", - "where": "chart.level = '7+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9195,9 +10164,10 @@ "inactive": false, "legacyFolderID": "F0f69bd36549a23eca398abc399eb3633f7088fe5698c3fdc879044ada5ab2a33", "searchTerms": [], + "slug": "7p-xverse-omni", "title": "Level 7+ (X-VERSE Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9205,9 +10175,10 @@ "inactive": false, "legacyFolderID": "Fd2bab4d3785eddb1c0658c722bab8a2642f82d8d90f56540a7ea4c79823af579", "searchTerms": [], + "slug": "7p-xverse", "title": "Level 7+ (X-VERSE)", - "where": "chart.level = '7+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '7+'" }, { "game": "chunithm", @@ -9215,9 +10186,10 @@ "inactive": true, "legacyFolderID": "Fb013c1d5fe6ee006513bde4f73a61439203b3d03cdaf5e0583e2880c664db710", "searchTerms": [], + "slug": "8-luminous-intl", "title": "Level 8 (LUMINOUS International)", - "where": "chart.level = '8'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9225,9 +10197,10 @@ "inactive": true, "legacyFolderID": "F29f593b162daa7ea20aac2b32e24b991cd78c9f216eda68572efb0225e58adbb", "searchTerms": [], + "slug": "8-luminous-omni", "title": "Level 8 (LUMINOUS Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9235,9 +10208,10 @@ "inactive": true, "legacyFolderID": "F79650fbf8bb42b7e0d50bcf0d3ca0050433690a37dd3caf7a746756f68820f72", "searchTerms": [], + "slug": "8-luminousplus-intl", "title": "Level 8 (LUMINOUS PLUS International)", - "where": "chart.level = '8'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9245,9 +10219,10 @@ "inactive": true, "legacyFolderID": "F29d05d578578284bfe71c5678b0c9b2cd6f2bdda99913498452ccfcf8c932680", "searchTerms": [], + "slug": "8-luminousplus-omni", "title": "Level 8 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9255,9 +10230,10 @@ "inactive": true, "legacyFolderID": "F39acf448f99291a353e704ac6bc0959117b78e8abdf7b2249bd15baa840746d8", "searchTerms": [], + "slug": "8-luminousplus", "title": "Level 8 (LUMINOUS PLUS)", - "where": "chart.level = '8'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9265,9 +10241,10 @@ "inactive": true, "legacyFolderID": "F7b23d0c2a540f69e4d87e44f364d66be358cff433db959cffcb690137c026db0", "searchTerms": [], + "slug": "8-luminous", "title": "Level 8 (LUMINOUS)", - "where": "chart.level = '8'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9275,9 +10252,10 @@ "inactive": true, "legacyFolderID": "Fe7f99a757fa0a1a0137e8b15fa53793afdc11929fd4e464d352d3f5b4182b0c2", "searchTerms": [], + "slug": "8-newplus", "title": "Level 8 (NEW PLUS)", - "where": "chart.level = '8'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9285,9 +10263,10 @@ "inactive": true, "legacyFolderID": "F352e020f3bdb1da68f16bc0f5e525110f0b8f2385bc469473a96ac3b18bcedf8", "searchTerms": [], + "slug": "8-new", "title": "Level 8 (NEW)", - "where": "chart.level = '8'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9295,9 +10274,10 @@ "inactive": false, "legacyFolderID": "F2ef4a7e09647d9924a2e1f6f5e07fa2ddcc4b8dfad30b1c5130a06cffa11241b", "searchTerms": [], + "slug": "8-paradiselost", "title": "Level 8 (Paradise Lost)", - "where": "chart.level = '8'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9305,9 +10285,10 @@ "inactive": true, "legacyFolderID": "F8f96042306eadc90f9553fc1910b9d9011a05af57e1ecd8f79cfd37ba6208fb5", "searchTerms": [], + "slug": "8-sun-omni", "title": "Level 8 (SUN Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9315,9 +10296,10 @@ "inactive": true, "legacyFolderID": "F4dd88f5b3e1f1cd321e6d2c6d9a10e87c2d31af31d78c28c867e97debf752289", "searchTerms": [], + "slug": "8-sunplus-intl", "title": "Level 8 (SUN PLUS International)", - "where": "chart.level = '8'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9325,9 +10307,10 @@ "inactive": true, "legacyFolderID": "F98038668999ab36d9cb3c3b7dcb9b2627d122f7939219fcf157809e9fec4fe70", "searchTerms": [], + "slug": "8-sunplus-omni", "title": "Level 8 (SUN PLUS Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9335,9 +10318,10 @@ "inactive": true, "legacyFolderID": "F0000508e3a7968d64eb9b1a7acea04293b48f82f392a705c5d086008bcce3513", "searchTerms": [], + "slug": "8-sunplus", "title": "Level 8 (SUN PLUS)", - "where": "chart.level = '8'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9345,9 +10329,10 @@ "inactive": true, "legacyFolderID": "F157c90044b9a570acc51d08e942952ac59f49dbbcc83fecacc001b7f4455363a", "searchTerms": [], + "slug": "8-sun", "title": "Level 8 (SUN)", - "where": "chart.level = '8'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9355,9 +10340,10 @@ "inactive": true, "legacyFolderID": "Fd6a6e308006c407fc684344eff547666ff5812ca672bd04999c7a864a2b5cf29", "searchTerms": [], + "slug": "8-verse-intl", "title": "Level 8 (VERSE International)", - "where": "chart.level = '8'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9365,9 +10351,10 @@ "inactive": false, "legacyFolderID": "Fd4a9d5cd2f299f6afc1dc6ea7db301760920bf0321df7e1d2d4add7147f4707a", "searchTerms": [], + "slug": "8-verse-omni", "title": "Level 8 (VERSE Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9375,9 +10362,10 @@ "inactive": false, "legacyFolderID": "Fd7e821ebd4bec3d9e6603ce2dd25f9298563fd1127123d1b3daa7242d1a8d7a5", "searchTerms": [], + "slug": "8-verse", "title": "Level 8 (VERSE)", - "where": "chart.level = '8'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9385,9 +10373,10 @@ "inactive": false, "legacyFolderID": "F52de6f14579ec9ee1c6430a19aff5b5f91b6db5ab0b1911eae535951ac5659d3", "searchTerms": [], + "slug": "8-xverse-intl", "title": "Level 8 (X-VERSE International)", - "where": "chart.level = '8'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9395,9 +10384,10 @@ "inactive": false, "legacyFolderID": "Fddd14e10560db50561fdaddbd01a175855b922f4fa9510a365c5cc9d08c7c311", "searchTerms": [], + "slug": "8-xverse-omni", "title": "Level 8 (X-VERSE Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9405,9 +10395,10 @@ "inactive": false, "legacyFolderID": "Ff943cdbcd5e3ef9dbf82bd0a6bf83997878d521477e4a1c24279155ea8f90742", "searchTerms": [], + "slug": "8-xverse", "title": "Level 8 (X-VERSE)", - "where": "chart.level = '8'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '8'" }, { "game": "chunithm", @@ -9415,9 +10406,10 @@ "inactive": true, "legacyFolderID": "F6caa9097594c1cbc3fc4fbb10ca836a965c074cab6bee4453aaf8ac3c4311ea7", "searchTerms": [], + "slug": "8p-luminous-intl", "title": "Level 8+ (LUMINOUS International)", - "where": "chart.level = '8+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9425,9 +10417,10 @@ "inactive": true, "legacyFolderID": "F16b9f6d6065116c8696abe7d0f08482b2d308921541240bf0fc18335a6f8a167", "searchTerms": [], + "slug": "8p-luminous-omni", "title": "Level 8+ (LUMINOUS Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9435,9 +10428,10 @@ "inactive": true, "legacyFolderID": "F1db57045b5e323f45502aabc1389c25e298db7b7efb023eb5239dd4a00a6e64b", "searchTerms": [], + "slug": "8p-luminousplus-intl", "title": "Level 8+ (LUMINOUS PLUS International)", - "where": "chart.level = '8+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9445,9 +10439,10 @@ "inactive": true, "legacyFolderID": "F6489d9da41523bfe0227b27d00ffcc53a18d520af99b514076fcca8393e05981", "searchTerms": [], + "slug": "8p-luminousplus-omni", "title": "Level 8+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9455,9 +10450,10 @@ "inactive": true, "legacyFolderID": "F0edb55167a88b402b23cfae6db578ea32f5d26fa41807f2b1286138b6ee43f1c", "searchTerms": [], + "slug": "8p-luminousplus", "title": "Level 8+ (LUMINOUS PLUS)", - "where": "chart.level = '8+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9465,9 +10461,10 @@ "inactive": true, "legacyFolderID": "Fc1ab6ba6670eea7d9f6bcb55d71583c07716b9dc4edac41db820c44abfd8caab", "searchTerms": [], + "slug": "8p-luminous", "title": "Level 8+ (LUMINOUS)", - "where": "chart.level = '8+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9475,9 +10472,10 @@ "inactive": true, "legacyFolderID": "F6077441a3b9caf7bb7ed913be885230747370a87ea82ad9cabfea4c9b3c867be", "searchTerms": [], + "slug": "8p-newplus", "title": "Level 8+ (NEW PLUS)", - "where": "chart.level = '8+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9485,9 +10483,10 @@ "inactive": true, "legacyFolderID": "F9f73c3305de89387c1f06929304a412a0714ea523ef39e4178e748b2b16fa7ad", "searchTerms": [], + "slug": "8p-new", "title": "Level 8+ (NEW)", - "where": "chart.level = '8+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9495,9 +10494,10 @@ "inactive": false, "legacyFolderID": "F0b8602ad7e72434442af14363455e20714a8812cff5a70b8645b87674f64d2dd", "searchTerms": [], + "slug": "8p-paradiselost", "title": "Level 8+ (Paradise Lost)", - "where": "chart.level = '8+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9505,9 +10505,10 @@ "inactive": true, "legacyFolderID": "F11692d498d01fbd1ade153f8e6816f1cc69d1e2e35491d1f979b1f5c894d3648", "searchTerms": [], + "slug": "8p-sun-omni", "title": "Level 8+ (SUN Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9515,9 +10516,10 @@ "inactive": true, "legacyFolderID": "F89ee31fd0170e9f855d58250d88dd6cac4a663551af611b45ebd3acfa7e24792", "searchTerms": [], + "slug": "8p-sunplus-intl", "title": "Level 8+ (SUN PLUS International)", - "where": "chart.level = '8+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9525,9 +10527,10 @@ "inactive": true, "legacyFolderID": "Fa182fb4ce0f2713445f5450d8640b6db9713a526fddc6269c4936741fad78642", "searchTerms": [], + "slug": "8p-sunplus-omni", "title": "Level 8+ (SUN PLUS Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9535,9 +10538,10 @@ "inactive": true, "legacyFolderID": "F3043d5369a0ebf9cfaaa85320a45ae5ead2837aced98e295eeeb188ecb927464", "searchTerms": [], + "slug": "8p-sunplus", "title": "Level 8+ (SUN PLUS)", - "where": "chart.level = '8+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9545,9 +10549,10 @@ "inactive": true, "legacyFolderID": "F35062bbebbde3f1634087459e4fe0d7e2467cb8f71b276c800253fbe42ef6ac5", "searchTerms": [], + "slug": "8p-sun", "title": "Level 8+ (SUN)", - "where": "chart.level = '8+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9555,9 +10560,10 @@ "inactive": true, "legacyFolderID": "Fbab8be33f1ccb08a6f134b5daf9742797b9008d6752e62d19f73fe6b1f40c231", "searchTerms": [], + "slug": "8p-verse-intl", "title": "Level 8+ (VERSE International)", - "where": "chart.level = '8+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9565,9 +10571,10 @@ "inactive": false, "legacyFolderID": "Fe30a4652f31d90ce4bbe370db7e3732d339528295492edb70088430e5d10edfb", "searchTerms": [], + "slug": "8p-verse-omni", "title": "Level 8+ (VERSE Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9575,9 +10582,10 @@ "inactive": false, "legacyFolderID": "Ff7ed7743d4f8ac64ae70bd373b15b91f83e0825ad9a79345cb28a68a58ae575c", "searchTerms": [], + "slug": "8p-verse", "title": "Level 8+ (VERSE)", - "where": "chart.level = '8+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9585,9 +10593,10 @@ "inactive": false, "legacyFolderID": "Fdaf7c832e51cfc23924513cbe44e2f1ad90f0665a6628269c2d2cb387d4329ad", "searchTerms": [], + "slug": "8p-xverse-intl", "title": "Level 8+ (X-VERSE International)", - "where": "chart.level = '8+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9595,9 +10604,10 @@ "inactive": false, "legacyFolderID": "Fc468ce4cbe28fb9767a1a0962673ef01cd3f6190aac88462896b4d0b9fa8d9c0", "searchTerms": [], + "slug": "8p-xverse-omni", "title": "Level 8+ (X-VERSE Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9605,9 +10615,10 @@ "inactive": false, "legacyFolderID": "F9bdbb4910f9706a2a8c347e6016119ee8b153f12d7408bb2ee999b24df16a871", "searchTerms": [], + "slug": "8p-xverse", "title": "Level 8+ (X-VERSE)", - "where": "chart.level = '8+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '8+'" }, { "game": "chunithm", @@ -9615,9 +10626,10 @@ "inactive": true, "legacyFolderID": "F0b191d8b6b5e369ef60ab51c24f9e7630517a5a4f589c320c6f92b0e9de105b8", "searchTerms": [], + "slug": "9-luminous-intl", "title": "Level 9 (LUMINOUS International)", - "where": "chart.level = '9'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9625,9 +10637,10 @@ "inactive": true, "legacyFolderID": "F02af792a3c99a66a99aad0c53139204d8492d90b60c56fd29f6794fd854283db", "searchTerms": [], + "slug": "9-luminous-omni", "title": "Level 9 (LUMINOUS Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9635,9 +10648,10 @@ "inactive": true, "legacyFolderID": "F4232dd3d2521febed8b67966b907dcfc63e097232c95fb88c88b1238f1040a5c", "searchTerms": [], + "slug": "9-luminousplus-intl", "title": "Level 9 (LUMINOUS PLUS International)", - "where": "chart.level = '9'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9645,9 +10659,10 @@ "inactive": true, "legacyFolderID": "F850739281b03d25d7c0cb19616eb6b9b8cd97d1ed28fbdd820fd8860aca7bc35", "searchTerms": [], + "slug": "9-luminousplus-omni", "title": "Level 9 (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9655,9 +10670,10 @@ "inactive": true, "legacyFolderID": "Fcf6bad4316640b217d7e9415a939ba640613fd36b28f4cf6b48d41ad93b09d3e", "searchTerms": [], + "slug": "9-luminousplus", "title": "Level 9 (LUMINOUS PLUS)", - "where": "chart.level = '9'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9665,9 +10681,10 @@ "inactive": true, "legacyFolderID": "F7408737de948a0821d8f9ef999cb5f1cfc550b10b486e70f02e950da36e061c3", "searchTerms": [], + "slug": "9-luminous", "title": "Level 9 (LUMINOUS)", - "where": "chart.level = '9'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9675,9 +10692,10 @@ "inactive": true, "legacyFolderID": "Fd0262b7d9fe303e55a42095ea346c54550678afd461d694bc019be82aa96c474", "searchTerms": [], + "slug": "9-newplus", "title": "Level 9 (NEW PLUS)", - "where": "chart.level = '9'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9685,9 +10703,10 @@ "inactive": true, "legacyFolderID": "Ff22c0c54b4b64dc12d3ddb78f2968056f1a79cccbb18d69c7ac7d16d8345c95b", "searchTerms": [], + "slug": "9-new", "title": "Level 9 (NEW)", - "where": "chart.level = '9'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9695,9 +10714,10 @@ "inactive": false, "legacyFolderID": "F1972242bf9a2aa0b17ff4f33447590b79913d213beb9bbf0ba9552e10e718b54", "searchTerms": [], + "slug": "9-paradiselost", "title": "Level 9 (Paradise Lost)", - "where": "chart.level = '9'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9705,9 +10725,10 @@ "inactive": true, "legacyFolderID": "F7f97db16a4c66fdb4ffe998f8dc2c040c4a1cb0921d20896b117c917068ccdb3", "searchTerms": [], + "slug": "9-sun-omni", "title": "Level 9 (SUN Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9715,9 +10736,10 @@ "inactive": true, "legacyFolderID": "F5f44928a5bd45258b73275e6f8f9b2449ffe9a1626a1e1d213b32df32ade9a08", "searchTerms": [], + "slug": "9-sunplus-intl", "title": "Level 9 (SUN PLUS International)", - "where": "chart.level = '9'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9725,9 +10747,10 @@ "inactive": true, "legacyFolderID": "F0c16f75839ad4e076e914909592048ca4f698fadb15d5270ee5d07d11f2574d0", "searchTerms": [], + "slug": "9-sunplus-omni", "title": "Level 9 (SUN PLUS Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9735,9 +10758,10 @@ "inactive": true, "legacyFolderID": "F6ca30736ede1f303ae820da9a253599a0c84e940f6f7e8b57ccc0648b6646519", "searchTerms": [], + "slug": "9-sunplus", "title": "Level 9 (SUN PLUS)", - "where": "chart.level = '9'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9745,9 +10769,10 @@ "inactive": true, "legacyFolderID": "F0920882a673a8f9fa6ba4aeb57042c55629133395e22d90e6ee959245c4b4b40", "searchTerms": [], + "slug": "9-sun", "title": "Level 9 (SUN)", - "where": "chart.level = '9'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9755,9 +10780,10 @@ "inactive": true, "legacyFolderID": "Fa89bcaf0580e432bf1c9514c57f01b77bb5f810337e51b3a53b8e6cf1fe7e54a", "searchTerms": [], + "slug": "9-verse-intl", "title": "Level 9 (VERSE International)", - "where": "chart.level = '9'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9765,9 +10791,10 @@ "inactive": false, "legacyFolderID": "Fb95d6cfc40c2f082e53d584c59e0a500db3a658a0d3ca9d9a292fb2c334d8f03", "searchTerms": [], + "slug": "9-verse-omni", "title": "Level 9 (VERSE Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9775,9 +10802,10 @@ "inactive": false, "legacyFolderID": "F8095deec17ca582f3d51c860f65d4b6fd612ee1040bce896ba64dfc69fdfe427", "searchTerms": [], + "slug": "9-verse", "title": "Level 9 (VERSE)", - "where": "chart.level = '9'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9785,9 +10813,10 @@ "inactive": false, "legacyFolderID": "Fccdcc8c02073ac12e7dd165f8d55a2a17efbcb58faa4d8fa6a7850f15883b1c7", "searchTerms": [], + "slug": "9-xverse-intl", "title": "Level 9 (X-VERSE International)", - "where": "chart.level = '9'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9795,9 +10824,10 @@ "inactive": false, "legacyFolderID": "F15eb5fb557bd60d06aa79ef4014455a021178eb8a4b638cf4636a97de0a2bbc1", "searchTerms": [], + "slug": "9-xverse-omni", "title": "Level 9 (X-VERSE Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9805,9 +10835,10 @@ "inactive": false, "legacyFolderID": "F8682445afed83b8ed7595629415ed705867f133f9e92345e1662232ad0e13961", "searchTerms": [], + "slug": "9-xverse", "title": "Level 9 (X-VERSE)", - "where": "chart.level = '9'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '9'" }, { "game": "chunithm", @@ -9815,9 +10846,10 @@ "inactive": true, "legacyFolderID": "F52774d028c8ff0d1327fb5505a40a9f87cef22b53d192d6a5295f45c15cf2a80", "searchTerms": [], + "slug": "9p-luminous-intl", "title": "Level 9+ (LUMINOUS International)", - "where": "chart.level = '9+'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9825,9 +10857,10 @@ "inactive": true, "legacyFolderID": "F2d9dbd2ded745f2f97db840423c1b3de4cd46c40ef2c68f61f95fc06e3ed0a6c", "searchTerms": [], + "slug": "9p-luminous-omni", "title": "Level 9+ (LUMINOUS Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9835,9 +10868,10 @@ "inactive": true, "legacyFolderID": "F08f7ad9714d17dbe103c653307fb3996027c6ae969173551eb08fbcf30076ffd", "searchTerms": [], + "slug": "9p-luminousplus-intl", "title": "Level 9+ (LUMINOUS PLUS International)", - "where": "chart.level = '9+'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9845,9 +10879,10 @@ "inactive": true, "legacyFolderID": "Fb07c3e51e26e968b608c4beb678a2fe96451e3bc9c280ba7ea6cf6cdd7905950", "searchTerms": [], + "slug": "9p-luminousplus-omni", "title": "Level 9+ (LUMINOUS PLUS Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9855,9 +10890,10 @@ "inactive": true, "legacyFolderID": "Fa255dba103d90581cf69d3d8a61296932fc06ee65b2fb90d9e23bf3f0c7717cb", "searchTerms": [], + "slug": "9p-luminousplus", "title": "Level 9+ (LUMINOUS PLUS)", - "where": "chart.level = '9+'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9865,9 +10901,10 @@ "inactive": true, "legacyFolderID": "Fba599b0ca4a0e40dd820f0ef8df88a6e2fbe293185d04b4b5dcdf1705d5479b0", "searchTerms": [], + "slug": "9p-luminous", "title": "Level 9+ (LUMINOUS)", - "where": "chart.level = '9+'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9875,9 +10912,10 @@ "inactive": true, "legacyFolderID": "Fe0cb7257cce8dcad60c1a88eb0423a6e6656486a146f76686fed16988be0a8b3", "searchTerms": [], + "slug": "9p-newplus", "title": "Level 9+ (NEW PLUS)", - "where": "chart.level = '9+'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9885,9 +10923,10 @@ "inactive": true, "legacyFolderID": "Fb5f1f783adbd2911a82c263483696a7eee0c70c9773fa9f7a0709784d5b0b51f", "searchTerms": [], + "slug": "9p-new", "title": "Level 9+ (NEW)", - "where": "chart.level = '9+'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9895,9 +10934,10 @@ "inactive": false, "legacyFolderID": "Ff3a04b25e1100d17e5b030ab275534ce950b8afab1f0eca5e96eb99b837c24eb", "searchTerms": [], + "slug": "9p-paradiselost", "title": "Level 9+ (Paradise Lost)", - "where": "chart.level = '9+'", - "versionFilter": ["paradiselost"] + "versionFilter": ["paradiselost"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9905,9 +10945,10 @@ "inactive": true, "legacyFolderID": "Fd89d43ad4028245895acd793dbdb503db0051cf3a848d5174a4f9fb0948b1589", "searchTerms": [], + "slug": "9p-sun-omni", "title": "Level 9+ (SUN Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9915,9 +10956,10 @@ "inactive": true, "legacyFolderID": "F8959296d279a6a1fda4fc6f434e9e0c21e669c5208245bba6038cba6ede6c3be", "searchTerms": [], + "slug": "9p-sunplus-intl", "title": "Level 9+ (SUN PLUS International)", - "where": "chart.level = '9+'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9925,9 +10967,10 @@ "inactive": true, "legacyFolderID": "Fe6f10d1dc4369099ce160042975c254acace51e0214c5b52c32887311af216fe", "searchTerms": [], + "slug": "9p-sunplus-omni", "title": "Level 9+ (SUN PLUS Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9935,9 +10978,10 @@ "inactive": true, "legacyFolderID": "Fe32f5e35341d47eb612727bc6a88de1ebf7f60df4e335b51eacb788ff7983226", "searchTerms": [], + "slug": "9p-sunplus", "title": "Level 9+ (SUN PLUS)", - "where": "chart.level = '9+'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9945,9 +10989,10 @@ "inactive": true, "legacyFolderID": "Fa3046e8dfc86ba9062261b82ebfc0080d96027ebbc230bc693c3f3ac7aad6a8f", "searchTerms": [], + "slug": "9p-sun", "title": "Level 9+ (SUN)", - "where": "chart.level = '9+'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9955,9 +11000,10 @@ "inactive": true, "legacyFolderID": "Fb874f12accd7cfd5fc052b29893b15b289a57b4bef6c16aed6a0f17d37e8b3c8", "searchTerms": [], + "slug": "9p-verse-intl", "title": "Level 9+ (VERSE International)", - "where": "chart.level = '9+'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9965,9 +11011,10 @@ "inactive": false, "legacyFolderID": "F0c44329d76573d70c94af14daa00ff50752e31a31a855ee663487fe25bad0c26", "searchTerms": [], + "slug": "9p-verse-omni", "title": "Level 9+ (VERSE Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9975,9 +11022,10 @@ "inactive": false, "legacyFolderID": "Fb5caebfca2d9e65c0a19d844634c0fb6e924684f5a97340cb010d51607472156", "searchTerms": [], + "slug": "9p-verse", "title": "Level 9+ (VERSE)", - "where": "chart.level = '9+'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9985,9 +11033,10 @@ "inactive": false, "legacyFolderID": "F6513449a98265e3c63f3b37f33f72cde1b27e22207a0a153ab95b62f09b60527", "searchTerms": [], + "slug": "9p-xverse-intl", "title": "Level 9+ (X-VERSE International)", - "where": "chart.level = '9+'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -9995,9 +11044,10 @@ "inactive": false, "legacyFolderID": "F7c3ded39712c123fbc009b2ed2e7e0192e109e5d71a8988afc4ca04bc9a7e49d", "searchTerms": [], + "slug": "9p-xverse-omni", "title": "Level 9+ (X-VERSE Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -10005,9 +11055,10 @@ "inactive": false, "legacyFolderID": "Fe6f718c84cf23f0b344de37e30f86e01c9708a2ae9d6c13f97a6542a2bc6671b", "searchTerms": [], + "slug": "9p-xverse", "title": "Level 9+ (X-VERSE)", - "where": "chart.level = '9+'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.level = '9+'" }, { "game": "chunithm", @@ -10015,9 +11066,10 @@ "inactive": true, "legacyFolderID": "F58d79bbb95d21d2a1327a4af8367d392f174b3312e06b6ca7f980dc17d8795c1", "searchTerms": [], + "slug": "master-luminous-intl", "title": "MASTER (LUMINOUS International)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10025,9 +11077,10 @@ "inactive": true, "legacyFolderID": "F2cc0dfdb21af7961c669709543912c0e00eca3e942ca9b39d9ad14747ca4d147", "searchTerms": [], + "slug": "master-luminous-omni", "title": "MASTER (LUMINOUS Omnimix)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10035,9 +11088,10 @@ "inactive": true, "legacyFolderID": "Fb18726ae0ee60d218a4cad912b01c557c60a01da506d59b38207949d09cf31d1", "searchTerms": [], + "slug": "master-luminousplus-intl", "title": "MASTER (LUMINOUS PLUS International)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10045,9 +11099,10 @@ "inactive": true, "legacyFolderID": "Fa7290721adc0412398202b87e0d49894cf008da9399bfd52c6426456bfeb2c4e", "searchTerms": [], + "slug": "master-luminousplus-omni", "title": "MASTER (LUMINOUS PLUS Omnimix)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10055,9 +11110,10 @@ "inactive": true, "legacyFolderID": "F2a6420ee644d2adca885820d2810bce78108eca0b79897dad75b2557868603e7", "searchTerms": [], + "slug": "master-luminousplus", "title": "MASTER (LUMINOUS PLUS)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10065,9 +11121,10 @@ "inactive": true, "legacyFolderID": "F9ea15d2aa0a17e9902019e851d7ba78ac4f68019e25dc4230c437dce296d96a6", "searchTerms": [], + "slug": "master-luminous", "title": "MASTER (LUMINOUS)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10075,9 +11132,10 @@ "inactive": true, "legacyFolderID": "F6a8bc92d71dfc73ea5da3d7c5744137b6a1bc4090186997161d66c278a2c16de", "searchTerms": [], + "slug": "master-newplus", "title": "MASTER (NEW PLUS)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10085,9 +11143,10 @@ "inactive": true, "legacyFolderID": "F0e6859f484a3abdd40f9f4ecf9366c7807b8ec83144f02bd117742a11ad9e392", "searchTerms": [], + "slug": "master-new", "title": "MASTER (NEW)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10095,9 +11154,10 @@ "inactive": true, "legacyFolderID": "F68951200549b9cd90b5f300bb1c2e8c8e69072cfd7485ae5e7b8c6b381807ce5", "searchTerms": [], + "slug": "master-sun-omni", "title": "MASTER (SUN Omnimix)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10105,9 +11165,10 @@ "inactive": true, "legacyFolderID": "F15c9c17ee37db494cf13a5a3bd86e2223a39c2b99005c02d866587ceb1772aad", "searchTerms": [], + "slug": "master-sunplus-intl", "title": "MASTER (SUN PLUS International)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10115,9 +11176,10 @@ "inactive": true, "legacyFolderID": "Fe2e4690088deda4e3efd34c6f172a37534adfdca6ca4988654168bc29de2fa6f", "searchTerms": [], + "slug": "master-sunplus-omni", "title": "MASTER (SUN PLUS Omnimix)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10125,9 +11187,10 @@ "inactive": true, "legacyFolderID": "Fc40045c378f96b749c539691e1c1b79bb64b313c4fb9ef2916b42e2d6d7d14f2", "searchTerms": [], + "slug": "master-sunplus", "title": "MASTER (SUN PLUS)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10135,9 +11198,10 @@ "inactive": true, "legacyFolderID": "Ffa96bd9e13bc807a7aea431e5169ef951c3517eafe96b059efede2c1b40c3bd2", "searchTerms": [], + "slug": "master-sun", "title": "MASTER (SUN)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10145,9 +11209,10 @@ "inactive": true, "legacyFolderID": "F875924b229ee264a74d08c1b9f0519bd44a433fb123d8803ad0f4de2b509e5fa", "searchTerms": [], + "slug": "master-verse-intl", "title": "MASTER (VERSE International)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10155,9 +11220,10 @@ "inactive": false, "legacyFolderID": "Faf2fd8d2bbd61e3222725121a9865ef271b2ac29d405d2929a0f74be5a462b85", "searchTerms": [], + "slug": "master-verse-omni", "title": "MASTER (VERSE Omnimix)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10165,9 +11231,10 @@ "inactive": false, "legacyFolderID": "Fc1925199b5141d40001d302fdb7842e82e650fbe7d850749f8922f1264818ca4", "searchTerms": [], + "slug": "master-verse", "title": "MASTER (VERSE)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10175,9 +11242,10 @@ "inactive": false, "legacyFolderID": "F4189916efac866a65bc9f9ab73900cbcc6f81a5b6906d049673de566f3b746d2", "searchTerms": [], + "slug": "master-xverse-intl", "title": "MASTER (X-VERSE International)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10185,9 +11253,10 @@ "inactive": false, "legacyFolderID": "F9e47bf5304b86844e274859134c54236dbf9a6314b8eb9d35aa2cfa0b5640ead", "searchTerms": [], + "slug": "master-xverse-omni", "title": "MASTER (X-VERSE Omnimix)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10195,9 +11264,10 @@ "inactive": false, "legacyFolderID": "F14463ead732fa6f8bb8d050fd439f9823dbd77aa700c1a17436efe92565b29e5", "searchTerms": [], + "slug": "master-xverse", "title": "MASTER (X-VERSE)", - "where": "chart.difficulty = 'MASTER'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.difficulty = 'MASTER'" }, { "game": "chunithm", @@ -10205,9 +11275,10 @@ "inactive": true, "legacyFolderID": "F73ed1fffd54e77a99e038e340410c776b6d2a2028b115a5c2fc7daf99d44980e", "searchTerms": [], + "slug": "master-ultima-luminousplus-omni", "title": "MASTER+ULTIMA (LUMINOUS PLUS Omnimix)", - "where": "chart.difficulty IN ('MASTER', 'ULTIMA')", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.difficulty IN ('MASTER', 'ULTIMA')" }, { "game": "chunithm", @@ -10215,9 +11286,10 @@ "inactive": true, "legacyFolderID": "Fdc7e7e6ae0b83372f68c4821c4cace37d488f621dad5e009108dc9406cf74957", "searchTerms": [], + "slug": "master-ultima-luminousplus", "title": "MASTER+ULTIMA (LUMINOUS PLUS)", - "where": "chart.difficulty IN ('MASTER', 'ULTIMA')", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.difficulty IN ('MASTER', 'ULTIMA')" }, { "game": "chunithm", @@ -10225,9 +11297,10 @@ "inactive": true, "legacyFolderID": "F5c89f008e387271e01a12abfca85aef07e295c478f7266caf82d204d85bb1aa8", "searchTerms": [], + "slug": "master-ultima-verse-intl", "title": "MASTER+ULTIMA (VERSE International)", - "where": "chart.difficulty IN ('MASTER', 'ULTIMA')", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.difficulty IN ('MASTER', 'ULTIMA')" }, { "game": "chunithm", @@ -10235,9 +11308,10 @@ "inactive": false, "legacyFolderID": "F5f1b5dea699911f8d656b0c82179b0922eed18684f61f0dc31b4468d12c93f75", "searchTerms": [], + "slug": "master-ultima-verse-omni", "title": "MASTER+ULTIMA (VERSE Omnimix)", - "where": "chart.difficulty IN ('MASTER', 'ULTIMA')", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.difficulty IN ('MASTER', 'ULTIMA')" }, { "game": "chunithm", @@ -10245,9 +11319,10 @@ "inactive": false, "legacyFolderID": "F7619677b79823975f6364254b0661e5575814b49713f8e8962d63bcc3b27dfe8", "searchTerms": [], + "slug": "master-ultima-verse", "title": "MASTER+ULTIMA (VERSE)", - "where": "chart.difficulty IN ('MASTER', 'ULTIMA')", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.difficulty IN ('MASTER', 'ULTIMA')" }, { "game": "chunithm", @@ -10255,9 +11330,10 @@ "inactive": false, "legacyFolderID": "Fee202376cdba6c231f02a9dc81d5c11eb31d8012e7326d5b254a6a307b06d992", "searchTerms": [], + "slug": "master-ultima-xverse-intl", "title": "MASTER+ULTIMA (X-VERSE International)", - "where": "chart.difficulty IN ('MASTER', 'ULTIMA')", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.difficulty IN ('MASTER', 'ULTIMA')" }, { "game": "chunithm", @@ -10265,9 +11341,10 @@ "inactive": false, "legacyFolderID": "F09392625378737b320168c8e1554162dfa249e938366b49836bd5cd0df2e7ef4", "searchTerms": [], + "slug": "master-ultima-xverse-omni", "title": "MASTER+ULTIMA (X-VERSE Omnimix)", - "where": "chart.difficulty IN ('MASTER', 'ULTIMA')", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.difficulty IN ('MASTER', 'ULTIMA')" }, { "game": "chunithm", @@ -10275,9 +11352,10 @@ "inactive": false, "legacyFolderID": "F4e83259002f2ebc240e7e655644eeafc86917e1db0135643f269dab8a94a7a02", "searchTerms": [], + "slug": "master-ultima-xverse", "title": "MASTER+ULTIMA (X-VERSE)", - "where": "chart.difficulty IN ('MASTER', 'ULTIMA')", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.difficulty IN ('MASTER', 'ULTIMA')" }, { "game": "chunithm", @@ -10285,9 +11363,10 @@ "inactive": true, "legacyFolderID": "F4d248317eb75d3de99114bd421a0dd2db878190136e2c72c9eb38159802b98fe", "searchTerms": [], + "slug": "ultima-luminous-intl", "title": "ULTIMA (LUMINOUS International)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["luminous-intl"] + "versionFilter": ["luminous-intl"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10295,9 +11374,10 @@ "inactive": true, "legacyFolderID": "F441dec800cb26c96b8305ce9a1ea2ff56608a4e2b889f4676b6d4eaa814b27f7", "searchTerms": [], + "slug": "ultima-luminous-omni", "title": "ULTIMA (LUMINOUS Omnimix)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["luminous-omni"] + "versionFilter": ["luminous-omni"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10305,9 +11385,10 @@ "inactive": true, "legacyFolderID": "F341bd38f44b01bba76ce22e8bb7b285f82457038b4ce978d884d1bf8c2012e7b", "searchTerms": [], + "slug": "ultima-luminousplus-intl", "title": "ULTIMA (LUMINOUS PLUS International)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["luminousplus-intl"] + "versionFilter": ["luminousplus-intl"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10315,9 +11396,10 @@ "inactive": true, "legacyFolderID": "F20a16eab0f0cdccb3065b9fae9cc9e68da8d53889415fac7969c989b30eed597", "searchTerms": [], + "slug": "ultima-luminousplus-omni", "title": "ULTIMA (LUMINOUS PLUS Omnimix)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["luminousplus-omni"] + "versionFilter": ["luminousplus-omni"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10325,9 +11407,10 @@ "inactive": true, "legacyFolderID": "F94dec7f14167a9ae0209e8169fc56d6cdea2b31f940cfb98b8e111c4d3abd541", "searchTerms": [], + "slug": "ultima-luminousplus", "title": "ULTIMA (LUMINOUS PLUS)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["luminousplus"] + "versionFilter": ["luminousplus"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10335,9 +11418,10 @@ "inactive": true, "legacyFolderID": "Fb1440a42e16b38e3aff4d0ce3973ab96d622b80357305e46dff66726afebde73", "searchTerms": [], + "slug": "ultima-luminous", "title": "ULTIMA (LUMINOUS)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["luminous"] + "versionFilter": ["luminous"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10345,9 +11429,10 @@ "inactive": true, "legacyFolderID": "Fd6a94db3cac613d154a826fb210ed7de035d9ae77814a46f852f091adb474828", "searchTerms": [], + "slug": "ultima-newplus", "title": "ULTIMA (NEW PLUS)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["newplus"] + "versionFilter": ["newplus"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10355,9 +11440,10 @@ "inactive": true, "legacyFolderID": "F6523178bc12cf2ea88bd4d1d00cc00d94165d4fb4d3170d5cd826f227fe12c7a", "searchTerms": [], + "slug": "ultima-new", "title": "ULTIMA (NEW)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["new"] + "versionFilter": ["new"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10365,9 +11451,10 @@ "inactive": true, "legacyFolderID": "F81e330f6b88a5c7715a84a9842a8900ed54f63a16da4f0a05ec55ca880705d41", "searchTerms": [], + "slug": "ultima-sun-omni", "title": "ULTIMA (SUN Omnimix)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["sun-omni"] + "versionFilter": ["sun-omni"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10375,9 +11462,10 @@ "inactive": true, "legacyFolderID": "F95235a5e7bdec38e18306a2e9d49c14699dac4b8117dec637eade33e1b34ca1a", "searchTerms": [], + "slug": "ultima-sunplus-intl", "title": "ULTIMA (SUN PLUS International)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["sunplus-intl"] + "versionFilter": ["sunplus-intl"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10385,9 +11473,10 @@ "inactive": true, "legacyFolderID": "F3c4525fa43cd873d5d089e91ba88becc448a5b0c2249b8fc39cd61b854800de6", "searchTerms": [], + "slug": "ultima-sunplus-omni", "title": "ULTIMA (SUN PLUS Omnimix)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["sunplus-omni"] + "versionFilter": ["sunplus-omni"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10395,9 +11484,10 @@ "inactive": true, "legacyFolderID": "Fd64cc883d1107033fdecf9154d8e158f26b4a04144c285b3a38490b38e43de8a", "searchTerms": [], + "slug": "ultima-sunplus", "title": "ULTIMA (SUN PLUS)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["sunplus"] + "versionFilter": ["sunplus"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10405,9 +11495,10 @@ "inactive": true, "legacyFolderID": "F632be66a0e43ed3fbcfac5c3c4555c4c9b693b365d62f5337765b7a93448546c", "searchTerms": [], + "slug": "ultima-sun", "title": "ULTIMA (SUN)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["sun"] + "versionFilter": ["sun"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10415,9 +11506,10 @@ "inactive": true, "legacyFolderID": "F3c55f07102751c31dc008af7cae3982340515cf4e68c802ecb7ac04a9c760a84", "searchTerms": [], + "slug": "ultima-verse-intl", "title": "ULTIMA (VERSE International)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["verse-intl"] + "versionFilter": ["verse-intl"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10425,9 +11517,10 @@ "inactive": false, "legacyFolderID": "Ffcf22e8ec59471aaf485559436aa87f744695e5942c28fd45e24b4b9c22838dd", "searchTerms": [], + "slug": "ultima-verse-omni", "title": "ULTIMA (VERSE Omnimix)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["verse-omni"] + "versionFilter": ["verse-omni"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10435,9 +11528,10 @@ "inactive": false, "legacyFolderID": "F285ddbd751c99661c03a15adbc52bf7bffac4f23312025876638cbf612f36348", "searchTerms": [], + "slug": "ultima-verse", "title": "ULTIMA (VERSE)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["verse"] + "versionFilter": ["verse"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10445,9 +11539,10 @@ "inactive": false, "legacyFolderID": "F0cd842b13c2f6e0e9d50f0acfea08ccccefcd08a63345364c2c674d9fc63c393", "searchTerms": [], + "slug": "ultima-xverse-intl", "title": "ULTIMA (X-VERSE International)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["xverse-intl"] + "versionFilter": ["xverse-intl"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10455,9 +11550,10 @@ "inactive": false, "legacyFolderID": "F660ce2d42c07dbcb642ac028464813a5038955afa9a873e58ea072ac5f9dcfaf", "searchTerms": [], + "slug": "ultima-xverse-omni", "title": "ULTIMA (X-VERSE Omnimix)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["xverse-omni"] + "versionFilter": ["xverse-omni"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "chunithm", @@ -10465,9 +11561,10 @@ "inactive": false, "legacyFolderID": "F205e23b63ffede2e7cc8a8ad71a270316bec837be31401393ab6250329479f22", "searchTerms": [], + "slug": "ultima-xverse", "title": "ULTIMA (X-VERSE)", - "where": "chart.difficulty = 'ULTIMA'", - "versionFilter": ["xverse"] + "versionFilter": ["xverse"], + "where": "chart.difficulty = 'ULTIMA'" }, { "game": "ddr-dp", @@ -10475,9 +11572,10 @@ "inactive": false, "legacyFolderID": "F122233e24f8b841183b974b0171db3931b0ea237fe7e9be7e90331220f94c3a2", "searchTerms": [], + "slug": "1-a", "title": "Level 1 (a)", - "where": "chart.level = '1'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '1'" }, { "game": "ddr-dp", @@ -10485,9 +11583,10 @@ "inactive": false, "legacyFolderID": "F9cfb5fc3b40592366d252c9c52a66bb965076cf14905ae48c2d2868a2850cc12", "searchTerms": [], + "slug": "1-a20", "title": "Level 1 (a20)", - "where": "chart.level = '1'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '1'" }, { "game": "ddr-dp", @@ -10495,9 +11594,10 @@ "inactive": false, "legacyFolderID": "Fc5135f25fab0e9ec929ec5a3518da32f28998d7eaac9bd310ef60187fcc0eb99", "searchTerms": [], + "slug": "1-a20plus", "title": "Level 1 (a20plus)", - "where": "chart.level = '1'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '1'" }, { "game": "ddr-dp", @@ -10505,9 +11605,10 @@ "inactive": false, "legacyFolderID": "F3087a1086b380d2bb05bd1287c6fb7cd0ec6e4ab7390cac360e8fa8db0bc2184", "searchTerms": [], + "slug": "1-a3", "title": "Level 1 (a3)", - "where": "chart.level = '1'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '1'" }, { "game": "ddr-dp", @@ -10515,9 +11616,10 @@ "inactive": false, "legacyFolderID": "F32672e622d8b6ae5d3c41731aa15d53fe2dd7d07cc128b1721e14facfc5907f3", "searchTerms": [], + "slug": "1-konaste", "title": "Level 1 (konaste)", - "where": "chart.level = '1'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '1'" }, { "game": "ddr-dp", @@ -10525,9 +11627,10 @@ "inactive": false, "legacyFolderID": "Fa1613160d15a414f057cac84ef3c2ebf6da5e1365f472ce291a729113d9bfdaa", "searchTerms": [], + "slug": "1-world", "title": "Level 1 (world)", - "where": "chart.level = '1'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '1'" }, { "game": "ddr-dp", @@ -10535,9 +11638,10 @@ "inactive": false, "legacyFolderID": "F093e4dbdc1c3e69751576cdc6eb80e99faf2ed59e871650745248daf5e4fd0de", "searchTerms": [], + "slug": "10-a", "title": "Level 10 (a)", - "where": "chart.level = '10'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '10'" }, { "game": "ddr-dp", @@ -10545,9 +11649,10 @@ "inactive": false, "legacyFolderID": "Fdbaaa3a3962160e35d47a4c28d4bacce81163e86c627f512b704a4601565ff27", "searchTerms": [], + "slug": "10-a20", "title": "Level 10 (a20)", - "where": "chart.level = '10'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '10'" }, { "game": "ddr-dp", @@ -10555,9 +11660,10 @@ "inactive": false, "legacyFolderID": "Fd4e7d30407a052044e31330bba25af71cf6649211e3783f2be3ccb0a6c983a17", "searchTerms": [], + "slug": "10-a20plus", "title": "Level 10 (a20plus)", - "where": "chart.level = '10'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '10'" }, { "game": "ddr-dp", @@ -10565,9 +11671,10 @@ "inactive": false, "legacyFolderID": "Fe3e323c84ec7dca96d941ad20d2e9c97e9b203cd8d41045d4455afb1475be609", "searchTerms": [], + "slug": "10-a3", "title": "Level 10 (a3)", - "where": "chart.level = '10'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '10'" }, { "game": "ddr-dp", @@ -10575,9 +11682,10 @@ "inactive": false, "legacyFolderID": "Fc2e213917d62243888ae885989c07bfcc0a012f3293e5191be867d079927af16", "searchTerms": [], + "slug": "10-konaste", "title": "Level 10 (konaste)", - "where": "chart.level = '10'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '10'" }, { "game": "ddr-dp", @@ -10585,9 +11693,10 @@ "inactive": false, "legacyFolderID": "F8c56542310d56d843cb0e36514013adec01a3c1c30939fd1891b56d1df2fe52c", "searchTerms": [], + "slug": "10-world", "title": "Level 10 (world)", - "where": "chart.level = '10'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '10'" }, { "game": "ddr-dp", @@ -10595,9 +11704,10 @@ "inactive": false, "legacyFolderID": "F56da82bc6a5eed8657d26d7a23e11ad6f94b11bfd0225984efe40d7f05ee07d4", "searchTerms": [], + "slug": "11-a", "title": "Level 11 (a)", - "where": "chart.level = '11'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '11'" }, { "game": "ddr-dp", @@ -10605,9 +11715,10 @@ "inactive": false, "legacyFolderID": "Fa975a4e6a0ec3aedfa805a5565c93f7db3e48dc9ed8dd9a23c92c99cc0009344", "searchTerms": [], + "slug": "11-a20", "title": "Level 11 (a20)", - "where": "chart.level = '11'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '11'" }, { "game": "ddr-dp", @@ -10615,9 +11726,10 @@ "inactive": false, "legacyFolderID": "F139f7c1829cdedd07251a2cc85f19cb86b1564fe0d476ab711d30014795cb00d", "searchTerms": [], + "slug": "11-a20plus", "title": "Level 11 (a20plus)", - "where": "chart.level = '11'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '11'" }, { "game": "ddr-dp", @@ -10625,9 +11737,10 @@ "inactive": false, "legacyFolderID": "F64d4f33b3253b0b953b95d1683fa2dd3131bc7e85f8eda1005dba41fc24a1e61", "searchTerms": [], + "slug": "11-a3", "title": "Level 11 (a3)", - "where": "chart.level = '11'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '11'" }, { "game": "ddr-dp", @@ -10635,9 +11748,10 @@ "inactive": false, "legacyFolderID": "F4724371eaadf8ec6ea6256a3c73c3642e90a5af4d54abcc08379834c4722e68b", "searchTerms": [], + "slug": "11-konaste", "title": "Level 11 (konaste)", - "where": "chart.level = '11'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '11'" }, { "game": "ddr-dp", @@ -10645,9 +11759,10 @@ "inactive": false, "legacyFolderID": "Fede179f7e56c295d7c5a5d75c62e00d486b1753413eb24e84389d13588841705", "searchTerms": [], + "slug": "11-world", "title": "Level 11 (world)", - "where": "chart.level = '11'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '11'" }, { "game": "ddr-dp", @@ -10655,9 +11770,10 @@ "inactive": false, "legacyFolderID": "F1ecb8ece5c332e9f0f2b2ed07fa1052ccf27f185f29350916c332a9f7794f647", "searchTerms": [], + "slug": "12-a", "title": "Level 12 (a)", - "where": "chart.level = '12'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '12'" }, { "game": "ddr-dp", @@ -10665,9 +11781,10 @@ "inactive": false, "legacyFolderID": "F5ba0a35c7a0c5808b497034c872ffcb6a6e0fecf890566e1112ebe69732dc199", "searchTerms": [], + "slug": "12-a20", "title": "Level 12 (a20)", - "where": "chart.level = '12'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '12'" }, { "game": "ddr-dp", @@ -10675,9 +11792,10 @@ "inactive": false, "legacyFolderID": "F676cc7bf0fd76d52ed7db69a3fc4949e09f18f4f543717d5b94a8ae01bd64b9b", "searchTerms": [], + "slug": "12-a20plus", "title": "Level 12 (a20plus)", - "where": "chart.level = '12'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '12'" }, { "game": "ddr-dp", @@ -10685,9 +11803,10 @@ "inactive": false, "legacyFolderID": "F031c7211015c785822790ae412b3991821077636f9c62b4fd0d698678998c2c3", "searchTerms": [], + "slug": "12-a3", "title": "Level 12 (a3)", - "where": "chart.level = '12'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '12'" }, { "game": "ddr-dp", @@ -10695,9 +11814,10 @@ "inactive": false, "legacyFolderID": "Fde33c443872eaa900ea76da4f4fd13d2725a3caebd838f0368463721dc840e43", "searchTerms": [], + "slug": "12-konaste", "title": "Level 12 (konaste)", - "where": "chart.level = '12'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '12'" }, { "game": "ddr-dp", @@ -10705,9 +11825,10 @@ "inactive": false, "legacyFolderID": "F6c2ce7e89580f08c24ff4cc733519971a6d17363c9597f5f7981538d8436b9a3", "searchTerms": [], + "slug": "12-world", "title": "Level 12 (world)", - "where": "chart.level = '12'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '12'" }, { "game": "ddr-dp", @@ -10715,9 +11836,10 @@ "inactive": false, "legacyFolderID": "Fe8827b7bfad1bcbc305cc2d73a75db9024045141492ee4330d7d8966fc4ed5d7", "searchTerms": [], + "slug": "13-a", "title": "Level 13 (a)", - "where": "chart.level = '13'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '13'" }, { "game": "ddr-dp", @@ -10725,9 +11847,10 @@ "inactive": false, "legacyFolderID": "Fbf188a3ec95be6b503e878e32bc7a4393770e2b4804d56a4ab9bd37d536e3bb7", "searchTerms": [], + "slug": "13-a20", "title": "Level 13 (a20)", - "where": "chart.level = '13'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '13'" }, { "game": "ddr-dp", @@ -10735,9 +11858,10 @@ "inactive": false, "legacyFolderID": "F69183f1b184c4f9a90879efe0b65de9aecfd54b08e644c21fb64e8e1f0f24a22", "searchTerms": [], + "slug": "13-a20plus", "title": "Level 13 (a20plus)", - "where": "chart.level = '13'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '13'" }, { "game": "ddr-dp", @@ -10745,9 +11869,10 @@ "inactive": false, "legacyFolderID": "F056062b64da89c352a477d6a2c5aec69458e975baf94ed77940b0fcba5dd81d8", "searchTerms": [], + "slug": "13-a3", "title": "Level 13 (a3)", - "where": "chart.level = '13'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '13'" }, { "game": "ddr-dp", @@ -10755,9 +11880,10 @@ "inactive": false, "legacyFolderID": "F3563e7c30b943a68d8b11b739734d2a40f24e0b53259a0cf2e86f6a696abe2cb", "searchTerms": [], + "slug": "13-konaste", "title": "Level 13 (konaste)", - "where": "chart.level = '13'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '13'" }, { "game": "ddr-dp", @@ -10765,9 +11891,10 @@ "inactive": false, "legacyFolderID": "F39759da0051b0f83aab9c6f4014b55173d06942a191cf41d16ebe22f17031866", "searchTerms": [], + "slug": "13-world", "title": "Level 13 (world)", - "where": "chart.level = '13'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '13'" }, { "game": "ddr-dp", @@ -10775,9 +11902,10 @@ "inactive": false, "legacyFolderID": "F5eba15c745577b28db1aa1bd6beab0d4698aafe794623f9107c42440998fc896", "searchTerms": [], + "slug": "14-a", "title": "Level 14 (a)", - "where": "chart.level = '14'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '14'" }, { "game": "ddr-dp", @@ -10785,9 +11913,10 @@ "inactive": false, "legacyFolderID": "F8749f635eb2d174af2f8e190180164199e6d511ee20262eff0eed0fafe8d91c2", "searchTerms": [], + "slug": "14-a20", "title": "Level 14 (a20)", - "where": "chart.level = '14'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '14'" }, { "game": "ddr-dp", @@ -10795,9 +11924,10 @@ "inactive": false, "legacyFolderID": "F2624a1f475df5b6ed1a2a4aaea524e8129feaeb6595c024348b3ff983f9f14f0", "searchTerms": [], + "slug": "14-a20plus", "title": "Level 14 (a20plus)", - "where": "chart.level = '14'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '14'" }, { "game": "ddr-dp", @@ -10805,9 +11935,10 @@ "inactive": false, "legacyFolderID": "F433ab1c5349310c9bb14a149d3f13362736af411e8304d8362dc9f9d321d8c92", "searchTerms": [], + "slug": "14-a3", "title": "Level 14 (a3)", - "where": "chart.level = '14'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '14'" }, { "game": "ddr-dp", @@ -10815,9 +11946,10 @@ "inactive": false, "legacyFolderID": "Fe8a2ff1ac201a7d43749921e875ae53d451122a8ddd999ccb918d6c70c7eb605", "searchTerms": [], + "slug": "14-konaste", "title": "Level 14 (konaste)", - "where": "chart.level = '14'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '14'" }, { "game": "ddr-dp", @@ -10825,9 +11957,10 @@ "inactive": false, "legacyFolderID": "Fae150b7168c0006ddc977488443733d5cb9abcb2a49a7ac78c2275b0a31c1775", "searchTerms": [], + "slug": "14-world", "title": "Level 14 (world)", - "where": "chart.level = '14'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '14'" }, { "game": "ddr-dp", @@ -10835,9 +11968,10 @@ "inactive": false, "legacyFolderID": "F2af784eea48de13a809f94739ae93ef8dfe0394c611cdcbc6b87a77516954d7d", "searchTerms": [], + "slug": "15-a", "title": "Level 15 (a)", - "where": "chart.level = '15'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '15'" }, { "game": "ddr-dp", @@ -10845,9 +11979,10 @@ "inactive": false, "legacyFolderID": "F9b22bc418bba915a62e5344f329e616fb98a6f136af9e69306edf927fd8c90af", "searchTerms": [], + "slug": "15-a20", "title": "Level 15 (a20)", - "where": "chart.level = '15'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '15'" }, { "game": "ddr-dp", @@ -10855,9 +11990,10 @@ "inactive": false, "legacyFolderID": "F4289ed998762d10518c4a8e13889d986b422b531e6fd7dffc78ae911c6fb1189", "searchTerms": [], + "slug": "15-a20plus", "title": "Level 15 (a20plus)", - "where": "chart.level = '15'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '15'" }, { "game": "ddr-dp", @@ -10865,9 +12001,10 @@ "inactive": false, "legacyFolderID": "F545f9be1276c0282dcc9763d17f61bc361fcfc3981aaa5ebc010932a7640e6e0", "searchTerms": [], + "slug": "15-a3", "title": "Level 15 (a3)", - "where": "chart.level = '15'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '15'" }, { "game": "ddr-dp", @@ -10875,9 +12012,10 @@ "inactive": false, "legacyFolderID": "F256322cb549b8a9a8fdb8a22cebf0c31a4b549f982317829f5d93ab623a9cd90", "searchTerms": [], + "slug": "15-konaste", "title": "Level 15 (konaste)", - "where": "chart.level = '15'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '15'" }, { "game": "ddr-dp", @@ -10885,9 +12023,10 @@ "inactive": false, "legacyFolderID": "F2258fbc49434b9ed5497d704ed28541e88b6ea44cc9c4ef817ea44c712f990a6", "searchTerms": [], + "slug": "15-world", "title": "Level 15 (world)", - "where": "chart.level = '15'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '15'" }, { "game": "ddr-dp", @@ -10895,9 +12034,10 @@ "inactive": false, "legacyFolderID": "F5c23e6fca61f2d37e8c815f96e05c6f1d2d66efd9883b6b3b460a1bc3a54b06f", "searchTerms": [], + "slug": "16-a", "title": "Level 16 (a)", - "where": "chart.level = '16'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '16'" }, { "game": "ddr-dp", @@ -10905,9 +12045,10 @@ "inactive": false, "legacyFolderID": "F20c7307be73871e15100e5dfcb327cc17800d3e7b920a9639484eb6c764d3193", "searchTerms": [], + "slug": "16-a20", "title": "Level 16 (a20)", - "where": "chart.level = '16'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '16'" }, { "game": "ddr-dp", @@ -10915,9 +12056,10 @@ "inactive": false, "legacyFolderID": "F407fc4fba6b80efd671109ad6fb25d2bd96b538c1bef2893296a34e8fe6409f8", "searchTerms": [], + "slug": "16-a20plus", "title": "Level 16 (a20plus)", - "where": "chart.level = '16'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '16'" }, { "game": "ddr-dp", @@ -10925,9 +12067,10 @@ "inactive": false, "legacyFolderID": "Fa1ce54cd462d8902f7a28c428e6e955f8c3ec2f6217b01e55e65419b38e76baa", "searchTerms": [], + "slug": "16-a3", "title": "Level 16 (a3)", - "where": "chart.level = '16'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '16'" }, { "game": "ddr-dp", @@ -10935,9 +12078,10 @@ "inactive": false, "legacyFolderID": "Fc3f43119f88ad3cc8d61f96c72447aef8301e2ec887e06f059181e1d840d302d", "searchTerms": [], + "slug": "16-konaste", "title": "Level 16 (konaste)", - "where": "chart.level = '16'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '16'" }, { "game": "ddr-dp", @@ -10945,9 +12089,10 @@ "inactive": false, "legacyFolderID": "F4d7c3db8cee537140b1be892b73bb946f84a883d5797950c63d131d0746c6320", "searchTerms": [], + "slug": "16-world", "title": "Level 16 (world)", - "where": "chart.level = '16'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '16'" }, { "game": "ddr-dp", @@ -10955,9 +12100,10 @@ "inactive": false, "legacyFolderID": "F30c746df3f8cd925a69a047be6813219f46ccaf1345bb7cd4eded4263ab43257", "searchTerms": [], + "slug": "17-a", "title": "Level 17 (a)", - "where": "chart.level = '17'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '17'" }, { "game": "ddr-dp", @@ -10965,9 +12111,10 @@ "inactive": false, "legacyFolderID": "Ffaa25b4f3c96db8dcab8a19cad510ac414c26aff2b20e857fcf03a0cbf48840e", "searchTerms": [], + "slug": "17-a20", "title": "Level 17 (a20)", - "where": "chart.level = '17'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '17'" }, { "game": "ddr-dp", @@ -10975,9 +12122,10 @@ "inactive": false, "legacyFolderID": "Fdef22cc054e094cd24c53513d7582369409efa1fdd8a3cd96bba7f2e0fbced55", "searchTerms": [], + "slug": "17-a20plus", "title": "Level 17 (a20plus)", - "where": "chart.level = '17'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '17'" }, { "game": "ddr-dp", @@ -10985,9 +12133,10 @@ "inactive": false, "legacyFolderID": "F4d94ab6211c3d1a8beefbe4b751ec5e71c1b0eb115b2633520c67ef8770d74a8", "searchTerms": [], + "slug": "17-a3", "title": "Level 17 (a3)", - "where": "chart.level = '17'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '17'" }, { "game": "ddr-dp", @@ -10995,9 +12144,10 @@ "inactive": false, "legacyFolderID": "F4032128e44c5dc9fb4e3a36ea638ff153318f8e87519d5117132d477d7898f18", "searchTerms": [], + "slug": "17-konaste", "title": "Level 17 (konaste)", - "where": "chart.level = '17'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '17'" }, { "game": "ddr-dp", @@ -11005,9 +12155,10 @@ "inactive": false, "legacyFolderID": "F75c3a342dcc59a9f67c79431209c60011798a54ea4fdea8bc63128e490d8b289", "searchTerms": [], + "slug": "17-world", "title": "Level 17 (world)", - "where": "chart.level = '17'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '17'" }, { "game": "ddr-dp", @@ -11015,9 +12166,10 @@ "inactive": false, "legacyFolderID": "F62b471d44e26dd5ef71ad1c7753d6ae7306f117a8f22d53c56d550f32506658c", "searchTerms": [], + "slug": "18-a", "title": "Level 18 (a)", - "where": "chart.level = '18'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '18'" }, { "game": "ddr-dp", @@ -11025,9 +12177,10 @@ "inactive": false, "legacyFolderID": "Ff771880a2e26ebc7be8b64eb87205b67c6f547d17d8afa440ad2a807e99eec13", "searchTerms": [], + "slug": "18-a20", "title": "Level 18 (a20)", - "where": "chart.level = '18'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '18'" }, { "game": "ddr-dp", @@ -11035,9 +12188,10 @@ "inactive": false, "legacyFolderID": "F26f784b26e2247c195a8a4d55a6602f173dc62d6dbdca00cbec9d97b3fb77ffc", "searchTerms": [], + "slug": "18-a20plus", "title": "Level 18 (a20plus)", - "where": "chart.level = '18'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '18'" }, { "game": "ddr-dp", @@ -11045,9 +12199,10 @@ "inactive": false, "legacyFolderID": "Fcaf066fdb5630578635b03950094d8c36d8f10816a06a2683bc32b5d5e64c122", "searchTerms": [], + "slug": "18-a3", "title": "Level 18 (a3)", - "where": "chart.level = '18'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '18'" }, { "game": "ddr-dp", @@ -11055,9 +12210,10 @@ "inactive": false, "legacyFolderID": "Fabab243c8f21e7c9412fa30a1cc90cd88564ada75511c07d6bffc2060018986d", "searchTerms": [], + "slug": "18-konaste", "title": "Level 18 (konaste)", - "where": "chart.level = '18'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '18'" }, { "game": "ddr-dp", @@ -11065,9 +12221,10 @@ "inactive": false, "legacyFolderID": "F8c4d318bdaf67fd17339974b4e9799ff3b1ea8a98e8479ffe956e1cfdd468f09", "searchTerms": [], + "slug": "18-world", "title": "Level 18 (world)", - "where": "chart.level = '18'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '18'" }, { "game": "ddr-dp", @@ -11075,9 +12232,10 @@ "inactive": false, "legacyFolderID": "F26574862eed021ea575abbc27fe396fb23a5f894b7e164d88f2ad2e5bfc0674d", "searchTerms": [], + "slug": "19-a", "title": "Level 19 (a)", - "where": "chart.level = '19'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '19'" }, { "game": "ddr-dp", @@ -11085,9 +12243,10 @@ "inactive": false, "legacyFolderID": "F975eab679523200f7092ac3ad9559b55af31eb37f86d4d50d4d3fe4a15aef5b1", "searchTerms": [], + "slug": "19-a20", "title": "Level 19 (a20)", - "where": "chart.level = '19'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '19'" }, { "game": "ddr-dp", @@ -11095,9 +12254,10 @@ "inactive": false, "legacyFolderID": "F41f6821b43a4446b380e5a27b4088b57c92b5e5bf6bc46509ff20c7906b5c21a", "searchTerms": [], + "slug": "19-a20plus", "title": "Level 19 (a20plus)", - "where": "chart.level = '19'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '19'" }, { "game": "ddr-dp", @@ -11105,9 +12265,10 @@ "inactive": false, "legacyFolderID": "Ffd863b43d6d4995a6be3bcb3c631ef93324751b1464d7c611894332b91f610e6", "searchTerms": [], + "slug": "19-a3", "title": "Level 19 (a3)", - "where": "chart.level = '19'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '19'" }, { "game": "ddr-dp", @@ -11115,9 +12276,10 @@ "inactive": false, "legacyFolderID": "F8d43d6e58c3a5fe5b136c40e949229f25beb8d18502db2f531b806a0d2d3c924", "searchTerms": [], + "slug": "19-konaste", "title": "Level 19 (konaste)", - "where": "chart.level = '19'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '19'" }, { "game": "ddr-dp", @@ -11125,9 +12287,10 @@ "inactive": false, "legacyFolderID": "F3a6e4587d9955d23f57f11a5a7106db188c39a64a69c188dd0cd43bc9befb5bc", "searchTerms": [], + "slug": "19-world", "title": "Level 19 (world)", - "where": "chart.level = '19'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '19'" }, { "game": "ddr-dp", @@ -11135,9 +12298,10 @@ "inactive": false, "legacyFolderID": "Fd20c45fa1d63f4145edf24b8f0b006f18e309f2af84677653430b0182912d2e5", "searchTerms": [], + "slug": "2-a", "title": "Level 2 (a)", - "where": "chart.level = '2'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '2'" }, { "game": "ddr-dp", @@ -11145,9 +12309,10 @@ "inactive": false, "legacyFolderID": "Fac6c9a7c503835a797424e6dba2bd721aa8e4322258e9e8660cf909ce94c1927", "searchTerms": [], + "slug": "2-a20", "title": "Level 2 (a20)", - "where": "chart.level = '2'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '2'" }, { "game": "ddr-dp", @@ -11155,9 +12320,10 @@ "inactive": false, "legacyFolderID": "F079d2f1831ade00be1ec4e9f4fa4d2d4d6ff951f0e5afffc83ad28bdd8fb4be3", "searchTerms": [], + "slug": "2-a20plus", "title": "Level 2 (a20plus)", - "where": "chart.level = '2'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '2'" }, { "game": "ddr-dp", @@ -11165,9 +12331,10 @@ "inactive": false, "legacyFolderID": "F8244bb1b94108a0e1dded145a412afaead4f3a7bdcb0c065ccde792c32e954d5", "searchTerms": [], + "slug": "2-a3", "title": "Level 2 (a3)", - "where": "chart.level = '2'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '2'" }, { "game": "ddr-dp", @@ -11175,9 +12342,10 @@ "inactive": false, "legacyFolderID": "F7ad9ebf58e146d4c464232633a2445bcb66d7a1941802670bc10403508187574", "searchTerms": [], + "slug": "2-konaste", "title": "Level 2 (konaste)", - "where": "chart.level = '2'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '2'" }, { "game": "ddr-dp", @@ -11185,9 +12353,10 @@ "inactive": false, "legacyFolderID": "F4f12ec2926b9397635beff55c74d943a109efed2b4bae3f9f9aca1d8b10f5ebc", "searchTerms": [], + "slug": "2-world", "title": "Level 2 (world)", - "where": "chart.level = '2'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '2'" }, { "game": "ddr-dp", @@ -11195,9 +12364,10 @@ "inactive": false, "legacyFolderID": "Fc78aa149ae561f31b5869a761562978e7738f4059151fce388c35066b7c44af5", "searchTerms": [], + "slug": "3-a", "title": "Level 3 (a)", - "where": "chart.level = '3'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '3'" }, { "game": "ddr-dp", @@ -11205,9 +12375,10 @@ "inactive": false, "legacyFolderID": "F3f54774013e6b9dbb703a11552fd02af588456576fd11cda74ac8da9c600f2c4", "searchTerms": [], + "slug": "3-a20", "title": "Level 3 (a20)", - "where": "chart.level = '3'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '3'" }, { "game": "ddr-dp", @@ -11215,9 +12386,10 @@ "inactive": false, "legacyFolderID": "F59c8b5653050b86b06b2f376f500d9bf8dea53a20dcf20a13574f37b5d1c7319", "searchTerms": [], + "slug": "3-a20plus", "title": "Level 3 (a20plus)", - "where": "chart.level = '3'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '3'" }, { "game": "ddr-dp", @@ -11225,9 +12397,10 @@ "inactive": false, "legacyFolderID": "F6f5f09306126c01a2b78668399abfb3b76ece7887cc85f2f1e316b23db1e5c61", "searchTerms": [], + "slug": "3-a3", "title": "Level 3 (a3)", - "where": "chart.level = '3'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '3'" }, { "game": "ddr-dp", @@ -11235,9 +12408,10 @@ "inactive": false, "legacyFolderID": "F5ca4b7b5c697f53a38e6037f887cb1f682e1ab57066d17fcf602e061633822b6", "searchTerms": [], + "slug": "3-konaste", "title": "Level 3 (konaste)", - "where": "chart.level = '3'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '3'" }, { "game": "ddr-dp", @@ -11245,9 +12419,10 @@ "inactive": false, "legacyFolderID": "Fafce5c5fd35e83a243079278b80a2ac79b49ee6e44b228037f9e7f345307171f", "searchTerms": [], + "slug": "3-world", "title": "Level 3 (world)", - "where": "chart.level = '3'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '3'" }, { "game": "ddr-dp", @@ -11255,9 +12430,10 @@ "inactive": false, "legacyFolderID": "Fa944c29e29ef20810d14f536af49078f4af622337a779c19bbf127c7e8b24d4d", "searchTerms": [], + "slug": "4-a", "title": "Level 4 (a)", - "where": "chart.level = '4'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '4'" }, { "game": "ddr-dp", @@ -11265,9 +12441,10 @@ "inactive": false, "legacyFolderID": "Fcc311feadbb193b25a42c5d8b1bf282b889342542c1494332b1ecb8b313b7f5e", "searchTerms": [], + "slug": "4-a20", "title": "Level 4 (a20)", - "where": "chart.level = '4'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '4'" }, { "game": "ddr-dp", @@ -11275,9 +12452,10 @@ "inactive": false, "legacyFolderID": "Fbfc1fa19afca65bc146b2d72850f0be013f8772ff41849d69c60082f70d625e7", "searchTerms": [], + "slug": "4-a20plus", "title": "Level 4 (a20plus)", - "where": "chart.level = '4'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '4'" }, { "game": "ddr-dp", @@ -11285,9 +12463,10 @@ "inactive": false, "legacyFolderID": "F59aee925ecf6fc6b92ab57a9c4da9bdfd96b8f9fef4a63fd1919ab1995160590", "searchTerms": [], + "slug": "4-a3", "title": "Level 4 (a3)", - "where": "chart.level = '4'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '4'" }, { "game": "ddr-dp", @@ -11295,9 +12474,10 @@ "inactive": false, "legacyFolderID": "F7c5bb58ca192959792f2ac6393e3eb134fe5611c617fdc204a9360fa5076fb75", "searchTerms": [], + "slug": "4-konaste", "title": "Level 4 (konaste)", - "where": "chart.level = '4'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '4'" }, { "game": "ddr-dp", @@ -11305,9 +12485,10 @@ "inactive": false, "legacyFolderID": "F3d88e81dcf92da6d1735f10b4446afb46a52963a930b942047b96b008b87b456", "searchTerms": [], + "slug": "4-world", "title": "Level 4 (world)", - "where": "chart.level = '4'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '4'" }, { "game": "ddr-dp", @@ -11315,9 +12496,10 @@ "inactive": false, "legacyFolderID": "Fb20026f48b2bac0440e661845cbc92d732cec76b1b13a4065524c771550f099a", "searchTerms": [], + "slug": "5-a", "title": "Level 5 (a)", - "where": "chart.level = '5'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '5'" }, { "game": "ddr-dp", @@ -11325,9 +12507,10 @@ "inactive": false, "legacyFolderID": "F8329c6d632a94cb8bb4716b87044c85f3b97d22f6f1f2caed033d23f87a134f7", "searchTerms": [], + "slug": "5-a20", "title": "Level 5 (a20)", - "where": "chart.level = '5'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '5'" }, { "game": "ddr-dp", @@ -11335,9 +12518,10 @@ "inactive": false, "legacyFolderID": "F9155c45256b7682c3a2f8eb46c30333d9379c4c7d7b5345c64a07139a1c11ad7", "searchTerms": [], + "slug": "5-a20plus", "title": "Level 5 (a20plus)", - "where": "chart.level = '5'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '5'" }, { "game": "ddr-dp", @@ -11345,9 +12529,10 @@ "inactive": false, "legacyFolderID": "F6ad596460698a99f327c8fce36c807eede6660e4999e472e4cfd48bc0f387ce6", "searchTerms": [], + "slug": "5-a3", "title": "Level 5 (a3)", - "where": "chart.level = '5'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '5'" }, { "game": "ddr-dp", @@ -11355,9 +12540,10 @@ "inactive": false, "legacyFolderID": "F7763bd432aae7cf40b4d792ca3d4e34518c3aab9908005e9d74aa5de971cab26", "searchTerms": [], + "slug": "5-konaste", "title": "Level 5 (konaste)", - "where": "chart.level = '5'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '5'" }, { "game": "ddr-dp", @@ -11365,9 +12551,10 @@ "inactive": false, "legacyFolderID": "F30cefed8e5cf10538567015fe831a1e43e1db81f14a69f8f0a2d3e9db4aef666", "searchTerms": [], + "slug": "5-world", "title": "Level 5 (world)", - "where": "chart.level = '5'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '5'" }, { "game": "ddr-dp", @@ -11375,9 +12562,10 @@ "inactive": false, "legacyFolderID": "Fb71635bc5735cdeb29d7e540c649513c0f7cd82a50a8faa56cdca3b79ae9a203", "searchTerms": [], + "slug": "6-a", "title": "Level 6 (a)", - "where": "chart.level = '6'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '6'" }, { "game": "ddr-dp", @@ -11385,9 +12573,10 @@ "inactive": false, "legacyFolderID": "F45f7166a1fe726136f82850c91c04af8057c8d18c8c9ba644473bdf9758b6160", "searchTerms": [], + "slug": "6-a20", "title": "Level 6 (a20)", - "where": "chart.level = '6'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '6'" }, { "game": "ddr-dp", @@ -11395,9 +12584,10 @@ "inactive": false, "legacyFolderID": "F4598e38f9bbb4559973dfd6918f1a7efa8b777c6ebde16332cffa8af5758b170", "searchTerms": [], + "slug": "6-a20plus", "title": "Level 6 (a20plus)", - "where": "chart.level = '6'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '6'" }, { "game": "ddr-dp", @@ -11405,9 +12595,10 @@ "inactive": false, "legacyFolderID": "Fea26c9c3a64c01d53aaccee9bdaa1d1724b441cb3d09c1c7943b7dd3a10ceca0", "searchTerms": [], + "slug": "6-a3", "title": "Level 6 (a3)", - "where": "chart.level = '6'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '6'" }, { "game": "ddr-dp", @@ -11415,9 +12606,10 @@ "inactive": false, "legacyFolderID": "Fbe7bcdaba4f40ef50b4f3545cb7224c7ce173b3fff1b3ce367654f9ef37e6449", "searchTerms": [], + "slug": "6-konaste", "title": "Level 6 (konaste)", - "where": "chart.level = '6'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '6'" }, { "game": "ddr-dp", @@ -11425,9 +12617,10 @@ "inactive": false, "legacyFolderID": "F2bd4d95fc1ad87bdc4f3780047d36dda40c49b863f73190d3a09e4c3aab7caae", "searchTerms": [], + "slug": "6-world", "title": "Level 6 (world)", - "where": "chart.level = '6'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '6'" }, { "game": "ddr-dp", @@ -11435,9 +12628,10 @@ "inactive": false, "legacyFolderID": "F5bdd32061c766ee957ae001e2821f64ce767780bd431a578c491c9634cd3e672", "searchTerms": [], + "slug": "7-a", "title": "Level 7 (a)", - "where": "chart.level = '7'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '7'" }, { "game": "ddr-dp", @@ -11445,9 +12639,10 @@ "inactive": false, "legacyFolderID": "F304107d1e6a9778e59e505e748185e97010e4896c7d93dc85759c86d8466258c", "searchTerms": [], + "slug": "7-a20", "title": "Level 7 (a20)", - "where": "chart.level = '7'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '7'" }, { "game": "ddr-dp", @@ -11455,9 +12650,10 @@ "inactive": false, "legacyFolderID": "F5e16a5a72daa435475488df7ef8e4b2b7e04bfb3aed6544205c1ceb284d7a96e", "searchTerms": [], + "slug": "7-a20plus", "title": "Level 7 (a20plus)", - "where": "chart.level = '7'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '7'" }, { "game": "ddr-dp", @@ -11465,9 +12661,10 @@ "inactive": false, "legacyFolderID": "Ffc22b6c292946b47e1c08def5f891b29427609d25a12528a2e33562abd6c6a54", "searchTerms": [], + "slug": "7-a3", "title": "Level 7 (a3)", - "where": "chart.level = '7'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '7'" }, { "game": "ddr-dp", @@ -11475,9 +12672,10 @@ "inactive": false, "legacyFolderID": "Fc94fde141bb74746c3972041db3981e291c6c9e5aff6ba7a448c84a4e70e74e9", "searchTerms": [], + "slug": "7-konaste", "title": "Level 7 (konaste)", - "where": "chart.level = '7'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '7'" }, { "game": "ddr-dp", @@ -11485,9 +12683,10 @@ "inactive": false, "legacyFolderID": "F6010c2a24e669c7f9fb8b342f9c048c625cf833dc837c575ca068337b740fde4", "searchTerms": [], + "slug": "7-world", "title": "Level 7 (world)", - "where": "chart.level = '7'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '7'" }, { "game": "ddr-dp", @@ -11495,9 +12694,10 @@ "inactive": false, "legacyFolderID": "F4dcbb6212d40b2f1cd0f090f11bc6a175f46eb37fb3a514b0c60794f54c77ba8", "searchTerms": [], + "slug": "8-a", "title": "Level 8 (a)", - "where": "chart.level = '8'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '8'" }, { "game": "ddr-dp", @@ -11505,9 +12705,10 @@ "inactive": false, "legacyFolderID": "F3c369334efdcf5ba7b346db437b03d707ae2578e44b892c13f05a8fb750856d7", "searchTerms": [], + "slug": "8-a20", "title": "Level 8 (a20)", - "where": "chart.level = '8'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '8'" }, { "game": "ddr-dp", @@ -11515,9 +12716,10 @@ "inactive": false, "legacyFolderID": "F64a5254c7fa94344c54ca0af9dcbe4bd98bb1c3003f695ef7fa0f4926b29a3e2", "searchTerms": [], + "slug": "8-a20plus", "title": "Level 8 (a20plus)", - "where": "chart.level = '8'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '8'" }, { "game": "ddr-dp", @@ -11525,9 +12727,10 @@ "inactive": false, "legacyFolderID": "F7bdeee34ae6682575bea98cfb8d729269779cb842f0ddd35ee6c544d0be1870c", "searchTerms": [], + "slug": "8-a3", "title": "Level 8 (a3)", - "where": "chart.level = '8'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '8'" }, { "game": "ddr-dp", @@ -11535,9 +12738,10 @@ "inactive": false, "legacyFolderID": "Fd4168eb3f1cf05815de85345ab857c414b8cb74c478f6dc8411a60993cff306d", "searchTerms": [], + "slug": "8-konaste", "title": "Level 8 (konaste)", - "where": "chart.level = '8'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '8'" }, { "game": "ddr-dp", @@ -11545,9 +12749,10 @@ "inactive": false, "legacyFolderID": "Fefce269769fc55fe045ceded8fe97df82d403b7712624cd20165ef9e5eb0b166", "searchTerms": [], + "slug": "8-world", "title": "Level 8 (world)", - "where": "chart.level = '8'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '8'" }, { "game": "ddr-dp", @@ -11555,9 +12760,10 @@ "inactive": false, "legacyFolderID": "F947e30f7455f784673aa14ed3bc026fa32098fff3d09ac343ac5419b68f7473a", "searchTerms": [], + "slug": "9-a", "title": "Level 9 (a)", - "where": "chart.level = '9'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '9'" }, { "game": "ddr-dp", @@ -11565,9 +12771,10 @@ "inactive": false, "legacyFolderID": "F44512013d26887815d0d23445608d5bc270d560ea61dfc7a55765872daf7b0f0", "searchTerms": [], + "slug": "9-a20", "title": "Level 9 (a20)", - "where": "chart.level = '9'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '9'" }, { "game": "ddr-dp", @@ -11575,9 +12782,10 @@ "inactive": false, "legacyFolderID": "Ff0c23b192f6c2ba97fde560f6124fce9bd1aa1687dd1375de53147e8c1e95a30", "searchTerms": [], + "slug": "9-a20plus", "title": "Level 9 (a20plus)", - "where": "chart.level = '9'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '9'" }, { "game": "ddr-dp", @@ -11585,9 +12793,10 @@ "inactive": false, "legacyFolderID": "Fde9f6d9165de06af01f05ffb0b4cc2f6cc798e7c7081325dd9a92e738ddbd499", "searchTerms": [], + "slug": "9-a3", "title": "Level 9 (a3)", - "where": "chart.level = '9'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '9'" }, { "game": "ddr-dp", @@ -11595,9 +12804,10 @@ "inactive": false, "legacyFolderID": "F102b21d52b6f0b079e279ca6485dca33d39e26aaa2d698fe207fafbe81d9a716", "searchTerms": [], + "slug": "9-konaste", "title": "Level 9 (konaste)", - "where": "chart.level = '9'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '9'" }, { "game": "ddr-dp", @@ -11605,9 +12815,10 @@ "inactive": false, "legacyFolderID": "F175fe25d30384ee88b2dbdc013f6b518f42f7ed9b0e41326382af91006d2cf40", "searchTerms": [], + "slug": "9-world", "title": "Level 9 (world)", - "where": "chart.level = '9'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '9'" }, { "game": "ddr-sp", @@ -11615,9 +12826,10 @@ "inactive": false, "legacyFolderID": "F75f576a0fd1d015935843d923083f940ef92b684e0ece6436d30eaff371dde16", "searchTerms": [], + "slug": "1-a", "title": "Level 1 (a)", - "where": "chart.level = '1'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '1'" }, { "game": "ddr-sp", @@ -11625,9 +12837,10 @@ "inactive": false, "legacyFolderID": "Fbd426239e8fd94b9ed6d54b50ac1fe2d1f39f26926b27d91ebc5b76cb0b1dc87", "searchTerms": [], + "slug": "1-a20", "title": "Level 1 (a20)", - "where": "chart.level = '1'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '1'" }, { "game": "ddr-sp", @@ -11635,9 +12848,10 @@ "inactive": false, "legacyFolderID": "Fc1feb10adcd7cbb29bce81db3620f5df81b731b771b322a0d6e5b19fd87a577c", "searchTerms": [], + "slug": "1-a20plus", "title": "Level 1 (a20plus)", - "where": "chart.level = '1'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '1'" }, { "game": "ddr-sp", @@ -11645,9 +12859,10 @@ "inactive": false, "legacyFolderID": "Fe2ebd0daed8a85df1dc6ccfd48d603f26c455e05ffeebd9a6c784273e001af37", "searchTerms": [], + "slug": "1-a3", "title": "Level 1 (a3)", - "where": "chart.level = '1'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '1'" }, { "game": "ddr-sp", @@ -11655,9 +12870,10 @@ "inactive": false, "legacyFolderID": "Fec0865e5706b5356f2797e4bb51bc099df1022489bed35ca6c87c395281353c7", "searchTerms": [], + "slug": "1-konaste", "title": "Level 1 (konaste)", - "where": "chart.level = '1'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '1'" }, { "game": "ddr-sp", @@ -11665,9 +12881,10 @@ "inactive": false, "legacyFolderID": "Fde0848ff994b4bbab2f4b2f65b920ee40a4816db5d015425ade7a5e3221737a5", "searchTerms": [], + "slug": "1-world", "title": "Level 1 (world)", - "where": "chart.level = '1'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '1'" }, { "game": "ddr-sp", @@ -11675,9 +12892,10 @@ "inactive": false, "legacyFolderID": "F290db9b1a25430229fed8044dba14e3425cb026264024ecdac2e223aea5db871", "searchTerms": [], + "slug": "10-a", "title": "Level 10 (a)", - "where": "chart.level = '10'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '10'" }, { "game": "ddr-sp", @@ -11685,9 +12903,10 @@ "inactive": false, "legacyFolderID": "Fb57ec3d31ce4c0764746dcb873fd128d6534926b5685484acc7f6e56c34a1431", "searchTerms": [], + "slug": "10-a20", "title": "Level 10 (a20)", - "where": "chart.level = '10'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '10'" }, { "game": "ddr-sp", @@ -11695,9 +12914,10 @@ "inactive": false, "legacyFolderID": "F354a2a5309347fa26136b7fff77859fa7d039d563b4cf8762b63a82d63fb59c0", "searchTerms": [], + "slug": "10-a20plus", "title": "Level 10 (a20plus)", - "where": "chart.level = '10'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '10'" }, { "game": "ddr-sp", @@ -11705,9 +12925,10 @@ "inactive": false, "legacyFolderID": "F3b54ced0bc526b3abd9ec54bb3748106bf510e16e22cfc2c77395d361cd1bfac", "searchTerms": [], + "slug": "10-a3", "title": "Level 10 (a3)", - "where": "chart.level = '10'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '10'" }, { "game": "ddr-sp", @@ -11715,9 +12936,10 @@ "inactive": false, "legacyFolderID": "F5aa66ceae1366b2fe2e6cf3fb72e1406cdd8633b99f0c223467f7736706cf09f", "searchTerms": [], + "slug": "10-konaste", "title": "Level 10 (konaste)", - "where": "chart.level = '10'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '10'" }, { "game": "ddr-sp", @@ -11725,9 +12947,10 @@ "inactive": false, "legacyFolderID": "F19eb67b105ac0edf0f3dafd3fe1cab95c985c473b6c6c4bfa071596c7c213025", "searchTerms": [], + "slug": "10-world", "title": "Level 10 (world)", - "where": "chart.level = '10'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '10'" }, { "game": "ddr-sp", @@ -11735,9 +12958,10 @@ "inactive": false, "legacyFolderID": "Fc6c4674896e52c2b8fd910da6dd48aee5c7ba92923bb2dc3faa1694b0994acdd", "searchTerms": [], + "slug": "11-a", "title": "Level 11 (a)", - "where": "chart.level = '11'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '11'" }, { "game": "ddr-sp", @@ -11745,9 +12969,10 @@ "inactive": false, "legacyFolderID": "F85ca9eba3da38d16f2ef38ae6f5d5b70e48c55da661c1bace69778c62c9d9fd5", "searchTerms": [], + "slug": "11-a20", "title": "Level 11 (a20)", - "where": "chart.level = '11'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '11'" }, { "game": "ddr-sp", @@ -11755,9 +12980,10 @@ "inactive": false, "legacyFolderID": "Fe1a853471db894efb78687fb0e848d69faf7a1105a2cb62568e563c73e239ba3", "searchTerms": [], + "slug": "11-a20plus", "title": "Level 11 (a20plus)", - "where": "chart.level = '11'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '11'" }, { "game": "ddr-sp", @@ -11765,9 +12991,10 @@ "inactive": false, "legacyFolderID": "Fec33d26ca14fe19b7f1e230f89b4e407d249bdc6cfff9e8d0bcee2d8fdcdd040", "searchTerms": [], + "slug": "11-a3", "title": "Level 11 (a3)", - "where": "chart.level = '11'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '11'" }, { "game": "ddr-sp", @@ -11775,9 +13002,10 @@ "inactive": false, "legacyFolderID": "F45a73830ba0c4f6807de163e1d9e40a0ce4ff75e7e08e7de4a31c2d8fc0542ed", "searchTerms": [], + "slug": "11-konaste", "title": "Level 11 (konaste)", - "where": "chart.level = '11'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '11'" }, { "game": "ddr-sp", @@ -11785,9 +13013,10 @@ "inactive": false, "legacyFolderID": "F83b713346d9f76873b652d65d7f05dc13a4cca5747bb284a151c4de9a7ea5d33", "searchTerms": [], + "slug": "11-world", "title": "Level 11 (world)", - "where": "chart.level = '11'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '11'" }, { "game": "ddr-sp", @@ -11795,9 +13024,10 @@ "inactive": false, "legacyFolderID": "Faa2d4922ed6e35f42c0b0c17d14f24d7ce65225102d47ea3094f47e13e499352", "searchTerms": [], + "slug": "12-a", "title": "Level 12 (a)", - "where": "chart.level = '12'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '12'" }, { "game": "ddr-sp", @@ -11805,9 +13035,10 @@ "inactive": false, "legacyFolderID": "F4fd192c9c844f85a24b920c89cab852210e84df39fdc05f15832f3d84bfeed74", "searchTerms": [], + "slug": "12-a20", "title": "Level 12 (a20)", - "where": "chart.level = '12'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '12'" }, { "game": "ddr-sp", @@ -11815,9 +13046,10 @@ "inactive": false, "legacyFolderID": "F28a030a2eb0f5f5c46ee9d4f48dd2d56601517644ec94e5195c422ee81a2f713", "searchTerms": [], + "slug": "12-a20plus", "title": "Level 12 (a20plus)", - "where": "chart.level = '12'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '12'" }, { "game": "ddr-sp", @@ -11825,9 +13057,10 @@ "inactive": false, "legacyFolderID": "F35ea43191dc0393e3dd485542895df4c1762d1e4f1061d999cc4c15704df4175", "searchTerms": [], + "slug": "12-a3", "title": "Level 12 (a3)", - "where": "chart.level = '12'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '12'" }, { "game": "ddr-sp", @@ -11835,9 +13068,10 @@ "inactive": false, "legacyFolderID": "Fb9e0b01c0366f535263e774e942e6977e5fec20a89d12e20c58628f3af13e3f7", "searchTerms": [], + "slug": "12-konaste", "title": "Level 12 (konaste)", - "where": "chart.level = '12'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '12'" }, { "game": "ddr-sp", @@ -11845,9 +13079,10 @@ "inactive": false, "legacyFolderID": "F6a88b21a690c289cc628df323e528c8d22203bb616f8b1025b3476625930f146", "searchTerms": [], + "slug": "12-world", "title": "Level 12 (world)", - "where": "chart.level = '12'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '12'" }, { "game": "ddr-sp", @@ -11855,9 +13090,10 @@ "inactive": false, "legacyFolderID": "F4a9930b1701446ed8033c544861690fe4d55c47d86931b0d761ea5e5f840ac4c", "searchTerms": [], + "slug": "13-a", "title": "Level 13 (a)", - "where": "chart.level = '13'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '13'" }, { "game": "ddr-sp", @@ -11865,9 +13101,10 @@ "inactive": false, "legacyFolderID": "F78cd2a32010bb7c65a8512ec0a2c7af0f0b472f522eedcb84687c817a3b6fe39", "searchTerms": [], + "slug": "13-a20", "title": "Level 13 (a20)", - "where": "chart.level = '13'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '13'" }, { "game": "ddr-sp", @@ -11875,9 +13112,10 @@ "inactive": false, "legacyFolderID": "F12b61a24df5e564b4e03d954dd9b7ff6fe2fac8b93e2acf093d1980408826cb4", "searchTerms": [], + "slug": "13-a20plus", "title": "Level 13 (a20plus)", - "where": "chart.level = '13'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '13'" }, { "game": "ddr-sp", @@ -11885,9 +13123,10 @@ "inactive": false, "legacyFolderID": "F6759e239397a497fff2ad881e48b35b302690c88ffd1b71c23f953b19f916778", "searchTerms": [], + "slug": "13-a3", "title": "Level 13 (a3)", - "where": "chart.level = '13'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '13'" }, { "game": "ddr-sp", @@ -11895,9 +13134,10 @@ "inactive": false, "legacyFolderID": "F41a0db4a7e8ee66aa233f979d88a38d55a4ce46e70623be790355d428da41a87", "searchTerms": [], + "slug": "13-konaste", "title": "Level 13 (konaste)", - "where": "chart.level = '13'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '13'" }, { "game": "ddr-sp", @@ -11905,9 +13145,10 @@ "inactive": false, "legacyFolderID": "Fe8cc44d50b362e8202b7ce589b0187778d6905e5d1e2a4833ac69aaf247d3b5e", "searchTerms": [], + "slug": "13-world", "title": "Level 13 (world)", - "where": "chart.level = '13'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '13'" }, { "game": "ddr-sp", @@ -11915,9 +13156,10 @@ "inactive": false, "legacyFolderID": "F733db9930230804b3eeeafc934a7232e5428cb6bb2790582d1f23de014815ea4", "searchTerms": [], + "slug": "14-a", "title": "Level 14 (a)", - "where": "chart.level = '14'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '14'" }, { "game": "ddr-sp", @@ -11925,9 +13167,10 @@ "inactive": false, "legacyFolderID": "F9e7a5a198434e1656bca927bf13eace2599f6cc2db316220043c90ff4a8dcadf", "searchTerms": [], + "slug": "14-a20", "title": "Level 14 (a20)", - "where": "chart.level = '14'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '14'" }, { "game": "ddr-sp", @@ -11935,9 +13178,10 @@ "inactive": false, "legacyFolderID": "Fef80dbd4112b895f8cd12ad5e7500017566ad8eb49da44c2251e0fb52b541261", "searchTerms": [], + "slug": "14-a20plus", "title": "Level 14 (a20plus)", - "where": "chart.level = '14'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '14'" }, { "game": "ddr-sp", @@ -11945,9 +13189,10 @@ "inactive": false, "legacyFolderID": "Fef12345762762537575586aae976bbecc8bf9ffa7ddce5ddce1a29402889ed88", "searchTerms": [], + "slug": "14-a3", "title": "Level 14 (a3)", - "where": "chart.level = '14'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '14'" }, { "game": "ddr-sp", @@ -11955,9 +13200,10 @@ "inactive": false, "legacyFolderID": "F5fa26b3aa1bfa8749ccdcd770983159bce231e300074c9ac96f82fe311aff771", "searchTerms": [], + "slug": "14-konaste", "title": "Level 14 (konaste)", - "where": "chart.level = '14'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '14'" }, { "game": "ddr-sp", @@ -11965,9 +13211,10 @@ "inactive": false, "legacyFolderID": "Fee9e6609b6f59c5d33c4d72cf8c64a9ea844207fdcff7569b2f75a75a803eaa1", "searchTerms": [], + "slug": "14-world", "title": "Level 14 (world)", - "where": "chart.level = '14'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '14'" }, { "game": "ddr-sp", @@ -11975,9 +13222,10 @@ "inactive": false, "legacyFolderID": "F9e52a35e16df5c57195f415c92f3811e53d4fd8106cf9bcc9f63ad529a314f52", "searchTerms": [], + "slug": "15-a", "title": "Level 15 (a)", - "where": "chart.level = '15'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '15'" }, { "game": "ddr-sp", @@ -11985,9 +13233,10 @@ "inactive": false, "legacyFolderID": "F467d665a6f614fd479bab54e83ffb1e0d62fcdb4d8696cdbc95c238415ea06aa", "searchTerms": [], + "slug": "15-a20", "title": "Level 15 (a20)", - "where": "chart.level = '15'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '15'" }, { "game": "ddr-sp", @@ -11995,9 +13244,10 @@ "inactive": false, "legacyFolderID": "F63a1f95eec8a4915f1db3a9d1405948cf05498b42b077f1a7ce1f76d678961fa", "searchTerms": [], + "slug": "15-a20plus", "title": "Level 15 (a20plus)", - "where": "chart.level = '15'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '15'" }, { "game": "ddr-sp", @@ -12005,9 +13255,10 @@ "inactive": false, "legacyFolderID": "Fd6eb0b58afb220174ca65791149c429c439e87b2ab90f60a6334ac8cc6002fd7", "searchTerms": [], + "slug": "15-a3", "title": "Level 15 (a3)", - "where": "chart.level = '15'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '15'" }, { "game": "ddr-sp", @@ -12015,9 +13266,10 @@ "inactive": false, "legacyFolderID": "F6510f706100ca8fadf66d2cef64f746aca0d576cc4317929718cec9e7e581297", "searchTerms": [], + "slug": "15-konaste", "title": "Level 15 (konaste)", - "where": "chart.level = '15'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '15'" }, { "game": "ddr-sp", @@ -12025,9 +13277,10 @@ "inactive": false, "legacyFolderID": "F9074ce3402c44ec4b6c371752e4ccd5aedc4d8321b0e0d21ca2840243e5e65c7", "searchTerms": [], + "slug": "15-world", "title": "Level 15 (world)", - "where": "chart.level = '15'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '15'" }, { "game": "ddr-sp", @@ -12035,9 +13288,10 @@ "inactive": false, "legacyFolderID": "F58302830c25e1b44070739f98ea59e89e21f3d58d3b38788c0db2fd076460125", "searchTerms": [], + "slug": "16-a", "title": "Level 16 (a)", - "where": "chart.level = '16'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '16'" }, { "game": "ddr-sp", @@ -12045,9 +13299,10 @@ "inactive": false, "legacyFolderID": "F7c4c087b8cb1fcea8d896b78d0567d12ee60cbad9c29c099c48c6f7ee71d32fa", "searchTerms": [], + "slug": "16-a20", "title": "Level 16 (a20)", - "where": "chart.level = '16'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '16'" }, { "game": "ddr-sp", @@ -12055,9 +13310,10 @@ "inactive": false, "legacyFolderID": "Fd7c85e392b4648a6bd042875e73e36e86ea410327d98d7146e6bfdb139c6e766", "searchTerms": [], + "slug": "16-a20plus", "title": "Level 16 (a20plus)", - "where": "chart.level = '16'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '16'" }, { "game": "ddr-sp", @@ -12065,9 +13321,10 @@ "inactive": false, "legacyFolderID": "F655ff75b1b52195e560497b0be721b8d6077faeda27784613daad7d163f9c47c", "searchTerms": [], + "slug": "16-a3", "title": "Level 16 (a3)", - "where": "chart.level = '16'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '16'" }, { "game": "ddr-sp", @@ -12075,9 +13332,10 @@ "inactive": false, "legacyFolderID": "F2e5d4140a7809d5b098f2513fe69c817d08e687f989d045d119df4a0227f70ab", "searchTerms": [], + "slug": "16-konaste", "title": "Level 16 (konaste)", - "where": "chart.level = '16'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '16'" }, { "game": "ddr-sp", @@ -12085,9 +13343,10 @@ "inactive": false, "legacyFolderID": "F64de6e6180695be6caf8cef64601604db19416ce33367c42e752e5cf652f409d", "searchTerms": [], + "slug": "16-world", "title": "Level 16 (world)", - "where": "chart.level = '16'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '16'" }, { "game": "ddr-sp", @@ -12095,9 +13354,10 @@ "inactive": false, "legacyFolderID": "F303d6396f95e0f6edfbd7262c89abb01a49c21e46e92c9a7d14bd78d739d4075", "searchTerms": [], + "slug": "17-a", "title": "Level 17 (a)", - "where": "chart.level = '17'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '17'" }, { "game": "ddr-sp", @@ -12105,9 +13365,10 @@ "inactive": false, "legacyFolderID": "F62e6546b97c96bfc0c86b7db5059a490f6e42eb0037651580bd6342b212dfac7", "searchTerms": [], + "slug": "17-a20", "title": "Level 17 (a20)", - "where": "chart.level = '17'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '17'" }, { "game": "ddr-sp", @@ -12115,9 +13376,10 @@ "inactive": false, "legacyFolderID": "Fa85f8d90db33f480bdda8491e5f9462aab4e8ca1cd3dc93de7346c6b2e86263e", "searchTerms": [], + "slug": "17-a20plus", "title": "Level 17 (a20plus)", - "where": "chart.level = '17'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '17'" }, { "game": "ddr-sp", @@ -12125,9 +13387,10 @@ "inactive": false, "legacyFolderID": "F2defd0a78ed6c63bb6b91f36b672698e79c250b1b80879be86242d1fed723a94", "searchTerms": [], + "slug": "17-a3", "title": "Level 17 (a3)", - "where": "chart.level = '17'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '17'" }, { "game": "ddr-sp", @@ -12135,9 +13398,10 @@ "inactive": false, "legacyFolderID": "Fbdc1303b1a70405a777eb2b2ae0e1a55298101d7f05059e05352b5805aba8c56", "searchTerms": [], + "slug": "17-konaste", "title": "Level 17 (konaste)", - "where": "chart.level = '17'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '17'" }, { "game": "ddr-sp", @@ -12145,9 +13409,10 @@ "inactive": false, "legacyFolderID": "Fa5ae2477acb78416bd72a293cbea0093767b4c4d76c401348484736cfe3c2aa6", "searchTerms": [], + "slug": "17-world", "title": "Level 17 (world)", - "where": "chart.level = '17'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '17'" }, { "game": "ddr-sp", @@ -12155,9 +13420,10 @@ "inactive": false, "legacyFolderID": "F2a51a68d8c5c9e536da79a28c3fdb7160cfe4f3349fcbc87268d7e1e67faf4d4", "searchTerms": [], + "slug": "18-a", "title": "Level 18 (a)", - "where": "chart.level = '18'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '18'" }, { "game": "ddr-sp", @@ -12165,9 +13431,10 @@ "inactive": false, "legacyFolderID": "F6bf85dde4849ce7a7c96c2ec57e8a12908646011ff16e079398dd44ef757abdc", "searchTerms": [], + "slug": "18-a20", "title": "Level 18 (a20)", - "where": "chart.level = '18'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '18'" }, { "game": "ddr-sp", @@ -12175,9 +13442,10 @@ "inactive": false, "legacyFolderID": "Ff6a8597ccaa139ecb1d7dc8f0e41d824e79afff3d651f88614f676f71bf060b6", "searchTerms": [], + "slug": "18-a20plus", "title": "Level 18 (a20plus)", - "where": "chart.level = '18'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '18'" }, { "game": "ddr-sp", @@ -12185,9 +13453,10 @@ "inactive": false, "legacyFolderID": "F0d217a116adb2d4c2421f3f08a752f20203594a788481cd81f7f9d751c8dd3cf", "searchTerms": [], + "slug": "18-a3", "title": "Level 18 (a3)", - "where": "chart.level = '18'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '18'" }, { "game": "ddr-sp", @@ -12195,9 +13464,10 @@ "inactive": false, "legacyFolderID": "F111d5eaa60b87a9f4ee279d6bde4b8b207165096acdb95e6ac2be263b272cd47", "searchTerms": [], + "slug": "18-konaste", "title": "Level 18 (konaste)", - "where": "chart.level = '18'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '18'" }, { "game": "ddr-sp", @@ -12205,9 +13475,10 @@ "inactive": false, "legacyFolderID": "F37351356f2a65fe0e5ce2b8313191d0f4f83ac6824ac976e894b42d372ce5587", "searchTerms": [], + "slug": "18-world", "title": "Level 18 (world)", - "where": "chart.level = '18'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '18'" }, { "game": "ddr-sp", @@ -12215,9 +13486,10 @@ "inactive": false, "legacyFolderID": "F8f89630c7f593a97c241bdf758903664e2dd6f0a4737ec34186f4d49a8c49563", "searchTerms": [], + "slug": "19-a", "title": "Level 19 (a)", - "where": "chart.level = '19'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '19'" }, { "game": "ddr-sp", @@ -12225,9 +13497,10 @@ "inactive": false, "legacyFolderID": "Fac84915cfeeb206a3758c83d53c63d51bb4b0061d9fe597b72cae4c5f6317675", "searchTerms": [], + "slug": "19-a20", "title": "Level 19 (a20)", - "where": "chart.level = '19'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '19'" }, { "game": "ddr-sp", @@ -12235,9 +13508,10 @@ "inactive": false, "legacyFolderID": "F508818f77f6e173e548b4bcad0a0ade1449de07184a4cb2090a643bf7fb5e82e", "searchTerms": [], + "slug": "19-a20plus", "title": "Level 19 (a20plus)", - "where": "chart.level = '19'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '19'" }, { "game": "ddr-sp", @@ -12245,9 +13519,10 @@ "inactive": false, "legacyFolderID": "Fc82f934fdc932b46b0aed8702aaabf50d31fd6705f488358db828962be184c47", "searchTerms": [], + "slug": "19-a3", "title": "Level 19 (a3)", - "where": "chart.level = '19'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '19'" }, { "game": "ddr-sp", @@ -12255,9 +13530,10 @@ "inactive": false, "legacyFolderID": "F6441f83032bd6fced4a635ffa8a1301c8e9d4b83de187ef6c8caa6cf006259ab", "searchTerms": [], + "slug": "19-konaste", "title": "Level 19 (konaste)", - "where": "chart.level = '19'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '19'" }, { "game": "ddr-sp", @@ -12265,9 +13541,10 @@ "inactive": false, "legacyFolderID": "F12834d34f1445bb489bab24a5948c52c30be21f47804d866b8b3807a305d78a0", "searchTerms": [], + "slug": "19-world", "title": "Level 19 (world)", - "where": "chart.level = '19'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '19'" }, { "game": "ddr-sp", @@ -12275,9 +13552,10 @@ "inactive": false, "legacyFolderID": "F942a1ea9c928dda50133aa28d43808cef7939dd7cb274b5193982dd0d81dd4dd", "searchTerms": [], + "slug": "2-a", "title": "Level 2 (a)", - "where": "chart.level = '2'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '2'" }, { "game": "ddr-sp", @@ -12285,9 +13563,10 @@ "inactive": false, "legacyFolderID": "Fbdbe830277086bf4b0e0b6b9ab36956c36ee88037a91eac02253e19a2930dcb4", "searchTerms": [], + "slug": "2-a20", "title": "Level 2 (a20)", - "where": "chart.level = '2'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '2'" }, { "game": "ddr-sp", @@ -12295,9 +13574,10 @@ "inactive": false, "legacyFolderID": "F29dc320eec48459c9201d1a9666a13fa669ca2bfefb6b0e584dde61e850de69e", "searchTerms": [], + "slug": "2-a20plus", "title": "Level 2 (a20plus)", - "where": "chart.level = '2'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '2'" }, { "game": "ddr-sp", @@ -12305,9 +13585,10 @@ "inactive": false, "legacyFolderID": "F3ae32eb5ec63ddc5cd41d46afb5b917ab211f2356d4ee425fcf1ece3b37bcd0c", "searchTerms": [], + "slug": "2-a3", "title": "Level 2 (a3)", - "where": "chart.level = '2'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '2'" }, { "game": "ddr-sp", @@ -12315,9 +13596,10 @@ "inactive": false, "legacyFolderID": "F1695b73c020bd79d4443d370efdb9564a9d21076d370c58d512b20da831a42b1", "searchTerms": [], + "slug": "2-konaste", "title": "Level 2 (konaste)", - "where": "chart.level = '2'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '2'" }, { "game": "ddr-sp", @@ -12325,9 +13607,10 @@ "inactive": false, "legacyFolderID": "F5761226aebebacb3b7dfd9f2801dc8f457e64edab70b29c458e2bc265144fe0a", "searchTerms": [], + "slug": "2-world", "title": "Level 2 (world)", - "where": "chart.level = '2'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '2'" }, { "game": "ddr-sp", @@ -12335,9 +13618,10 @@ "inactive": false, "legacyFolderID": "Fcb801a5a69685f851ac1e179a39557b5427b35ac1d6995dc4abf67632ed903b2", "searchTerms": [], + "slug": "3-a", "title": "Level 3 (a)", - "where": "chart.level = '3'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '3'" }, { "game": "ddr-sp", @@ -12345,9 +13629,10 @@ "inactive": false, "legacyFolderID": "F516050b95fd07f322770abccaa0b3476dd6d6fe8ca080503686d9fb2b7ff4ae0", "searchTerms": [], + "slug": "3-a20", "title": "Level 3 (a20)", - "where": "chart.level = '3'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '3'" }, { "game": "ddr-sp", @@ -12355,9 +13640,10 @@ "inactive": false, "legacyFolderID": "Fc77523d545e239787dca9dd6aaa04de3eac516856a7f16f0369a7e93685bf5ba", "searchTerms": [], + "slug": "3-a20plus", "title": "Level 3 (a20plus)", - "where": "chart.level = '3'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '3'" }, { "game": "ddr-sp", @@ -12365,9 +13651,10 @@ "inactive": false, "legacyFolderID": "Fa242a02a2075e5b119a8384595e1869d6f5e200dac23c07723a9f5144ca46d5a", "searchTerms": [], + "slug": "3-a3", "title": "Level 3 (a3)", - "where": "chart.level = '3'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '3'" }, { "game": "ddr-sp", @@ -12375,9 +13662,10 @@ "inactive": false, "legacyFolderID": "F3059622c5fe9ead4a598ff22becc16dcc68472d8735e520a225f9fa8267cf246", "searchTerms": [], + "slug": "3-konaste", "title": "Level 3 (konaste)", - "where": "chart.level = '3'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '3'" }, { "game": "ddr-sp", @@ -12385,9 +13673,10 @@ "inactive": false, "legacyFolderID": "F43d653371351c82921ca88fc433fc1350dc5ea6a1d8fcb3d9f93fbfb62355a60", "searchTerms": [], + "slug": "3-world", "title": "Level 3 (world)", - "where": "chart.level = '3'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '3'" }, { "game": "ddr-sp", @@ -12395,9 +13684,10 @@ "inactive": false, "legacyFolderID": "Fbd1ad6822b14321c739de69d81e830d133315a5e5ed01fe7eb8b53ca5d7eb167", "searchTerms": [], + "slug": "4-a", "title": "Level 4 (a)", - "where": "chart.level = '4'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '4'" }, { "game": "ddr-sp", @@ -12405,9 +13695,10 @@ "inactive": false, "legacyFolderID": "Fabad5cd4ecfa2540000ee0ac820b592c0282b7c2583be946922b0618988ce6f5", "searchTerms": [], + "slug": "4-a20", "title": "Level 4 (a20)", - "where": "chart.level = '4'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '4'" }, { "game": "ddr-sp", @@ -12415,9 +13706,10 @@ "inactive": false, "legacyFolderID": "F55aa665b8c525d114c4daf3ac9f4be6013f41aef36b72017664cc7f9c84f5992", "searchTerms": [], + "slug": "4-a20plus", "title": "Level 4 (a20plus)", - "where": "chart.level = '4'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '4'" }, { "game": "ddr-sp", @@ -12425,9 +13717,10 @@ "inactive": false, "legacyFolderID": "Ffc3a11473d1c711c2aa1177427e0ddc800f66446f5fd65c868d0df0549a611da", "searchTerms": [], + "slug": "4-a3", "title": "Level 4 (a3)", - "where": "chart.level = '4'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '4'" }, { "game": "ddr-sp", @@ -12435,9 +13728,10 @@ "inactive": false, "legacyFolderID": "F892e11d06e8ad5973d79f7d0131b00b7895aa76d590a21f3efc5bf1abe5f09f6", "searchTerms": [], + "slug": "4-konaste", "title": "Level 4 (konaste)", - "where": "chart.level = '4'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '4'" }, { "game": "ddr-sp", @@ -12445,9 +13739,10 @@ "inactive": false, "legacyFolderID": "F0435b8c216be06fb3894c80abc1bc52fdf87861c6009e3f3b9d479695dd7dc18", "searchTerms": [], + "slug": "4-world", "title": "Level 4 (world)", - "where": "chart.level = '4'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '4'" }, { "game": "ddr-sp", @@ -12455,9 +13750,10 @@ "inactive": false, "legacyFolderID": "F385293fd208b2390af1dd12eaf4d7979266c161195cd1f04d55495b24c9f6b26", "searchTerms": [], + "slug": "5-a", "title": "Level 5 (a)", - "where": "chart.level = '5'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '5'" }, { "game": "ddr-sp", @@ -12465,9 +13761,10 @@ "inactive": false, "legacyFolderID": "F356eafa4794f629335da17bfc8a63d2fa39beb9187e9fba6e469fbe3023c9d53", "searchTerms": [], + "slug": "5-a20", "title": "Level 5 (a20)", - "where": "chart.level = '5'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '5'" }, { "game": "ddr-sp", @@ -12475,9 +13772,10 @@ "inactive": false, "legacyFolderID": "Fd12ba80de32539d7e285db9e8fd8db046e0d10039c12e8d63188e712c3d9546d", "searchTerms": [], + "slug": "5-a20plus", "title": "Level 5 (a20plus)", - "where": "chart.level = '5'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '5'" }, { "game": "ddr-sp", @@ -12485,9 +13783,10 @@ "inactive": false, "legacyFolderID": "F81f3a6426b91472da606712565af745b3ac1936f0bad921a778a659a0663b99b", "searchTerms": [], + "slug": "5-a3", "title": "Level 5 (a3)", - "where": "chart.level = '5'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '5'" }, { "game": "ddr-sp", @@ -12495,9 +13794,10 @@ "inactive": false, "legacyFolderID": "F6e8ac6004d0d1d32181d5a5d1541bb16f69758f55452cb5ccef8484c994edb22", "searchTerms": [], + "slug": "5-konaste", "title": "Level 5 (konaste)", - "where": "chart.level = '5'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '5'" }, { "game": "ddr-sp", @@ -12505,9 +13805,10 @@ "inactive": false, "legacyFolderID": "F65640be7e586ed7bc022fd7cf4e130f81dab5fbfa714246a70cb507cf2cc55da", "searchTerms": [], + "slug": "5-world", "title": "Level 5 (world)", - "where": "chart.level = '5'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '5'" }, { "game": "ddr-sp", @@ -12515,9 +13816,10 @@ "inactive": false, "legacyFolderID": "F914e31e8a530b2996a3c371d06a4298a4d2f12d076f2f4af96c4a0e3f157c228", "searchTerms": [], + "slug": "6-a", "title": "Level 6 (a)", - "where": "chart.level = '6'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '6'" }, { "game": "ddr-sp", @@ -12525,9 +13827,10 @@ "inactive": false, "legacyFolderID": "F2a2e47b5ad91ff8a7acf4378d5179fb215e1fd74273b3c51d14e0a0379b376c9", "searchTerms": [], + "slug": "6-a20", "title": "Level 6 (a20)", - "where": "chart.level = '6'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '6'" }, { "game": "ddr-sp", @@ -12535,9 +13838,10 @@ "inactive": false, "legacyFolderID": "Fff2f537b331b312e4aa74b985dae017d02619c1f4a0bfd37be27a1eb69a4d16a", "searchTerms": [], + "slug": "6-a20plus", "title": "Level 6 (a20plus)", - "where": "chart.level = '6'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '6'" }, { "game": "ddr-sp", @@ -12545,9 +13849,10 @@ "inactive": false, "legacyFolderID": "Fde54a68769142fb14a2e51e29a138f55a8b137317182e16557536f7959ab97d3", "searchTerms": [], + "slug": "6-a3", "title": "Level 6 (a3)", - "where": "chart.level = '6'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '6'" }, { "game": "ddr-sp", @@ -12555,9 +13860,10 @@ "inactive": false, "legacyFolderID": "Ff0041be5b1ed5e0bed3f75162c86525e947cfddc0cc5771890058742af5cc4d8", "searchTerms": [], + "slug": "6-konaste", "title": "Level 6 (konaste)", - "where": "chart.level = '6'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '6'" }, { "game": "ddr-sp", @@ -12565,9 +13871,10 @@ "inactive": false, "legacyFolderID": "F70cd3987abc609f8b74a6fcec8bc0742fdd0dd34033c570c2a4e77e0819e2e07", "searchTerms": [], + "slug": "6-world", "title": "Level 6 (world)", - "where": "chart.level = '6'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '6'" }, { "game": "ddr-sp", @@ -12575,9 +13882,10 @@ "inactive": false, "legacyFolderID": "F6ddca1fe8468dda1e22a9e028fd9eb518cb1661cece3cba7de6869350dcbf1e8", "searchTerms": [], + "slug": "7-a", "title": "Level 7 (a)", - "where": "chart.level = '7'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '7'" }, { "game": "ddr-sp", @@ -12585,9 +13893,10 @@ "inactive": false, "legacyFolderID": "Fb78941ab7057011e14e5037cc85a07b0a6f9477b51b9c7cfac8aeaaa57ca10fb", "searchTerms": [], + "slug": "7-a20", "title": "Level 7 (a20)", - "where": "chart.level = '7'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '7'" }, { "game": "ddr-sp", @@ -12595,9 +13904,10 @@ "inactive": false, "legacyFolderID": "Fb243b385b7a996cedaaa6fcb8e0104cb8cef49fb69541d950233666c3a23feda", "searchTerms": [], + "slug": "7-a20plus", "title": "Level 7 (a20plus)", - "where": "chart.level = '7'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '7'" }, { "game": "ddr-sp", @@ -12605,9 +13915,10 @@ "inactive": false, "legacyFolderID": "Ff9b1587b5360d55b33af44f3ee53fdec1b2c68b3165f7f04b26662048794066e", "searchTerms": [], + "slug": "7-a3", "title": "Level 7 (a3)", - "where": "chart.level = '7'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '7'" }, { "game": "ddr-sp", @@ -12615,9 +13926,10 @@ "inactive": false, "legacyFolderID": "F02c1e0bd43bd6e7ac1ceb969ef69d4a4b7fec15e87c212bdff46735d266cea2a", "searchTerms": [], + "slug": "7-konaste", "title": "Level 7 (konaste)", - "where": "chart.level = '7'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '7'" }, { "game": "ddr-sp", @@ -12625,9 +13937,10 @@ "inactive": false, "legacyFolderID": "Fa55df3c1dfeaf2039d0e7a8d60389446c8067611be3c96b5c2bd5ef72b9300f0", "searchTerms": [], + "slug": "7-world", "title": "Level 7 (world)", - "where": "chart.level = '7'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '7'" }, { "game": "ddr-sp", @@ -12635,9 +13948,10 @@ "inactive": false, "legacyFolderID": "F87e01a5ba7f9b17e68318b90b1d1fc70ab0bc519df6de6e83d9ce59c8c8cf57d", "searchTerms": [], + "slug": "8-a", "title": "Level 8 (a)", - "where": "chart.level = '8'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '8'" }, { "game": "ddr-sp", @@ -12645,9 +13959,10 @@ "inactive": false, "legacyFolderID": "Ffb0b66e41f3c891b21eeb5208d7fcd68aac7ee429ace0f388c0511b84ef037fb", "searchTerms": [], + "slug": "8-a20", "title": "Level 8 (a20)", - "where": "chart.level = '8'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '8'" }, { "game": "ddr-sp", @@ -12655,9 +13970,10 @@ "inactive": false, "legacyFolderID": "Faec8af54acb11e376919359a7e536a0c905e3cb4fe1f97cb67ed8fd008bd4df7", "searchTerms": [], + "slug": "8-a20plus", "title": "Level 8 (a20plus)", - "where": "chart.level = '8'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '8'" }, { "game": "ddr-sp", @@ -12665,9 +13981,10 @@ "inactive": false, "legacyFolderID": "F84573ac4ed880c5157209edb6af1a0a0fa229d5972dc8f63656d8eb90b407817", "searchTerms": [], + "slug": "8-a3", "title": "Level 8 (a3)", - "where": "chart.level = '8'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '8'" }, { "game": "ddr-sp", @@ -12675,9 +13992,10 @@ "inactive": false, "legacyFolderID": "Fe9673fe186c984c981c2c5d34199920b52a80788ac8c88660d12e47295ac8560", "searchTerms": [], + "slug": "8-konaste", "title": "Level 8 (konaste)", - "where": "chart.level = '8'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '8'" }, { "game": "ddr-sp", @@ -12685,9 +14003,10 @@ "inactive": false, "legacyFolderID": "Fc4ed03c88638a4b2e7c615a958d43702bcdf802aca9a9738d221284eb2da9b9d", "searchTerms": [], + "slug": "8-world", "title": "Level 8 (world)", - "where": "chart.level = '8'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '8'" }, { "game": "ddr-sp", @@ -12695,9 +14014,10 @@ "inactive": false, "legacyFolderID": "F5075513fb6f2d4d5013ac64342393137934dafc6a3a0e28f3c2eeec9aa88cdbc", "searchTerms": [], + "slug": "9-a", "title": "Level 9 (a)", - "where": "chart.level = '9'", - "versionFilter": ["a"] + "versionFilter": ["a"], + "where": "chart.level = '9'" }, { "game": "ddr-sp", @@ -12705,9 +14025,10 @@ "inactive": false, "legacyFolderID": "Fe8ce9267abfaaf6c51f3c2472beebf31820fee50055006ee64d4a7bd06ee6ed5", "searchTerms": [], + "slug": "9-a20", "title": "Level 9 (a20)", - "where": "chart.level = '9'", - "versionFilter": ["a20"] + "versionFilter": ["a20"], + "where": "chart.level = '9'" }, { "game": "ddr-sp", @@ -12715,9 +14036,10 @@ "inactive": false, "legacyFolderID": "F27aabd1e5fbe550f459da3f17c2338fb8a56b07f8d814d1a788907683509d24d", "searchTerms": [], + "slug": "9-a20plus", "title": "Level 9 (a20plus)", - "where": "chart.level = '9'", - "versionFilter": ["a20plus"] + "versionFilter": ["a20plus"], + "where": "chart.level = '9'" }, { "game": "ddr-sp", @@ -12725,9 +14047,10 @@ "inactive": false, "legacyFolderID": "Fb23a361a65e1000de21438689f4feb60abc2985383f08ec4632a5c586d4bf636", "searchTerms": [], + "slug": "9-a3", "title": "Level 9 (a3)", - "where": "chart.level = '9'", - "versionFilter": ["a3"] + "versionFilter": ["a3"], + "where": "chart.level = '9'" }, { "game": "ddr-sp", @@ -12735,9 +14058,10 @@ "inactive": false, "legacyFolderID": "F92361080194ce28d6e61457ea41961061bdbfb43269dfc949391448f63c21734", "searchTerms": [], + "slug": "9-konaste", "title": "Level 9 (konaste)", - "where": "chart.level = '9'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '9'" }, { "game": "ddr-sp", @@ -12745,9 +14069,10 @@ "inactive": false, "legacyFolderID": "Ff5d554f76b9da739b5f0f2e58f0512f4a74a63c08fbb4ce3c42041dbbb51e561", "searchTerms": [], + "slug": "9-world", "title": "Level 9 (world)", - "where": "chart.level = '9'", - "versionFilter": ["world"] + "versionFilter": ["world"], + "where": "chart.level = '9'" }, { "game": "gitadora-dora", @@ -12755,9 +14080,10 @@ "inactive": false, "legacyFolderID": "F1b7efdd6936f61dde4777c89d9060cf2231dde18cef192167ba3ec3f40ba9978", "searchTerms": [], + "slug": "konaste-ge-1-lt-2", "title": "Level 1.00 - 1.99 (Konaste)", - "where": "chart.level_num >= 1 AND chart.level_num < 2", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 1 AND chart.level_num < 2" }, { "game": "gitadora-dora", @@ -12765,9 +14091,10 @@ "inactive": false, "legacyFolderID": "F451df7c2930cb557e87cc101d18ecfaf70ee2b760496e43c75cca9b1e5dc83af", "searchTerms": [], + "slug": "konaste-ge-2-lt-3", "title": "Level 2.00 - 2.99 (Konaste)", - "where": "chart.level_num >= 2 AND chart.level_num < 3", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 2 AND chart.level_num < 3" }, { "game": "gitadora-dora", @@ -12775,9 +14102,10 @@ "inactive": false, "legacyFolderID": "F7116a3f89c9c231f32d5ad9822a869bd380d784672a784d7990614002e285b86", "searchTerms": [], + "slug": "konaste-ge-3-lt-4", "title": "Level 3.00 - 3.99 (Konaste)", - "where": "chart.level_num >= 3 AND chart.level_num < 4", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 3 AND chart.level_num < 4" }, { "game": "gitadora-dora", @@ -12785,9 +14113,10 @@ "inactive": false, "legacyFolderID": "Ff4bab786f57595d2929edb33c15ae5f9758df8de37d1ce6e390cfb9a0974f802", "searchTerms": [], + "slug": "konaste-ge-4-lt-5", "title": "Level 4.00 - 4.99 (Konaste)", - "where": "chart.level_num >= 4 AND chart.level_num < 5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 4 AND chart.level_num < 5" }, { "game": "gitadora-dora", @@ -12795,9 +14124,10 @@ "inactive": false, "legacyFolderID": "Fc61f81691c5540d62696983d535aa3c0b63cf9dbdfadf4959756e495d50a7e28", "searchTerms": [], + "slug": "konaste-ge-5-lt-5p5", "title": "Level 5.00 - 5.49 (Konaste)", - "where": "chart.level_num >= 5 AND chart.level_num < 5.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 5 AND chart.level_num < 5.5" }, { "game": "gitadora-dora", @@ -12805,9 +14135,10 @@ "inactive": false, "legacyFolderID": "Fc700f8d613f0e631653cf251eecc9d5b3d3458be748d87817db94d168e6e8b9d", "searchTerms": [], + "slug": "konaste-ge-5p5-lt-6", "title": "Level 5.50 - 5.99 (Konaste)", - "where": "chart.level_num >= 5.5 AND chart.level_num < 6", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 5.5 AND chart.level_num < 6" }, { "game": "gitadora-dora", @@ -12815,9 +14146,10 @@ "inactive": false, "legacyFolderID": "Fe865db6c07d6850f70ba70182a19337e97f4bc73a9589754a311c31670eb9439", "searchTerms": [], + "slug": "konaste-ge-6-lt-6p5", "title": "Level 6.00 - 6.49 (Konaste)", - "where": "chart.level_num >= 6 AND chart.level_num < 6.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 6 AND chart.level_num < 6.5" }, { "game": "gitadora-dora", @@ -12825,9 +14157,10 @@ "inactive": false, "legacyFolderID": "Fdd24da097c2ebe2cdee8f96aeea4cac7695908a68aa3cd8ba1c98d20a8d84998", "searchTerms": [], + "slug": "konaste-ge-6p5-lt-7", "title": "Level 6.50 - 6.99 (Konaste)", - "where": "chart.level_num >= 6.5 AND chart.level_num < 7", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 6.5 AND chart.level_num < 7" }, { "game": "gitadora-dora", @@ -12835,9 +14168,10 @@ "inactive": false, "legacyFolderID": "Fa8a1004ef6dafa4cc5b39d6b51177a4057f7a6c5960a1ddb1a9dd422f41c50f9", "searchTerms": [], + "slug": "konaste-ge-7-lt-7p5", "title": "Level 7.00 - 7.49 (Konaste)", - "where": "chart.level_num >= 7 AND chart.level_num < 7.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 7 AND chart.level_num < 7.5" }, { "game": "gitadora-dora", @@ -12845,9 +14179,10 @@ "inactive": false, "legacyFolderID": "Fe24216fcc0eddceae24d345e1fccbcc59d71e40b27f9e94bffabd601be3decfa", "searchTerms": [], + "slug": "konaste-ge-7p5-lt-8", "title": "Level 7.50 - 7.99 (Konaste)", - "where": "chart.level_num >= 7.5 AND chart.level_num < 8", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 7.5 AND chart.level_num < 8" }, { "game": "gitadora-dora", @@ -12855,9 +14190,10 @@ "inactive": false, "legacyFolderID": "F72b7fe3ed49dc96f6b8a722419718ae7e35c260b5f998d203d9ae986ba96818a", "searchTerms": [], + "slug": "konaste-ge-8-lt-8p5", "title": "Level 8.00 - 8.49 (Konaste)", - "where": "chart.level_num >= 8 AND chart.level_num < 8.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 8 AND chart.level_num < 8.5" }, { "game": "gitadora-dora", @@ -12865,9 +14201,10 @@ "inactive": false, "legacyFolderID": "Fe0141df3e77fcedfce71210dabf3712f9907e03863f1705f1c2ab758c33fa8d4", "searchTerms": [], + "slug": "konaste-ge-8p5-lt-9", "title": "Level 8.50 - 8.99 (Konaste)", - "where": "chart.level_num >= 8.5 AND chart.level_num < 9", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 8.5 AND chart.level_num < 9" }, { "game": "gitadora-dora", @@ -12875,9 +14212,10 @@ "inactive": false, "legacyFolderID": "F4ffaaa71b34eceba275424d18733c8051c86c6d7127f129f3f835bc6447047ef", "searchTerms": [], + "slug": "konaste-ge-9-lt-9p5", "title": "Level 9.00 - 9.49 (Konaste)", - "where": "chart.level_num >= 9 AND chart.level_num < 9.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 9 AND chart.level_num < 9.5" }, { "game": "gitadora-dora", @@ -12885,9 +14223,10 @@ "inactive": false, "legacyFolderID": "Ff9aef6754daed011432d88cd72f6e627516a70fbe78c5a58ad52532172ceed79", "searchTerms": [], + "slug": "konaste-ge-9p5-lt-10", "title": "Level 9.50 - 9.99 (Konaste)", - "where": "chart.level_num >= 9.5 AND chart.level_num < 10", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 9.5 AND chart.level_num < 10" }, { "game": "gitadora-gita", @@ -12895,9 +14234,10 @@ "inactive": false, "legacyFolderID": "F042b7d6868a754a8e4b0548044d6d3686c48e6bc300ea694a62f72bef7cc4e7a", "searchTerms": [], + "slug": "konaste-ge-1-lt-2", "title": "Level 1.00 - 1.99 (Konaste)", - "where": "chart.level_num >= 1 AND chart.level_num < 2", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 1 AND chart.level_num < 2" }, { "game": "gitadora-gita", @@ -12905,9 +14245,10 @@ "inactive": false, "legacyFolderID": "F8474624585dfea3e7c6c0a20b1041b3da5af577bed5db02fa81755b104344a64", "searchTerms": [], + "slug": "konaste-ge-2-lt-3", "title": "Level 2.00 - 2.99 (Konaste)", - "where": "chart.level_num >= 2 AND chart.level_num < 3", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 2 AND chart.level_num < 3" }, { "game": "gitadora-gita", @@ -12915,9 +14256,10 @@ "inactive": false, "legacyFolderID": "Fc3c01addb3869d3af9a36cc42b832ff7bad89a3c82af04f10912a7543480282a", "searchTerms": [], + "slug": "konaste-ge-3-lt-4", "title": "Level 3.00 - 3.99 (Konaste)", - "where": "chart.level_num >= 3 AND chart.level_num < 4", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 3 AND chart.level_num < 4" }, { "game": "gitadora-gita", @@ -12925,9 +14267,10 @@ "inactive": false, "legacyFolderID": "Fef47234d737c9896906125e6605728a79c74ed9e447b0aea7cb73a43b0205e58", "searchTerms": [], + "slug": "konaste-ge-4-lt-5", "title": "Level 4.00 - 4.99 (Konaste)", - "where": "chart.level_num >= 4 AND chart.level_num < 5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 4 AND chart.level_num < 5" }, { "game": "gitadora-gita", @@ -12935,9 +14278,10 @@ "inactive": false, "legacyFolderID": "F95293ad6bb0c71b0f8890823876c4d6f72a5f07272bbfddf0d2df28a8e7c13ff", "searchTerms": [], + "slug": "konaste-ge-5-lt-5p5", "title": "Level 5.00 - 5.49 (Konaste)", - "where": "chart.level_num >= 5 AND chart.level_num < 5.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 5 AND chart.level_num < 5.5" }, { "game": "gitadora-gita", @@ -12945,9 +14289,10 @@ "inactive": false, "legacyFolderID": "F95e944c14c2bde5f8930a0285a88e045740a5ca72447b824f7039ae6cfa370bc", "searchTerms": [], + "slug": "konaste-ge-5p5-lt-6", "title": "Level 5.50 - 5.99 (Konaste)", - "where": "chart.level_num >= 5.5 AND chart.level_num < 6", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 5.5 AND chart.level_num < 6" }, { "game": "gitadora-gita", @@ -12955,9 +14300,10 @@ "inactive": false, "legacyFolderID": "Fbb466321f00c0a7dee81acc115d57b929c2953f2c797f0ba3b98bda7ec3ef8be", "searchTerms": [], + "slug": "konaste-ge-6-lt-6p5", "title": "Level 6.00 - 6.49 (Konaste)", - "where": "chart.level_num >= 6 AND chart.level_num < 6.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 6 AND chart.level_num < 6.5" }, { "game": "gitadora-gita", @@ -12965,9 +14311,10 @@ "inactive": false, "legacyFolderID": "F4a34a7a86ab683e9b2cce4c98acb20110a57ba65068fa8ac36b1d8c6a3c92e6b", "searchTerms": [], + "slug": "konaste-ge-6p5-lt-7", "title": "Level 6.50 - 6.99 (Konaste)", - "where": "chart.level_num >= 6.5 AND chart.level_num < 7", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 6.5 AND chart.level_num < 7" }, { "game": "gitadora-gita", @@ -12975,9 +14322,10 @@ "inactive": false, "legacyFolderID": "F951abc7b7e185117b645b0dc7e158815e0bb95da77dc4bded8b518befb29970f", "searchTerms": [], + "slug": "konaste-ge-7-lt-7p5", "title": "Level 7.00 - 7.49 (Konaste)", - "where": "chart.level_num >= 7 AND chart.level_num < 7.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 7 AND chart.level_num < 7.5" }, { "game": "gitadora-gita", @@ -12985,9 +14333,10 @@ "inactive": false, "legacyFolderID": "F639ce185ea10e7d56068d3c62f27b14a7ce9100b0aaf0772eeeb79706a87d6f2", "searchTerms": [], + "slug": "konaste-ge-7p5-lt-8", "title": "Level 7.50 - 7.99 (Konaste)", - "where": "chart.level_num >= 7.5 AND chart.level_num < 8", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 7.5 AND chart.level_num < 8" }, { "game": "gitadora-gita", @@ -12995,9 +14344,10 @@ "inactive": false, "legacyFolderID": "F715966c07fb1626f7f133c8c5528be795a11c39911e944f87ddb778720fc0fdd", "searchTerms": [], + "slug": "konaste-ge-8-lt-8p5", "title": "Level 8.00 - 8.49 (Konaste)", - "where": "chart.level_num >= 8 AND chart.level_num < 8.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 8 AND chart.level_num < 8.5" }, { "game": "gitadora-gita", @@ -13005,9 +14355,10 @@ "inactive": false, "legacyFolderID": "F516642e75e9460efa133ed2ca6173af2cfe9417408aaea5e0753eac498b47992", "searchTerms": [], + "slug": "konaste-ge-8p5-lt-9", "title": "Level 8.50 - 8.99 (Konaste)", - "where": "chart.level_num >= 8.5 AND chart.level_num < 9", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 8.5 AND chart.level_num < 9" }, { "game": "gitadora-gita", @@ -13015,9 +14366,10 @@ "inactive": false, "legacyFolderID": "F5a55f818a08b50a3bcacf16c53d0f0eda0ebbc5db3d6b15f7836c8077919b330", "searchTerms": [], + "slug": "konaste-ge-9-lt-9p5", "title": "Level 9.00 - 9.49 (Konaste)", - "where": "chart.level_num >= 9 AND chart.level_num < 9.5", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 9 AND chart.level_num < 9.5" }, { "game": "gitadora-gita", @@ -13025,9 +14377,10 @@ "inactive": false, "legacyFolderID": "Fad86e6fe8eb8b5c569383faa1830204420ef794bfbeb8410e8d2a707b9b1e864", "searchTerms": [], + "slug": "konaste-ge-9p5-lt-10", "title": "Level 9.50 - 9.99 (Konaste)", - "where": "chart.level_num >= 9.5 AND chart.level_num < 10", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level_num >= 9.5 AND chart.level_num < 10" }, { "game": "iidx-dp", @@ -13035,9 +14388,10 @@ "inactive": true, "legacyFolderID": "F1817b2ac2faeef75eba1fdb4be776cd04512a48cbbe28bfd139076a8f4d8e396", "searchTerms": [], + "slug": "1-10th-cs", "title": "Level 1 (10th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13045,9 +14399,10 @@ "inactive": true, "legacyFolderID": "Feeaf81047210abc2e25d3b75b6c1d251ebf818ca6cd6c1d4a81bea6e687e4783", "searchTerms": [], + "slug": "1-3rd-cs", "title": "Level 1 (3rd Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13055,9 +14410,10 @@ "inactive": true, "legacyFolderID": "Fd6e27bc530f88d69b02c7b48341d6902af628abcc84ba70bb335a9bfe1e2ac8d", "searchTerms": [], + "slug": "1-4th-cs", "title": "Level 1 (4th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13065,9 +14421,10 @@ "inactive": true, "legacyFolderID": "Fe28ce538dc009afbca6d346616e3ff0095315bf36f607eb23bcc0b2bf236968d", "searchTerms": [], + "slug": "1-5th-cs", "title": "Level 1 (5th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13075,9 +14432,10 @@ "inactive": true, "legacyFolderID": "Fd8928c46e1d5e0aff58b05098150adadece53af556b3889e69b9f74b5167c8d7", "searchTerms": [], + "slug": "1-6th-cs", "title": "Level 1 (6th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13085,9 +14443,10 @@ "inactive": true, "legacyFolderID": "F058c8060f18c19f025ee21cc5dceb6fefbd0593cc07173313dc663879f39cc4c", "searchTerms": [], + "slug": "1-7th-cs", "title": "Level 1 (7th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13095,9 +14454,10 @@ "inactive": true, "legacyFolderID": "F52303aa8c8d9d260eea27e75b432fe27cf7adfdd941df8d72bbc8c6b32c43f1e", "searchTerms": [], + "slug": "1-8th-cs", "title": "Level 1 (8th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13105,9 +14465,10 @@ "inactive": true, "legacyFolderID": "Febe38040f3dbae7aeba59e7231ba900c3bb069dd812501cda7ace8c598762236", "searchTerms": [], + "slug": "1-9th-cs", "title": "Level 1 (9th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13115,9 +14476,10 @@ "inactive": true, "legacyFolderID": "Fd380fe20c20201f1bba2acc27eea89b6f8f25fd441f9f5244d6cf137d3038c06", "searchTerms": [], + "slug": "1-bmus", "title": "Level 1 (BEATMANIA US)", - "where": "chart.level = '1'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13125,9 +14487,10 @@ "inactive": true, "legacyFolderID": "F447f9872ffc845a8252209f951f5b740f7542ddce10ad79a4e91bedabd88f2fc", "searchTerms": [], + "slug": "1-bistrover-omni", "title": "Level 1 (BISTROVER Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13135,9 +14498,10 @@ "inactive": true, "legacyFolderID": "Ff16f1e656bc837c56291d1477408fe90c88b093f998969be328583273e3d35a5", "searchTerms": [], + "slug": "1-bistrover", "title": "Level 1 (BISTROVER)", - "where": "chart.level = '1'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13145,9 +14509,10 @@ "inactive": true, "legacyFolderID": "F37beed3c3fd45a06151c4d61e357e6b4a84630b6c956ea34b7aaa7197a586952", "searchTerms": [], + "slug": "1-cannonballers", "title": "Level 1 (CANNON BALLERS)", - "where": "chart.level = '1'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13155,9 +14520,10 @@ "inactive": true, "legacyFolderID": "Fc69d42c0d7a35cf628dfefc49ecc3f15969cd7db26014fd7c11859b64d08d47e", "searchTerms": [], + "slug": "1-casthour-omni", "title": "Level 1 (CastHour Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13165,9 +14531,10 @@ "inactive": true, "legacyFolderID": "F72f522ea790115f463f08d9f3c3d24ab95e6baa3edabd510b4f11c4a67139e16", "searchTerms": [], + "slug": "1-casthour", "title": "Level 1 (CastHour)", - "where": "chart.level = '1'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13175,9 +14542,10 @@ "inactive": true, "legacyFolderID": "F86d78c9c8af785c164be547ed6aab72bdc9f45a5bfaed99c28ee1d5827ee21b2", "searchTerms": [], + "slug": "1-copula", "title": "Level 1 (copula)", - "where": "chart.level = '1'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13185,9 +14553,10 @@ "inactive": true, "legacyFolderID": "F82b4222c41c0edfe21d44a42caaa8ee94378ed2351a2063cbb4edefd059e8037", "searchTerms": [], + "slug": "1-13th-cs", "title": "Level 1 (DISTORTED CS)", - "where": "chart.level = '1'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13195,9 +14564,10 @@ "inactive": true, "legacyFolderID": "F1acc29dcea7c4eec57a0af090ee8dad0f35d33ae8a8ff371cd21e5e2d7cccce2", "searchTerms": [], + "slug": "1-15th-cs", "title": "Level 1 (DJ TROOPERS CS)", - "where": "chart.level = '1'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13205,9 +14575,10 @@ "inactive": true, "legacyFolderID": "F529b2870050fd91bf57d23ddb27489939d2bdc0e88ffd70aa58d81ffd6a3ad0e", "searchTerms": [], + "slug": "1-16th-cs", "title": "Level 1 (EMPRESS CS)", - "where": "chart.level = '1'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13215,9 +14586,10 @@ "inactive": true, "legacyFolderID": "Fce0f83e4232f13acf7e4755083c91006f470a7f0bae3ff104b91db35ddad92b5", "searchTerms": [], + "slug": "1-epolis-omni", "title": "Level 1 (Epolis Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13225,9 +14597,10 @@ "inactive": true, "legacyFolderID": "F433523a3d513a07b3e1e4e4b9441eec2337f748bd6e8a249ea49dd4bcae92d7b", "searchTerms": [], + "slug": "1-epolis", "title": "Level 1 (EPOLIS)", - "where": "chart.level = '1'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13235,9 +14608,10 @@ "inactive": true, "legacyFolderID": "F14159009731b0ab1fca61d9f61a51aafb9c014ef6a5aa814db697ffefc79cfc1", "searchTerms": [], + "slug": "1-14th-cs", "title": "Level 1 (GOLD CS)", - "where": "chart.level = '1'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13245,9 +14619,10 @@ "inactive": true, "legacyFolderID": "Ffe9a9fa1a0450daa931eabd6e11412b6b9552a7d0ce422a2dd0307ba713f3c67", "searchTerms": [], + "slug": "1-12th-cs", "title": "Level 1 (HAPPY SKY CS)", - "where": "chart.level = '1'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13255,9 +14630,10 @@ "inactive": true, "legacyFolderID": "F466941324f463e2fcdf34cc6e3ae939a110041f1b35d180a78d671b2a6aa07fb", "searchTerms": [], + "slug": "1-heroicverse-2dxtra", "title": "Level 1 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '1'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13265,9 +14641,10 @@ "inactive": true, "legacyFolderID": "F6f66e1724c49e4ba2912c4d5ba43229e0ca22d6302b9a61a55cfb27c99250467", "searchTerms": [], + "slug": "1-heroicverse-omni", "title": "Level 1 (HEROIC VERSE Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13275,9 +14652,10 @@ "inactive": true, "legacyFolderID": "Fe27db32043858983c036c171fad659d4b276efccb6e124414a5c215e6a1ebd99", "searchTerms": [], + "slug": "1-heroicverse", "title": "Level 1 (HEROIC VERSE)", - "where": "chart.level = '1'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13285,9 +14663,10 @@ "inactive": true, "legacyFolderID": "F452a320e22cb3b8403840485ea79bea58c2ce6f296650f1b52f56b7453372a6f", "searchTerms": [], + "slug": "1-11th-cs", "title": "Level 1 (IIDX RED CS)", - "where": "chart.level = '1'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13295,9 +14674,10 @@ "inactive": false, "legacyFolderID": "F53bacbfb216135e2a22cd89055625d62610389cc488042c1ed09a6118f593ce4", "searchTerms": [], + "slug": "1-inf", "title": "Level 1 (INFINITAS)", - "where": "chart.level = '1'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13305,9 +14685,10 @@ "inactive": true, "legacyFolderID": "F323a40926c9239a8258047fd327ff4d457d7a369a7c7e279c12cd0435bb44a73", "searchTerms": [], + "slug": "1-pendual", "title": "Level 1 (PENDUAL)", - "where": "chart.level = '1'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13315,9 +14696,10 @@ "inactive": false, "legacyFolderID": "Fc1411fe3deeada73749dca7bbc41a01c9b7311467b149c95a1fda2942e788e05", "searchTerms": [], + "slug": "1-pinkycrush-omni", "title": "Level 1 (Pinky Crush Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13325,9 +14707,10 @@ "inactive": false, "legacyFolderID": "F2b2fcda94a766584618391be7c46b9dac6b7914f92386ad0171b97403a4f849f", "searchTerms": [], + "slug": "1-pinkycrush", "title": "Level 1 (Pinky Crush)", - "where": "chart.level = '1'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13335,9 +14718,10 @@ "inactive": true, "legacyFolderID": "Ff4de2cc21bffa3e6e9eb8725e0fbe0986c231dab88178d584c41c7b3c256b118", "searchTerms": [], + "slug": "1-resident-omni", "title": "Level 1 (Resident Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13345,9 +14729,10 @@ "inactive": true, "legacyFolderID": "F1218170b008e056f1651d6bca37c6cf5eb0f3bc1f770c94c5a56677a3a468314", "searchTerms": [], + "slug": "1-resident", "title": "Level 1 (Resident)", - "where": "chart.level = '1'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13355,9 +14740,10 @@ "inactive": true, "legacyFolderID": "F174738197aea623b7c061abf43b373474caf980e9956141fbd6fcd1d49d4123c", "searchTerms": [], + "slug": "1-rootage-omni", "title": "Level 1 (ROOTAGE Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13365,9 +14751,10 @@ "inactive": true, "legacyFolderID": "Ff1a84ddf3b7a6985fafdae0e2b53eebec5a2fc88908a4f5ef7ca67b271c4fa49", "searchTerms": [], + "slug": "1-rootage", "title": "Level 1 (ROOTAGE)", - "where": "chart.level = '1'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13375,9 +14762,10 @@ "inactive": true, "legacyFolderID": "F2f00312f395eb78c060f47e090cf814754b051ed27ee0e52bedebca6277bb735", "searchTerms": [], + "slug": "1-sinobuz", "title": "Level 1 (SINOBUZ)", - "where": "chart.level = '1'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13385,9 +14773,10 @@ "inactive": true, "legacyFolderID": "Feea7c9a8d302a75b704ce3e2a6500895027ca2aafbc4377a3d72dccbf217ac19", "searchTerms": [], + "slug": "1-spada", "title": "Level 1 (SPADA)", - "where": "chart.level = '1'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13395,9 +14784,10 @@ "inactive": false, "legacyFolderID": "Fa48be9743bbf5c76506acd751db0e1f9ca6c39ea78e9d32bfd1c120da3a0e675", "searchTerms": [], + "slug": "1-sparkleshower", "title": "Level 1 (Sparkle Shower)", - "where": "chart.level = '1'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13405,9 +14795,10 @@ "inactive": true, "legacyFolderID": "Fbf2c18031f426d0eeb5a72809e53bdb8b29dcf56ed6b3acfcf26cd3d000078df", "searchTerms": [], + "slug": "1-tricoro", "title": "Level 1 (tricoro)", - "where": "chart.level = '1'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '1'" }, { "game": "iidx-dp", @@ -13415,9 +14806,10 @@ "inactive": true, "legacyFolderID": "F576ef6cbd2c4a0c9b09a266f12c62202dbcf22d0dc8191f1b1f33021836851ef", "searchTerms": [], + "slug": "10-10th-cs", "title": "Level 10 (10th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13425,9 +14817,10 @@ "inactive": true, "legacyFolderID": "Fa49435ad9b01c961c04924c7a35c6da42cca565e04ed6134d28427c38d011f72", "searchTerms": [], + "slug": "10-3rd-cs", "title": "Level 10 (3rd Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13435,9 +14828,10 @@ "inactive": true, "legacyFolderID": "F312bafd57eec7b0ca24bde5b58a4f8ed579f3bf96425094d6bae0e871ac67fea", "searchTerms": [], + "slug": "10-4th-cs", "title": "Level 10 (4th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13445,9 +14839,10 @@ "inactive": true, "legacyFolderID": "F626b9d0f930667cae2d50fba090c6773e2e73ee13bd1a7df7194909398f79b16", "searchTerms": [], + "slug": "10-5th-cs", "title": "Level 10 (5th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13455,9 +14850,10 @@ "inactive": true, "legacyFolderID": "F4948976b0527ca4b1062dcb5aa0164671b07507fd5c0707b5e9cbebafb288057", "searchTerms": [], + "slug": "10-6th-cs", "title": "Level 10 (6th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13465,9 +14861,10 @@ "inactive": true, "legacyFolderID": "F1358fa9cea072ff124024ed7d646bd7e41afe4f3c120f835107bf928ef17a425", "searchTerms": [], + "slug": "10-7th-cs", "title": "Level 10 (7th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13475,9 +14872,10 @@ "inactive": true, "legacyFolderID": "F6a4b74e0c608c3c15d1462bf361bdd2afcb02787cd4719f85fc12909d6cad4a8", "searchTerms": [], + "slug": "10-8th-cs", "title": "Level 10 (8th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13485,9 +14883,10 @@ "inactive": true, "legacyFolderID": "F1bab47a6290e1a019dd502d2e893822934ec098ced28427e2e3872266014aba8", "searchTerms": [], + "slug": "10-9th-cs", "title": "Level 10 (9th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13495,9 +14894,10 @@ "inactive": true, "legacyFolderID": "F3ad4c67ed8ae19c46b0b67aa006dad1e0582e238b395a1caa3b5977312b0d238", "searchTerms": [], + "slug": "10-bmus", "title": "Level 10 (BEATMANIA US)", - "where": "chart.level = '10'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13505,9 +14905,10 @@ "inactive": true, "legacyFolderID": "Ff6ad61edd764c499929587d905de73ca7b9342f2737755353af15956d499f53b", "searchTerms": [], + "slug": "10-bistrover-omni", "title": "Level 10 (BISTROVER Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13515,9 +14916,10 @@ "inactive": true, "legacyFolderID": "F76b3ba1bf11fc4e18cd7606a07c4f8cba4601c84b68805912974889f8b7bedc5", "searchTerms": [], + "slug": "10-bistrover", "title": "Level 10 (BISTROVER)", - "where": "chart.level = '10'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13525,9 +14927,10 @@ "inactive": true, "legacyFolderID": "F84d3c9046275894c00068cf9eea6454a34e9a5615d9bc0c80a7df8a5e432fd9d", "searchTerms": [], + "slug": "10-cannonballers", "title": "Level 10 (CANNON BALLERS)", - "where": "chart.level = '10'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13535,9 +14938,10 @@ "inactive": true, "legacyFolderID": "F43a16ecd8da35fde4718541fd5a458c48cc06e74107fea350d17025f40e00b36", "searchTerms": [], + "slug": "10-casthour-omni", "title": "Level 10 (CastHour Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13545,9 +14949,10 @@ "inactive": true, "legacyFolderID": "Fa80ba9d3a809a7a941f0d05ab3688a33b204b6833c19cf5db70909341787bce8", "searchTerms": [], + "slug": "10-casthour", "title": "Level 10 (CastHour)", - "where": "chart.level = '10'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13555,9 +14960,10 @@ "inactive": true, "legacyFolderID": "F089c29ce95a68f15d6e37bb991804693a03e865e660a4b53abb4983d60945e23", "searchTerms": [], + "slug": "10-copula", "title": "Level 10 (copula)", - "where": "chart.level = '10'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13565,9 +14971,10 @@ "inactive": true, "legacyFolderID": "F70599b483f342ec713ee500795689fbaba97231ae08b0e7a3c92488cad1fc96e", "searchTerms": [], + "slug": "10-13th-cs", "title": "Level 10 (DISTORTED CS)", - "where": "chart.level = '10'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13575,9 +14982,10 @@ "inactive": true, "legacyFolderID": "F710648cd1ff587a850802cc4be7ff9c1dd70c8bd80f74a4ff2c634e3eb2cd502", "searchTerms": [], + "slug": "10-15th-cs", "title": "Level 10 (DJ TROOPERS CS)", - "where": "chart.level = '10'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13585,9 +14993,10 @@ "inactive": true, "legacyFolderID": "F71fb342b27a94e590e6b95b74a7ef85b8622298206c8413bcb19e3e7c5418304", "searchTerms": [], + "slug": "10-16th-cs", "title": "Level 10 (EMPRESS CS)", - "where": "chart.level = '10'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13595,9 +15004,10 @@ "inactive": true, "legacyFolderID": "F11e79bbeaa28b8ff54b8e3905d7ff4d39a93ed972a294f31378e230b900f21e9", "searchTerms": [], + "slug": "10-epolis-omni", "title": "Level 10 (Epolis Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13605,9 +15015,10 @@ "inactive": true, "legacyFolderID": "Fbe4f47c37dd0902394f94049e63c95e1da77686f6b71e38782adb3985d0264b1", "searchTerms": [], + "slug": "10-epolis", "title": "Level 10 (EPOLIS)", - "where": "chart.level = '10'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13615,9 +15026,10 @@ "inactive": true, "legacyFolderID": "F19aa675d7ac2873eb2ea4bc8ceda017028f70275d37600565dd920dc661bc68f", "searchTerms": [], + "slug": "10-14th-cs", "title": "Level 10 (GOLD CS)", - "where": "chart.level = '10'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13625,9 +15037,10 @@ "inactive": true, "legacyFolderID": "F8aba8e8ba7183b29077c59ada7599870fa595c6551bd330ccac1fcc58fa6811a", "searchTerms": [], + "slug": "10-12th-cs", "title": "Level 10 (HAPPY SKY CS)", - "where": "chart.level = '10'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13635,9 +15048,10 @@ "inactive": true, "legacyFolderID": "F7a59859f10603eec7182e4aab40471e0ca2be91d4961ef7684b883a532fe242a", "searchTerms": [], + "slug": "10-heroicverse-2dxtra", "title": "Level 10 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '10'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13645,9 +15059,10 @@ "inactive": true, "legacyFolderID": "F38ef81b5718f6e8c3aa32c7c7f5bcf898992f6a0b6e7df5271914ab2acee0fa0", "searchTerms": [], + "slug": "10-heroicverse-omni", "title": "Level 10 (HEROIC VERSE Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13655,9 +15070,10 @@ "inactive": true, "legacyFolderID": "F61f3e26740860c1ae239fb7e5cd1ab4928bf5bd1fb0f43c666bec2c705a7270d", "searchTerms": [], + "slug": "10-heroicverse", "title": "Level 10 (HEROIC VERSE)", - "where": "chart.level = '10'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13665,9 +15081,10 @@ "inactive": true, "legacyFolderID": "Ffec5aedacaa7cb07fc33d6eef10650d87ed3fd43826a0eb495b2b4d546054c45", "searchTerms": [], + "slug": "10-11th-cs", "title": "Level 10 (IIDX RED CS)", - "where": "chart.level = '10'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13675,9 +15092,10 @@ "inactive": false, "legacyFolderID": "Fb6157afce4f5ce83c6f7b79e0b9175456ceef36cc64bd0a17bbb24e7afe5d211", "searchTerms": [], + "slug": "10-inf", "title": "Level 10 (INFINITAS)", - "where": "chart.level = '10'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13685,9 +15103,10 @@ "inactive": true, "legacyFolderID": "F7c82ab55e957570d2b62f706f367c3cd3506565ce8b58a16cf9e45c9e5f46139", "searchTerms": [], + "slug": "10-pendual", "title": "Level 10 (PENDUAL)", - "where": "chart.level = '10'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13695,9 +15114,10 @@ "inactive": false, "legacyFolderID": "F90577fc619c5e79f53ae83af34212ccf5fc222832ad95131bc07aa1cd6a61c71", "searchTerms": [], + "slug": "10-pinkycrush-omni", "title": "Level 10 (Pinky Crush Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13705,9 +15125,10 @@ "inactive": false, "legacyFolderID": "Ff7de366cce345e873c464922e6d9ac098dc90cc6790896b11a5170c0842b0ae6", "searchTerms": [], + "slug": "10-pinkycrush", "title": "Level 10 (Pinky Crush)", - "where": "chart.level = '10'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13715,9 +15136,10 @@ "inactive": true, "legacyFolderID": "F1efd11cef63058f3bbd628f263e69c3550db00f101600164ecdb2bdbbf700e4c", "searchTerms": [], + "slug": "10-resident-omni", "title": "Level 10 (Resident Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13725,9 +15147,10 @@ "inactive": true, "legacyFolderID": "F8894726fd2e4bc4f453b10db1f1f640d527c97627bf5e2549a0b13655eabb0ed", "searchTerms": [], + "slug": "10-resident", "title": "Level 10 (Resident)", - "where": "chart.level = '10'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13735,9 +15158,10 @@ "inactive": true, "legacyFolderID": "F6e233bcb9f4b958718e3fb3b93716ac8c1a0926456287fbd2a4871b6ad926a5d", "searchTerms": [], + "slug": "10-rootage-omni", "title": "Level 10 (ROOTAGE Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13745,9 +15169,10 @@ "inactive": true, "legacyFolderID": "F24b9b786236c87c389e887d8d59853ad6f2a91d3354c9e46eab7888d5f4b31c0", "searchTerms": [], + "slug": "10-rootage", "title": "Level 10 (ROOTAGE)", - "where": "chart.level = '10'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13755,9 +15180,10 @@ "inactive": true, "legacyFolderID": "F553f87dc519383269083c9fc052b770b485dcd10ec5a37cc7ff44c5d4b62de85", "searchTerms": [], + "slug": "10-sinobuz", "title": "Level 10 (SINOBUZ)", - "where": "chart.level = '10'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13765,9 +15191,10 @@ "inactive": true, "legacyFolderID": "Fbb410561130767bbe2c75f01d69a5372c958e9362cdf7024db616824ef61a4fe", "searchTerms": [], + "slug": "10-spada", "title": "Level 10 (SPADA)", - "where": "chart.level = '10'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13775,9 +15202,10 @@ "inactive": false, "legacyFolderID": "F79a78c80ea952fac15486636d3dddd9eec70bbd0afa10a6534fea21f2e39b07c", "searchTerms": [], + "slug": "10-sparkleshower", "title": "Level 10 (Sparkle Shower)", - "where": "chart.level = '10'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13785,9 +15213,10 @@ "inactive": true, "legacyFolderID": "Faa0a08d6ed50833b260f51da5c5ccc76a75339f0ae5ab7cbbd270098981a4653", "searchTerms": [], + "slug": "10-tricoro", "title": "Level 10 (tricoro)", - "where": "chart.level = '10'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '10'" }, { "game": "iidx-dp", @@ -13795,9 +15224,10 @@ "inactive": true, "legacyFolderID": "F433de3f3fceba9d1f6634fb4e2ec07881109a1a0d42bfacfbfc87d0be49f8a1b", "searchTerms": [], + "slug": "11-10th-cs", "title": "Level 11 (10th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13805,9 +15235,10 @@ "inactive": true, "legacyFolderID": "Fab841203cb6533c1811b0e9dde1ac056b6193c757a8d42368f0fa5afb009b77c", "searchTerms": [], + "slug": "11-3rd-cs", "title": "Level 11 (3rd Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13815,9 +15246,10 @@ "inactive": true, "legacyFolderID": "Fad43f2d8a2ae1198147b2597ed35f72a513a071ba4bd3c5823fb3c5510c01b8b", "searchTerms": [], + "slug": "11-4th-cs", "title": "Level 11 (4th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13825,9 +15257,10 @@ "inactive": true, "legacyFolderID": "F5cc2bb99191c70912e25baa4065462c44049ee9aed9928d0d78f99d4811e1cdd", "searchTerms": [], + "slug": "11-5th-cs", "title": "Level 11 (5th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13835,9 +15268,10 @@ "inactive": true, "legacyFolderID": "Fb0f00000571e1d3342fe1ce8724fc91a9b17cc7461b596393398f598171a385b", "searchTerms": [], + "slug": "11-6th-cs", "title": "Level 11 (6th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13845,9 +15279,10 @@ "inactive": true, "legacyFolderID": "F399a334ba515a60a854f0c3fcc474f3548a951db22a2d8e695f0c8cc92fc1e7f", "searchTerms": [], + "slug": "11-7th-cs", "title": "Level 11 (7th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13855,9 +15290,10 @@ "inactive": true, "legacyFolderID": "Fef6dbe6f88fadc1e6ab865cbc0368f54447c1aed34ae7ce38f893bbc28992567", "searchTerms": [], + "slug": "11-8th-cs", "title": "Level 11 (8th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13865,9 +15301,10 @@ "inactive": true, "legacyFolderID": "F593ce7ac14430f0417dfce336739941184ec9d9d54067cd74a5e1dd7711cd0cb", "searchTerms": [], + "slug": "11-9th-cs", "title": "Level 11 (9th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13875,9 +15312,10 @@ "inactive": true, "legacyFolderID": "Fac89cf6672db69fd245c6c89a47a1e390089bcbbb541205d272593cfdc658e8b", "searchTerms": [], + "slug": "11-bmus", "title": "Level 11 (BEATMANIA US)", - "where": "chart.level = '11'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13885,9 +15323,10 @@ "inactive": true, "legacyFolderID": "Fe432fcc5a4f05a11f66fd43987897e700f718be3f973e925bd2ce7849d5c53a1", "searchTerms": [], + "slug": "11-bistrover-omni", "title": "Level 11 (BISTROVER Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13895,9 +15334,10 @@ "inactive": true, "legacyFolderID": "F5d497ade0426dbac64e2ca9dfd1c40cd6256ee8b1faa3fbb767a3d306681b8c9", "searchTerms": [], + "slug": "11-bistrover", "title": "Level 11 (BISTROVER)", - "where": "chart.level = '11'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13905,9 +15345,10 @@ "inactive": true, "legacyFolderID": "F9fda75933cba3db6f53e77c73fdd664b4177fe2464b84b9e8c7dce81e981fdca", "searchTerms": [], + "slug": "11-cannonballers", "title": "Level 11 (CANNON BALLERS)", - "where": "chart.level = '11'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13915,9 +15356,10 @@ "inactive": true, "legacyFolderID": "F3ec36187d4889aa539009da648ac0bbe8d187bf1b9e70a20cffc3d325aaa95a0", "searchTerms": [], + "slug": "11-casthour-omni", "title": "Level 11 (CastHour Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13925,9 +15367,10 @@ "inactive": true, "legacyFolderID": "F0305e2715be6ecde7849175f017b1a2021d947f160369a3960207ab8d3c0b216", "searchTerms": [], + "slug": "11-casthour", "title": "Level 11 (CastHour)", - "where": "chart.level = '11'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13935,9 +15378,10 @@ "inactive": true, "legacyFolderID": "F0371fda1947867b76b79168fd1c8f5a82dcccd3abb438ee20933f3054f5098c5", "searchTerms": [], + "slug": "11-copula", "title": "Level 11 (copula)", - "where": "chart.level = '11'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13945,9 +15389,10 @@ "inactive": true, "legacyFolderID": "Fad04c12c27fddaceb2d4b89546c5837c442cd4375e933b4324ca96dbd71f1ba5", "searchTerms": [], + "slug": "11-13th-cs", "title": "Level 11 (DISTORTED CS)", - "where": "chart.level = '11'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13955,9 +15400,10 @@ "inactive": true, "legacyFolderID": "F6c235a94ab136dc6a17dfffb30cf2dec005ca61edce6b0b8c39a0266e2d8d1a4", "searchTerms": [], + "slug": "11-15th-cs", "title": "Level 11 (DJ TROOPERS CS)", - "where": "chart.level = '11'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13965,9 +15411,10 @@ "inactive": true, "legacyFolderID": "F251455f5ec1c8112053cc93cd1d478c8cbd9b97b68d074e80d00bc6fb617d20a", "searchTerms": [], + "slug": "11-16th-cs", "title": "Level 11 (EMPRESS CS)", - "where": "chart.level = '11'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13975,9 +15422,10 @@ "inactive": true, "legacyFolderID": "Fdae84f4eb3c7a88e480c42389b2f88c12e4b449d5fea1ff121aa229b2382f9a9", "searchTerms": [], + "slug": "11-epolis-omni", "title": "Level 11 (Epolis Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13985,9 +15433,10 @@ "inactive": true, "legacyFolderID": "F9c0a1ec60283b225f62eb8cd5ad240651fad00c1cbdfc2b1ca3fe3cb0e0689a4", "searchTerms": [], + "slug": "11-epolis", "title": "Level 11 (EPOLIS)", - "where": "chart.level = '11'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -13995,9 +15444,10 @@ "inactive": true, "legacyFolderID": "F75593cd8aa5156a7f2fda4383609a3acf39048f5575c154038f01861c199fb60", "searchTerms": [], + "slug": "11-14th-cs", "title": "Level 11 (GOLD CS)", - "where": "chart.level = '11'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14005,9 +15455,10 @@ "inactive": true, "legacyFolderID": "Fa20753e2b07476d20060b10a814cce51bf6c7173d2d0b9cd8723202b7f61ef31", "searchTerms": [], + "slug": "11-12th-cs", "title": "Level 11 (HAPPY SKY CS)", - "where": "chart.level = '11'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14015,9 +15466,10 @@ "inactive": true, "legacyFolderID": "F59b9e73289923ebb92033feaeded15de9400e403172bc5c0f1b687659e031586", "searchTerms": [], + "slug": "11-heroicverse-2dxtra", "title": "Level 11 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '11'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14025,9 +15477,10 @@ "inactive": true, "legacyFolderID": "Fce52f76d2220a411161c1cb8d8ee7449195f5d44b5e5ac3d63e1d5001ec8e2c6", "searchTerms": [], + "slug": "11-heroicverse-omni", "title": "Level 11 (HEROIC VERSE Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14035,9 +15488,10 @@ "inactive": true, "legacyFolderID": "Fc2e8e4f39aab95d9043819cc1417a9c837cab679daaf63d30690f68b7ba26df4", "searchTerms": [], + "slug": "11-heroicverse", "title": "Level 11 (HEROIC VERSE)", - "where": "chart.level = '11'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14045,9 +15499,10 @@ "inactive": true, "legacyFolderID": "F238b5f71cf34aabd243bacca845fa7407fde8b977d31fa71fb856318b71e130b", "searchTerms": [], + "slug": "11-11th-cs", "title": "Level 11 (IIDX RED CS)", - "where": "chart.level = '11'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14055,9 +15510,10 @@ "inactive": false, "legacyFolderID": "F25a77a1c18ebf1e9787918f926dc188ed28350b8b9e94d466dfe7d2bc35d8535", "searchTerms": [], + "slug": "11-inf", "title": "Level 11 (INFINITAS)", - "where": "chart.level = '11'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14065,9 +15521,10 @@ "inactive": true, "legacyFolderID": "F6276248d3d2d617eae581b2c2595d39481eecb703df9eb73bc0d8ef920002ad2", "searchTerms": [], + "slug": "11-pendual", "title": "Level 11 (PENDUAL)", - "where": "chart.level = '11'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14075,9 +15532,10 @@ "inactive": false, "legacyFolderID": "F1b340f24ab4b5c6bef93824a3d9e7f60106d68951b3f7977e097be64a7c74255", "searchTerms": [], + "slug": "11-pinkycrush-omni", "title": "Level 11 (Pinky Crush Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14085,9 +15543,10 @@ "inactive": false, "legacyFolderID": "F2f12724378cd72e4d55d309bf8168f9313f9b5d1f65a06ca7a1b12be4f48a4fe", "searchTerms": [], + "slug": "11-pinkycrush", "title": "Level 11 (Pinky Crush)", - "where": "chart.level = '11'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14095,9 +15554,10 @@ "inactive": true, "legacyFolderID": "Fe59d635ed6e06f7f2962324bf8381d2b02cdcd706bee31a2c95b9f5fb87504d2", "searchTerms": [], + "slug": "11-resident-omni", "title": "Level 11 (Resident Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14105,9 +15565,10 @@ "inactive": true, "legacyFolderID": "F48eaed0a62996f00c7aabe9b2830e3cc40f00dfb13584e2e795fa0d6cfafc863", "searchTerms": [], + "slug": "11-resident", "title": "Level 11 (Resident)", - "where": "chart.level = '11'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14115,9 +15576,10 @@ "inactive": true, "legacyFolderID": "F6fc2860a02c118c46f3026a062aa37bc20aefd974fd71537cf31f265d3bc421c", "searchTerms": [], + "slug": "11-rootage-omni", "title": "Level 11 (ROOTAGE Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14125,9 +15587,10 @@ "inactive": true, "legacyFolderID": "F9083e822858ea931bad05334affeed6daecad2d6500f18ad2ab92fcad549e834", "searchTerms": [], + "slug": "11-rootage", "title": "Level 11 (ROOTAGE)", - "where": "chart.level = '11'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14135,9 +15598,10 @@ "inactive": true, "legacyFolderID": "F8a2d5408d8cb59f1dd5f994b349b7cf84422984224aa5a058743c9c7dafc7f19", "searchTerms": [], + "slug": "11-sinobuz", "title": "Level 11 (SINOBUZ)", - "where": "chart.level = '11'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14145,9 +15609,10 @@ "inactive": true, "legacyFolderID": "F8997ef87f72197c494eca233ceb251697da6f8167fe4eb74927d291fc6d5dd3e", "searchTerms": [], + "slug": "11-spada", "title": "Level 11 (SPADA)", - "where": "chart.level = '11'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14155,9 +15620,10 @@ "inactive": false, "legacyFolderID": "F74d0073038e258ed6e7430fe73e9d05e575d8b64704d5396d4108c02b8494853", "searchTerms": [], + "slug": "11-sparkleshower", "title": "Level 11 (Sparkle Shower)", - "where": "chart.level = '11'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14165,9 +15631,10 @@ "inactive": true, "legacyFolderID": "F8212db5d7f00f1266b3136f9d7a99d481e24e978062475a4f234da4d4e3b541e", "searchTerms": [], + "slug": "11-tricoro", "title": "Level 11 (tricoro)", - "where": "chart.level = '11'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '11'" }, { "game": "iidx-dp", @@ -14175,9 +15642,10 @@ "inactive": true, "legacyFolderID": "Fecfb10862487a9e9a37d0b0b520493a847e62d72ac20008f36f3e61326bed461", "searchTerms": [], + "slug": "12-10th-cs", "title": "Level 12 (10th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14185,9 +15653,10 @@ "inactive": true, "legacyFolderID": "F478124d436c270c4df4310be8488d95063702b4db042409f2af2a9ba6cf8e063", "searchTerms": [], + "slug": "12-3rd-cs", "title": "Level 12 (3rd Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14195,9 +15664,10 @@ "inactive": true, "legacyFolderID": "Fee65664d07085e1fc77882e2e1ede609850ad7933b9ffa377727d25e1c5d4924", "searchTerms": [], + "slug": "12-4th-cs", "title": "Level 12 (4th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14205,9 +15675,10 @@ "inactive": true, "legacyFolderID": "F56474db2e70597dbf02341284d9b3eb102a755418f969bfe043a643c8f803e51", "searchTerms": [], + "slug": "12-5th-cs", "title": "Level 12 (5th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14215,9 +15686,10 @@ "inactive": true, "legacyFolderID": "F7aed476f40af0fa8f548f6b4bec7fc07b88b737c16f25ed53e368a50be4d505b", "searchTerms": [], + "slug": "12-6th-cs", "title": "Level 12 (6th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14225,9 +15697,10 @@ "inactive": true, "legacyFolderID": "F7cd30b1d6d2972e741ee4fd87429cb079e2cc0015a0c0326444367824a062358", "searchTerms": [], + "slug": "12-7th-cs", "title": "Level 12 (7th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14235,9 +15708,10 @@ "inactive": true, "legacyFolderID": "F65d3ee5a8f757c18496becde1d7e80a2ce7861cee002ddec847c66017dc96d92", "searchTerms": [], + "slug": "12-8th-cs", "title": "Level 12 (8th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14245,9 +15719,10 @@ "inactive": true, "legacyFolderID": "Fefe4b0af19c16f1117da5e46faabee2cf6cd1b385ed6691e728d1cfd8fc29113", "searchTerms": [], + "slug": "12-9th-cs", "title": "Level 12 (9th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14255,9 +15730,10 @@ "inactive": true, "legacyFolderID": "F5547d9d5fe52053cb5a14123bd617377bab2cafbd787ab4c002fa7532e4cc926", "searchTerms": [], + "slug": "12-bmus", "title": "Level 12 (BEATMANIA US)", - "where": "chart.level = '12'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14265,9 +15741,10 @@ "inactive": true, "legacyFolderID": "Fd866771c400da15d7ff0735f9e41447d299da9eae0af931f8ce803e4f9151eb2", "searchTerms": [], + "slug": "12-bistrover-omni", "title": "Level 12 (BISTROVER Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14275,9 +15752,10 @@ "inactive": true, "legacyFolderID": "F4c4e2e19957c749e699c76d8a9ddbefeb78f6106d62afba870b301858262ca90", "searchTerms": [], + "slug": "12-bistrover", "title": "Level 12 (BISTROVER)", - "where": "chart.level = '12'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14285,9 +15763,10 @@ "inactive": true, "legacyFolderID": "F5a8d8098f61dc677fe63c56ff123e971b9a39a44540cf3db6e6554440e86142a", "searchTerms": [], + "slug": "12-cannonballers", "title": "Level 12 (CANNON BALLERS)", - "where": "chart.level = '12'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14295,9 +15774,10 @@ "inactive": true, "legacyFolderID": "F56cd39e50005431e7b3f8d002b2e6f8772cf447660321bb241a9719615f158be", "searchTerms": [], + "slug": "12-casthour-omni", "title": "Level 12 (CastHour Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14305,9 +15785,10 @@ "inactive": true, "legacyFolderID": "F822bedcc82fef2b4e982f5214ca964db8a2948e522fb9b9c3458fab2cc6c17ed", "searchTerms": [], + "slug": "12-casthour", "title": "Level 12 (CastHour)", - "where": "chart.level = '12'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14315,9 +15796,10 @@ "inactive": true, "legacyFolderID": "F1b05751ca2c138e2f7133b54cad97b72c28cec8c72c31d9cd3583f059e413b38", "searchTerms": [], + "slug": "12-copula", "title": "Level 12 (copula)", - "where": "chart.level = '12'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14325,9 +15807,10 @@ "inactive": true, "legacyFolderID": "Ff39f625d5888e777b778388fedc8918d4d69e0f5ef7e3dfeccc0521342c11e78", "searchTerms": [], + "slug": "12-13th-cs", "title": "Level 12 (DISTORTED CS)", - "where": "chart.level = '12'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14335,9 +15818,10 @@ "inactive": true, "legacyFolderID": "F8b937758a2e4895a2ab0e7a38c55ccd2219508adbd7f9bf0ebdf96173dcaa3e6", "searchTerms": [], + "slug": "12-15th-cs", "title": "Level 12 (DJ TROOPERS CS)", - "where": "chart.level = '12'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14345,9 +15829,10 @@ "inactive": true, "legacyFolderID": "F7c23602ffdb354d28e0b626139a0724cf29f6e33b0571eef0a90d55c1d862c38", "searchTerms": [], + "slug": "12-16th-cs", "title": "Level 12 (EMPRESS CS)", - "where": "chart.level = '12'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14355,9 +15840,10 @@ "inactive": true, "legacyFolderID": "F7b18f594d7d131fc42909104cadd19521596fa953d9adf2c82adb37c6110dcf7", "searchTerms": [], + "slug": "12-epolis-omni", "title": "Level 12 (Epolis Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14365,9 +15851,10 @@ "inactive": true, "legacyFolderID": "F534cb868ae0066629b76a5e92d4c21fbb86d6ff1b8ede167a1a46c4dd8a0fb6f", "searchTerms": [], + "slug": "12-epolis", "title": "Level 12 (EPOLIS)", - "where": "chart.level = '12'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14375,9 +15862,10 @@ "inactive": true, "legacyFolderID": "F4ec3818abcba707a03c684decdd4032ec7b73c559cfe5ac646f6977666c80a32", "searchTerms": [], + "slug": "12-14th-cs", "title": "Level 12 (GOLD CS)", - "where": "chart.level = '12'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14385,9 +15873,10 @@ "inactive": true, "legacyFolderID": "Fcb96d9b9164447cf9beb1e47b482343842c78c9b1286254c246c230b577288b3", "searchTerms": [], + "slug": "12-12th-cs", "title": "Level 12 (HAPPY SKY CS)", - "where": "chart.level = '12'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14395,9 +15884,10 @@ "inactive": true, "legacyFolderID": "F07e6711e5abe3e07615f88305160ea5f4e8b9c79b08b8ab01475ffa3f6654976", "searchTerms": [], + "slug": "12-heroicverse-2dxtra", "title": "Level 12 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '12'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14405,9 +15895,10 @@ "inactive": true, "legacyFolderID": "Ff24856a4f61a1b19fca4a270324d101112ae932d17ec381d3e492101ff574e33", "searchTerms": [], + "slug": "12-heroicverse-omni", "title": "Level 12 (HEROIC VERSE Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14415,9 +15906,10 @@ "inactive": true, "legacyFolderID": "F67e695391180050e8479939315907ea817728d4e91f96c0dbd586b22a54f5a46", "searchTerms": [], + "slug": "12-heroicverse", "title": "Level 12 (HEROIC VERSE)", - "where": "chart.level = '12'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14425,9 +15917,10 @@ "inactive": true, "legacyFolderID": "F4217fe143a87b222805187682192b8047aa4784372cfa400855306e1370d7edd", "searchTerms": [], + "slug": "12-11th-cs", "title": "Level 12 (IIDX RED CS)", - "where": "chart.level = '12'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14435,9 +15928,10 @@ "inactive": false, "legacyFolderID": "Faea774f550bb3383ddf841557c76db994b152e962d8e704418b8cb0fc8f96239", "searchTerms": [], + "slug": "12-inf", "title": "Level 12 (INFINITAS)", - "where": "chart.level = '12'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14445,9 +15939,10 @@ "inactive": true, "legacyFolderID": "F0540df5168989c59864c0ffd04cb0e2b1ea74f59a86cdc165aae8f5c16512ee2", "searchTerms": [], + "slug": "12-pendual", "title": "Level 12 (PENDUAL)", - "where": "chart.level = '12'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14455,9 +15950,10 @@ "inactive": false, "legacyFolderID": "Fad77774ce608f72d2a149d8de3d47c0974f06878f30e5c429a599cc80becff84", "searchTerms": [], + "slug": "12-pinkycrush-omni", "title": "Level 12 (Pinky Crush Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14465,9 +15961,10 @@ "inactive": false, "legacyFolderID": "F7fffb3d5ebf0d000d1c8d8aa9e3fe6190e940286e623870d38b11edc9502502a", "searchTerms": [], + "slug": "12-pinkycrush", "title": "Level 12 (Pinky Crush)", - "where": "chart.level = '12'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14475,9 +15972,10 @@ "inactive": true, "legacyFolderID": "Fac017a39ab8c4084178808f92c5c7109e3165a03f1f08b091a697196e88ec77d", "searchTerms": [], + "slug": "12-resident-omni", "title": "Level 12 (Resident Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14485,9 +15983,10 @@ "inactive": true, "legacyFolderID": "Faada4f588a096bfae62f5dc8970cc772e93c79d52c12aad72f5da499b41db7b8", "searchTerms": [], + "slug": "12-resident", "title": "Level 12 (Resident)", - "where": "chart.level = '12'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14495,9 +15994,10 @@ "inactive": true, "legacyFolderID": "Ff9d6e4a89d1f1137c912faf226f1dd456cebf72c7907c1000bd0294c78d5a078", "searchTerms": [], + "slug": "12-rootage-omni", "title": "Level 12 (ROOTAGE Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14505,9 +16005,10 @@ "inactive": true, "legacyFolderID": "F8521daa36b08403acb3fa848a3486a8a121e85ce6c80284fec54230c38ee85cd", "searchTerms": [], + "slug": "12-rootage", "title": "Level 12 (ROOTAGE)", - "where": "chart.level = '12'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14515,9 +16016,10 @@ "inactive": true, "legacyFolderID": "F2459705ffcc24fc1a78d4b38457d51d2825f6ab77b91956f12a85dafe3515619", "searchTerms": [], + "slug": "12-sinobuz", "title": "Level 12 (SINOBUZ)", - "where": "chart.level = '12'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14525,9 +16027,10 @@ "inactive": true, "legacyFolderID": "F41e92113b3d1f1d6b84e79e90d0bb6c98e92dbd022b86531d057a2229910979c", "searchTerms": [], + "slug": "12-spada", "title": "Level 12 (SPADA)", - "where": "chart.level = '12'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14535,9 +16038,10 @@ "inactive": false, "legacyFolderID": "Fa7d9b63a67e40b46b490bff57762401bf8047413ed882d053a9d78d2f9152fe6", "searchTerms": [], + "slug": "12-sparkleshower", "title": "Level 12 (Sparkle Shower)", - "where": "chart.level = '12'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14545,9 +16049,10 @@ "inactive": true, "legacyFolderID": "F04a0f8eaf116b4da809f0868487652be14475c47b5078acafb9a75b34aaf6a83", "searchTerms": [], + "slug": "12-tricoro", "title": "Level 12 (tricoro)", - "where": "chart.level = '12'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '12'" }, { "game": "iidx-dp", @@ -14555,9 +16060,10 @@ "inactive": true, "legacyFolderID": "F871fb54146d2f860d9c9a36710777f1097e2f5331de512e9bed690975846d5cc", "searchTerms": [], + "slug": "2-10th-cs", "title": "Level 2 (10th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14565,9 +16071,10 @@ "inactive": true, "legacyFolderID": "Feab2d365b3486306ef400e5e708552bd4c0037a54a07e836a0b0ec9335f64ae3", "searchTerms": [], + "slug": "2-3rd-cs", "title": "Level 2 (3rd Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14575,9 +16082,10 @@ "inactive": true, "legacyFolderID": "F47fec6dbe123e6066ed80088df7c6c10e4a07ea5decb9a480d289ffa5cf15588", "searchTerms": [], + "slug": "2-4th-cs", "title": "Level 2 (4th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14585,9 +16093,10 @@ "inactive": true, "legacyFolderID": "F3386fe13a210a7f25c78d6a288733bbfd4d040d2ab0c196feb668daa94da26bd", "searchTerms": [], + "slug": "2-5th-cs", "title": "Level 2 (5th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14595,9 +16104,10 @@ "inactive": true, "legacyFolderID": "F1a9b080dcb2ce22c767cfb73b2eb763dd572e95a993cc518f51baa7ac78ee195", "searchTerms": [], + "slug": "2-6th-cs", "title": "Level 2 (6th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14605,9 +16115,10 @@ "inactive": true, "legacyFolderID": "F0d71e2e42d147ab762bf6a5b0462635d4d50749fc9d6a49c3cffe782ad1309c2", "searchTerms": [], + "slug": "2-7th-cs", "title": "Level 2 (7th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14615,9 +16126,10 @@ "inactive": true, "legacyFolderID": "F5016b4eda3b01ecba0047eebcd45db9f341f4533a8f1c3b6d1fb4109c6aa0c0e", "searchTerms": [], + "slug": "2-8th-cs", "title": "Level 2 (8th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14625,9 +16137,10 @@ "inactive": true, "legacyFolderID": "F89de284781ec1fb2a796ec72e839b8f227fe72dec4a595573aee2832dfe688df", "searchTerms": [], + "slug": "2-9th-cs", "title": "Level 2 (9th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14635,9 +16148,10 @@ "inactive": true, "legacyFolderID": "F466351166533a94e6fdc3635b6237f814a40d6be4a3a46bf9e8159e66e97fe2a", "searchTerms": [], + "slug": "2-bmus", "title": "Level 2 (BEATMANIA US)", - "where": "chart.level = '2'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14645,9 +16159,10 @@ "inactive": true, "legacyFolderID": "F112925789ec9ce1a35e28f0107b0028c3a7a10da92618f9234f5cb9aa3da8e56", "searchTerms": [], + "slug": "2-bistrover-omni", "title": "Level 2 (BISTROVER Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14655,9 +16170,10 @@ "inactive": true, "legacyFolderID": "F40eb795af4d63730218b9be37d3a6dd20e34e637783bcce03a313222b5db6f04", "searchTerms": [], + "slug": "2-bistrover", "title": "Level 2 (BISTROVER)", - "where": "chart.level = '2'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14665,9 +16181,10 @@ "inactive": true, "legacyFolderID": "F3fcb0c5210d5c064754e6d219af47a1df5792ab9f97dd8c108bb7282b95ff25e", "searchTerms": [], + "slug": "2-cannonballers", "title": "Level 2 (CANNON BALLERS)", - "where": "chart.level = '2'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14675,9 +16192,10 @@ "inactive": true, "legacyFolderID": "Fb16cca53146da196ab23fc13b79633ddcb3f8c61d8d2dcc9082c4f1698b9c91a", "searchTerms": [], + "slug": "2-casthour-omni", "title": "Level 2 (CastHour Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14685,9 +16203,10 @@ "inactive": true, "legacyFolderID": "F25686241f622bccea059f92757862b3e5a89c3764c5db78abb7f44dadf2007d7", "searchTerms": [], + "slug": "2-casthour", "title": "Level 2 (CastHour)", - "where": "chart.level = '2'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14695,9 +16214,10 @@ "inactive": true, "legacyFolderID": "Fe7b1459d6cb10fe6e8e25c02c3f8883c070856e0fe5b2db681bbaaf8550cddc1", "searchTerms": [], + "slug": "2-copula", "title": "Level 2 (copula)", - "where": "chart.level = '2'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14705,9 +16225,10 @@ "inactive": true, "legacyFolderID": "F785319f6eaaf765a5ae546ac083f62993cf13987d0047ee3264c3a8bb8ab6f35", "searchTerms": [], + "slug": "2-13th-cs", "title": "Level 2 (DISTORTED CS)", - "where": "chart.level = '2'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14715,9 +16236,10 @@ "inactive": true, "legacyFolderID": "F6d914fc58effa5e1b8b4359833608c2974e8bc03f5cc08c7ee1e904c6f7c310b", "searchTerms": [], + "slug": "2-15th-cs", "title": "Level 2 (DJ TROOPERS CS)", - "where": "chart.level = '2'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14725,9 +16247,10 @@ "inactive": true, "legacyFolderID": "F1b65f9212b0fc399bc184f461dab33e2bcc1d6aa7f6041241172d96f636de249", "searchTerms": [], + "slug": "2-16th-cs", "title": "Level 2 (EMPRESS CS)", - "where": "chart.level = '2'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14735,9 +16258,10 @@ "inactive": true, "legacyFolderID": "Fbc436e860e309cb518da1c27ca7af7c0b83c5a967fb738a8d0a673fb3d48faab", "searchTerms": [], + "slug": "2-epolis-omni", "title": "Level 2 (Epolis Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14745,9 +16269,10 @@ "inactive": true, "legacyFolderID": "F8121a7d8a56e9c0e599af7dbed57c370b8ada2221830861f0694d3411c346ce1", "searchTerms": [], + "slug": "2-epolis", "title": "Level 2 (EPOLIS)", - "where": "chart.level = '2'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14755,9 +16280,10 @@ "inactive": true, "legacyFolderID": "F093a9944c0cf2948f5c8a6a51867c991415866778e168c8d18803f3ef747c148", "searchTerms": [], + "slug": "2-14th-cs", "title": "Level 2 (GOLD CS)", - "where": "chart.level = '2'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14765,9 +16291,10 @@ "inactive": true, "legacyFolderID": "Fcc700b9ba3a337b5107b88e9a10632c0de79c328f1fb1d3e5e134974cbad5c61", "searchTerms": [], + "slug": "2-12th-cs", "title": "Level 2 (HAPPY SKY CS)", - "where": "chart.level = '2'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14775,9 +16302,10 @@ "inactive": true, "legacyFolderID": "F42b5e4a2ab0ca2e360cd9c52521e60e0ed9654186cda2758d3c33b5019cd4f2c", "searchTerms": [], + "slug": "2-heroicverse-2dxtra", "title": "Level 2 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '2'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14785,9 +16313,10 @@ "inactive": true, "legacyFolderID": "F7e7415394af686324194d79d373d8abb24797fda82ae6a36ce1c410a0b8680ab", "searchTerms": [], + "slug": "2-heroicverse-omni", "title": "Level 2 (HEROIC VERSE Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14795,9 +16324,10 @@ "inactive": true, "legacyFolderID": "F0355c67b5677103ef932bee1fbe06c78f468d5ae3c6f474f5caa9a1e769ba277", "searchTerms": [], + "slug": "2-heroicverse", "title": "Level 2 (HEROIC VERSE)", - "where": "chart.level = '2'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14805,9 +16335,10 @@ "inactive": true, "legacyFolderID": "Fa5bcc37a1feaf3f48bc5dbbfba94598f4ee579b8745b135210933f11a0c087ff", "searchTerms": [], + "slug": "2-11th-cs", "title": "Level 2 (IIDX RED CS)", - "where": "chart.level = '2'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14815,9 +16346,10 @@ "inactive": false, "legacyFolderID": "F93c3cbf19b67abcdf6cb9f30076aaada549f28fbaf5373d20962e9ea716df192", "searchTerms": [], + "slug": "2-inf", "title": "Level 2 (INFINITAS)", - "where": "chart.level = '2'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14825,9 +16357,10 @@ "inactive": true, "legacyFolderID": "F70a918a5684f7a171cbee68c9e57d679db92f8c89b8b67b1f72c6040debf02d7", "searchTerms": [], + "slug": "2-pendual", "title": "Level 2 (PENDUAL)", - "where": "chart.level = '2'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14835,9 +16368,10 @@ "inactive": false, "legacyFolderID": "F9d200c707b5677f86159833694f6300ea1bb47cbeacd86362cd4e5d6500f0ec6", "searchTerms": [], + "slug": "2-pinkycrush-omni", "title": "Level 2 (Pinky Crush Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14845,9 +16379,10 @@ "inactive": false, "legacyFolderID": "F166cfacd26723310870e4efc93341af713135145284b054b9020270245e8c5cb", "searchTerms": [], + "slug": "2-pinkycrush", "title": "Level 2 (Pinky Crush)", - "where": "chart.level = '2'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14855,9 +16390,10 @@ "inactive": true, "legacyFolderID": "F5143df7a36805fd4aacf65be0645bdceab4ce3c01177ad14268e3d6bce362801", "searchTerms": [], + "slug": "2-resident-omni", "title": "Level 2 (Resident Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14865,9 +16401,10 @@ "inactive": true, "legacyFolderID": "F9b579da9bf99227878c473acb9182cc28a35402388b039b41264f55ea7e608c1", "searchTerms": [], + "slug": "2-resident", "title": "Level 2 (Resident)", - "where": "chart.level = '2'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14875,9 +16412,10 @@ "inactive": true, "legacyFolderID": "F27fb9d0e1dc57c08837b3a776b72050feb5fb5de2fe0a26acc156f0eda3380e3", "searchTerms": [], + "slug": "2-rootage-omni", "title": "Level 2 (ROOTAGE Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14885,9 +16423,10 @@ "inactive": true, "legacyFolderID": "F06fb212d370cf400183254affcdfa9fc0db28d625d52460eb3e58fb8dabe1b4f", "searchTerms": [], + "slug": "2-rootage", "title": "Level 2 (ROOTAGE)", - "where": "chart.level = '2'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14895,9 +16434,10 @@ "inactive": true, "legacyFolderID": "Fdcd6abae0b57c30320eaa172ccc5240886d3aa717bf40cd7c8dab42fab17d53d", "searchTerms": [], + "slug": "2-sinobuz", "title": "Level 2 (SINOBUZ)", - "where": "chart.level = '2'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14905,9 +16445,10 @@ "inactive": true, "legacyFolderID": "F96e1a01f2cab7afcace0983e4dd64e067ccfa9e00e6b7c207d289ea9071ae7bd", "searchTerms": [], + "slug": "2-spada", "title": "Level 2 (SPADA)", - "where": "chart.level = '2'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14915,9 +16456,10 @@ "inactive": false, "legacyFolderID": "F373b83d9e576304cf3725509dea513fc3ee1c033b63fff33c2a7e320b2688bbe", "searchTerms": [], + "slug": "2-sparkleshower", "title": "Level 2 (Sparkle Shower)", - "where": "chart.level = '2'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14925,9 +16467,10 @@ "inactive": true, "legacyFolderID": "F60b82c8df8c4dc907a604cc14990effca46885602c8c31096b21a7b574ec2add", "searchTerms": [], + "slug": "2-tricoro", "title": "Level 2 (tricoro)", - "where": "chart.level = '2'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '2'" }, { "game": "iidx-dp", @@ -14935,9 +16478,10 @@ "inactive": true, "legacyFolderID": "Ff502f4dba0e44e50a23bf081ad82813ab6192de1fadf6317e2b194e98839e851", "searchTerms": [], + "slug": "3-10th-cs", "title": "Level 3 (10th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -14945,9 +16489,10 @@ "inactive": true, "legacyFolderID": "F5926dc6b4c7cc0a77a6db64b7a03c9752333b8fb9543aa28f0f99cd1e9627383", "searchTerms": [], + "slug": "3-3rd-cs", "title": "Level 3 (3rd Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -14955,9 +16500,10 @@ "inactive": true, "legacyFolderID": "F942ddea5d7c0dceb2bae7d08870a1b6f3a799a400f9e8bbb65c412f719d74684", "searchTerms": [], + "slug": "3-4th-cs", "title": "Level 3 (4th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -14965,9 +16511,10 @@ "inactive": true, "legacyFolderID": "F5b9abfd32132669ac4a99cb250478ad38db5a51c062e81a73cd217d653aab726", "searchTerms": [], + "slug": "3-5th-cs", "title": "Level 3 (5th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -14975,9 +16522,10 @@ "inactive": true, "legacyFolderID": "Fcf7e32b1e0d2f1328ddc32c771ca52a252730227e9c155549fac50945a957e04", "searchTerms": [], + "slug": "3-6th-cs", "title": "Level 3 (6th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -14985,9 +16533,10 @@ "inactive": true, "legacyFolderID": "F61c83f61aae3685dd87b66937b178baa0b5c8f1b6004d2a600aa2083b03eccfd", "searchTerms": [], + "slug": "3-7th-cs", "title": "Level 3 (7th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -14995,9 +16544,10 @@ "inactive": true, "legacyFolderID": "F814a8ec4db5e23e726b6c7ef1d7d287a2c589145f978d11c39d47e325834c9fb", "searchTerms": [], + "slug": "3-8th-cs", "title": "Level 3 (8th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15005,9 +16555,10 @@ "inactive": true, "legacyFolderID": "F96b2cb3eae226d9d6292be372bbf707892ab08cb16123e1b93ec15121d689912", "searchTerms": [], + "slug": "3-9th-cs", "title": "Level 3 (9th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15015,9 +16566,10 @@ "inactive": true, "legacyFolderID": "F36c3ddbb13fff9a811885134738fca96f2d2ecb760f54fd786d6405317ed0d19", "searchTerms": [], + "slug": "3-bmus", "title": "Level 3 (BEATMANIA US)", - "where": "chart.level = '3'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15025,9 +16577,10 @@ "inactive": true, "legacyFolderID": "Fee9097e10f16eb86292fbae4be12e33b6b112f067eda43f269193557b02bf894", "searchTerms": [], + "slug": "3-bistrover-omni", "title": "Level 3 (BISTROVER Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15035,9 +16588,10 @@ "inactive": true, "legacyFolderID": "F21a009e68f8f899d466956c6103ee6dd477b7bab2d9a62f461f3c5971af25181", "searchTerms": [], + "slug": "3-bistrover", "title": "Level 3 (BISTROVER)", - "where": "chart.level = '3'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15045,9 +16599,10 @@ "inactive": true, "legacyFolderID": "F8fe576e77eb7f9d0bfeb43074908f84b8feb257fcc858c7ba64e5d38f56b1e9e", "searchTerms": [], + "slug": "3-cannonballers", "title": "Level 3 (CANNON BALLERS)", - "where": "chart.level = '3'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15055,9 +16610,10 @@ "inactive": true, "legacyFolderID": "F1ebe7667613e1d8215143d7d5764a97ed4931b2d546559bf6d081d7eff6380de", "searchTerms": [], + "slug": "3-casthour-omni", "title": "Level 3 (CastHour Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15065,9 +16621,10 @@ "inactive": true, "legacyFolderID": "F5b0f1818352902384603671635c56cfe9a34b3be2a507ab954efd1c314a174fe", "searchTerms": [], + "slug": "3-casthour", "title": "Level 3 (CastHour)", - "where": "chart.level = '3'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15075,9 +16632,10 @@ "inactive": true, "legacyFolderID": "F7b5d4185ae5b25f368c211b3bf22ecd358139dfe3ff52a498b9473f45935f478", "searchTerms": [], + "slug": "3-copula", "title": "Level 3 (copula)", - "where": "chart.level = '3'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15085,9 +16643,10 @@ "inactive": true, "legacyFolderID": "Ffb06658c3afc752c0a9d731966d0105e99f086225f60a8972150dd96cd8ca772", "searchTerms": [], + "slug": "3-13th-cs", "title": "Level 3 (DISTORTED CS)", - "where": "chart.level = '3'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15095,9 +16654,10 @@ "inactive": true, "legacyFolderID": "F3d7491a32b113ee0385fb3162da4deff953890837d7237f26c9a1c8116708071", "searchTerms": [], + "slug": "3-15th-cs", "title": "Level 3 (DJ TROOPERS CS)", - "where": "chart.level = '3'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15105,9 +16665,10 @@ "inactive": true, "legacyFolderID": "F661afe6f3bde31bf3581bd48ed5033af019f36e7b534c9a25bb317ff6a376338", "searchTerms": [], + "slug": "3-16th-cs", "title": "Level 3 (EMPRESS CS)", - "where": "chart.level = '3'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15115,9 +16676,10 @@ "inactive": true, "legacyFolderID": "F4c55a47ec903a3c1d7d0177b1ce3333d9985041219a655c54106da37a2267646", "searchTerms": [], + "slug": "3-epolis-omni", "title": "Level 3 (Epolis Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15125,9 +16687,10 @@ "inactive": true, "legacyFolderID": "Fc80b3b1dfa2b007e4f23cd2c60f28a8d45485f95ba879eda08d858de574321bf", "searchTerms": [], + "slug": "3-epolis", "title": "Level 3 (EPOLIS)", - "where": "chart.level = '3'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15135,9 +16698,10 @@ "inactive": true, "legacyFolderID": "F676ec09aeb40fd7a9f66a945ad7c7b3135916f7a455278443858ac67afcaea2a", "searchTerms": [], + "slug": "3-14th-cs", "title": "Level 3 (GOLD CS)", - "where": "chart.level = '3'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15145,9 +16709,10 @@ "inactive": true, "legacyFolderID": "F284f7ac5455d47858bf43d94e957281dfbcd9345ea1300dc61820d396ab0672e", "searchTerms": [], + "slug": "3-12th-cs", "title": "Level 3 (HAPPY SKY CS)", - "where": "chart.level = '3'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15155,9 +16720,10 @@ "inactive": true, "legacyFolderID": "Ffd068bdf2ef0dec891aa15f173af89a13a8230f6d506056db4d6fd7f96f6fc6a", "searchTerms": [], + "slug": "3-heroicverse-2dxtra", "title": "Level 3 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '3'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15165,9 +16731,10 @@ "inactive": true, "legacyFolderID": "F96ba8425d69b2de816385f64587f9ca888480b161f7ae98ab8d8227ec40e132a", "searchTerms": [], + "slug": "3-heroicverse-omni", "title": "Level 3 (HEROIC VERSE Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15175,9 +16742,10 @@ "inactive": true, "legacyFolderID": "Fc126e7b04245f4c87544d639796ea45d77f4c673c6c4e4c62fc4bbe72b1b1bab", "searchTerms": [], + "slug": "3-heroicverse", "title": "Level 3 (HEROIC VERSE)", - "where": "chart.level = '3'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15185,9 +16753,10 @@ "inactive": true, "legacyFolderID": "Fdc346438c18d9f3405bb615382a49944e73cd0df94aaaa42b02a306fe309217c", "searchTerms": [], + "slug": "3-11th-cs", "title": "Level 3 (IIDX RED CS)", - "where": "chart.level = '3'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15195,9 +16764,10 @@ "inactive": false, "legacyFolderID": "Fc1447ed7e887abe13aef2a21d7a960252e269776837d2f8a0da24bd8e47900d4", "searchTerms": [], + "slug": "3-inf", "title": "Level 3 (INFINITAS)", - "where": "chart.level = '3'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15205,9 +16775,10 @@ "inactive": true, "legacyFolderID": "F40be86fb6b35ddba39718855a178ee03eebace23ba8c541361c7a66273c9b785", "searchTerms": [], + "slug": "3-pendual", "title": "Level 3 (PENDUAL)", - "where": "chart.level = '3'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15215,9 +16786,10 @@ "inactive": false, "legacyFolderID": "F49346c3823a8f4d15d2898fcc8585771c77f8404d5386637b3993929cf2e3243", "searchTerms": [], + "slug": "3-pinkycrush-omni", "title": "Level 3 (Pinky Crush Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15225,9 +16797,10 @@ "inactive": false, "legacyFolderID": "Fc1dfd93347e8b69cbf964684e89cf8aebd18413841e43a64d53f9004d0a349e4", "searchTerms": [], + "slug": "3-pinkycrush", "title": "Level 3 (Pinky Crush)", - "where": "chart.level = '3'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15235,9 +16808,10 @@ "inactive": true, "legacyFolderID": "F158445e29f4436e616866b7f126130e290a4463c0e12ff6f3e79bc41f1bb1ea2", "searchTerms": [], + "slug": "3-resident-omni", "title": "Level 3 (Resident Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15245,9 +16819,10 @@ "inactive": true, "legacyFolderID": "F8d88c9042dc5a3916febedb5319705559a13a6e51983b44177449ff2e3592166", "searchTerms": [], + "slug": "3-resident", "title": "Level 3 (Resident)", - "where": "chart.level = '3'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15255,9 +16830,10 @@ "inactive": true, "legacyFolderID": "F403d2048d992db7b93f3658b357b10b57f1690f2725577b972b6707ed5493d57", "searchTerms": [], + "slug": "3-rootage-omni", "title": "Level 3 (ROOTAGE Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15265,9 +16841,10 @@ "inactive": true, "legacyFolderID": "F890e447560978e4dee246388fb250d4afce44b8e9817bf1c5937533fbc9e925c", "searchTerms": [], + "slug": "3-rootage", "title": "Level 3 (ROOTAGE)", - "where": "chart.level = '3'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15275,9 +16852,10 @@ "inactive": true, "legacyFolderID": "F39bca6362c00334b42851fbaf80b46e2673eab05f47618c9d64a1f813489a182", "searchTerms": [], + "slug": "3-sinobuz", "title": "Level 3 (SINOBUZ)", - "where": "chart.level = '3'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15285,9 +16863,10 @@ "inactive": true, "legacyFolderID": "F134df45e30e3a2f970600c300f2b0c5a1ebfcceab61441733e2dc03dde0d3ddd", "searchTerms": [], + "slug": "3-spada", "title": "Level 3 (SPADA)", - "where": "chart.level = '3'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15295,9 +16874,10 @@ "inactive": false, "legacyFolderID": "F39151b7bee37a7188449f3a13f8a0542ffa9330341cdb1f43480df5de3d34a12", "searchTerms": [], + "slug": "3-sparkleshower", "title": "Level 3 (Sparkle Shower)", - "where": "chart.level = '3'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15305,9 +16885,10 @@ "inactive": true, "legacyFolderID": "Ff5e0bae05657e030f82ce569acb077303090d8b05296298765dfdb7f50af507f", "searchTerms": [], + "slug": "3-tricoro", "title": "Level 3 (tricoro)", - "where": "chart.level = '3'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '3'" }, { "game": "iidx-dp", @@ -15315,9 +16896,10 @@ "inactive": true, "legacyFolderID": "Fbfdd50fda9b8010169cb6520b68480ebef98ef173739abeb841e7d54a8f767e1", "searchTerms": [], + "slug": "4-10th-cs", "title": "Level 4 (10th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15325,9 +16907,10 @@ "inactive": true, "legacyFolderID": "F4fc4748831b8228c270d439ebaa8f67f9ae8556bbef640eeb11e922e2ccca9ed", "searchTerms": [], + "slug": "4-3rd-cs", "title": "Level 4 (3rd Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15335,9 +16918,10 @@ "inactive": true, "legacyFolderID": "Faa05b5b8c38dc84752ed4c30fc030456a8d14caab2387d11dc995894a06ac594", "searchTerms": [], + "slug": "4-4th-cs", "title": "Level 4 (4th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15345,9 +16929,10 @@ "inactive": true, "legacyFolderID": "F107ab11acff1fd6e7a9860313759142af0c8dc52fc34bfe32f1e4913a62a6db6", "searchTerms": [], + "slug": "4-5th-cs", "title": "Level 4 (5th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15355,9 +16940,10 @@ "inactive": true, "legacyFolderID": "F6694bcec609dfed47e7dba25cb218157789956a40e7c05506049352ca223fc47", "searchTerms": [], + "slug": "4-6th-cs", "title": "Level 4 (6th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15365,9 +16951,10 @@ "inactive": true, "legacyFolderID": "F87eab117ee6f7f1aec853f252d6e8b13fba6416fa7bc974877c869d04de19326", "searchTerms": [], + "slug": "4-7th-cs", "title": "Level 4 (7th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15375,9 +16962,10 @@ "inactive": true, "legacyFolderID": "Ffbacf392d37cba51203f2326e44d24ba6465dfb4254343f872329f866c411955", "searchTerms": [], + "slug": "4-8th-cs", "title": "Level 4 (8th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15385,9 +16973,10 @@ "inactive": true, "legacyFolderID": "Fc543dd3941712b78660373d0a0876fdefc04d22ac58a5669450b768f6c5fb4d0", "searchTerms": [], + "slug": "4-9th-cs", "title": "Level 4 (9th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15395,9 +16984,10 @@ "inactive": true, "legacyFolderID": "Fa7ea332edd61127802b709926f22e917fecb99e89c641e32528e1f4c20233552", "searchTerms": [], + "slug": "4-bmus", "title": "Level 4 (BEATMANIA US)", - "where": "chart.level = '4'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15405,9 +16995,10 @@ "inactive": true, "legacyFolderID": "F248ffab95b0f7b54de48ef81fb0695644a065b4c850cce43746566a914f2d66c", "searchTerms": [], + "slug": "4-bistrover-omni", "title": "Level 4 (BISTROVER Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15415,9 +17006,10 @@ "inactive": true, "legacyFolderID": "F59965c35203d164ef3924a8d49cb7880894be63a26d535481b8003ad72e205c2", "searchTerms": [], + "slug": "4-bistrover", "title": "Level 4 (BISTROVER)", - "where": "chart.level = '4'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15425,9 +17017,10 @@ "inactive": true, "legacyFolderID": "F83bffe1f90ef726b224f31c2ee776b5135767b8b7e91f22d5ed9410d7998bba3", "searchTerms": [], + "slug": "4-cannonballers", "title": "Level 4 (CANNON BALLERS)", - "where": "chart.level = '4'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15435,9 +17028,10 @@ "inactive": true, "legacyFolderID": "Fad966633580b7a37d5f7ef0a7f536495a091a8a875a174f8c959ab092cb8fb73", "searchTerms": [], + "slug": "4-casthour-omni", "title": "Level 4 (CastHour Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15445,9 +17039,10 @@ "inactive": true, "legacyFolderID": "F4bdb9165ba1588c09bb34f773d93abb205bcce276faf6e72fe22657e177b420a", "searchTerms": [], + "slug": "4-casthour", "title": "Level 4 (CastHour)", - "where": "chart.level = '4'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15455,9 +17050,10 @@ "inactive": true, "legacyFolderID": "F8454c6f95324f450bae4c19f252e5a84973f1ceac5603762f147a4d061844f0a", "searchTerms": [], + "slug": "4-copula", "title": "Level 4 (copula)", - "where": "chart.level = '4'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15465,9 +17061,10 @@ "inactive": true, "legacyFolderID": "F1f15a6dd79183abbf7808b1e058e870032d705603cf394c28dd3e648fdd154a6", "searchTerms": [], + "slug": "4-13th-cs", "title": "Level 4 (DISTORTED CS)", - "where": "chart.level = '4'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15475,9 +17072,10 @@ "inactive": true, "legacyFolderID": "Facad561fe7a18e10911fe60c642699dd1ebde479db94fe0af90a1218997f1765", "searchTerms": [], + "slug": "4-15th-cs", "title": "Level 4 (DJ TROOPERS CS)", - "where": "chart.level = '4'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15485,9 +17083,10 @@ "inactive": true, "legacyFolderID": "F9678f74dad29a7ed7529c9605499428d06ff4ce89f3fab7bbea804a953eb47e2", "searchTerms": [], + "slug": "4-16th-cs", "title": "Level 4 (EMPRESS CS)", - "where": "chart.level = '4'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15495,9 +17094,10 @@ "inactive": true, "legacyFolderID": "F866d1516892a7b8df968e0323133c266a1ca9dc1224117c00f4af78081ba76fd", "searchTerms": [], + "slug": "4-epolis-omni", "title": "Level 4 (Epolis Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15505,9 +17105,10 @@ "inactive": true, "legacyFolderID": "F35ed77db0f54982b25fef631bddfe278eaaa84b372edbfc2d4c5660804e77f11", "searchTerms": [], + "slug": "4-epolis", "title": "Level 4 (EPOLIS)", - "where": "chart.level = '4'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15515,9 +17116,10 @@ "inactive": true, "legacyFolderID": "F0dfcc2374852d80e32e784e802ecc61b8f28b2e1dae481e240defab9a0128525", "searchTerms": [], + "slug": "4-14th-cs", "title": "Level 4 (GOLD CS)", - "where": "chart.level = '4'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15525,9 +17127,10 @@ "inactive": true, "legacyFolderID": "Fd99382309bab175beaa280fb66b9f1e9fad10177c85b5f9500bb5425b679ca79", "searchTerms": [], + "slug": "4-12th-cs", "title": "Level 4 (HAPPY SKY CS)", - "where": "chart.level = '4'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15535,9 +17138,10 @@ "inactive": true, "legacyFolderID": "F4cd43fc16593d320d894b3cd3612e1547d337b1660516e1ae5a639e6a044b922", "searchTerms": [], + "slug": "4-heroicverse-2dxtra", "title": "Level 4 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '4'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15545,9 +17149,10 @@ "inactive": true, "legacyFolderID": "F38a32e468111425f976878cb01c834d9a1ae9f5abafa46bf5de613ed5e14dfb1", "searchTerms": [], + "slug": "4-heroicverse-omni", "title": "Level 4 (HEROIC VERSE Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15555,9 +17160,10 @@ "inactive": true, "legacyFolderID": "Fb3f840053c795dfb0eb056f505c2dfa459b40523501192e7e2c606bf3720d754", "searchTerms": [], + "slug": "4-heroicverse", "title": "Level 4 (HEROIC VERSE)", - "where": "chart.level = '4'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15565,9 +17171,10 @@ "inactive": true, "legacyFolderID": "F1ffb36b069231a0218f1c3033a0c5ba6f9ca077d225f3bd99b71548ae45ebfbe", "searchTerms": [], + "slug": "4-11th-cs", "title": "Level 4 (IIDX RED CS)", - "where": "chart.level = '4'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15575,9 +17182,10 @@ "inactive": false, "legacyFolderID": "Faba996be7beb550b0741937de98e6ba85097a9214bca9b71340aac298e76e197", "searchTerms": [], + "slug": "4-inf", "title": "Level 4 (INFINITAS)", - "where": "chart.level = '4'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15585,9 +17193,10 @@ "inactive": true, "legacyFolderID": "F0fe9af0bf08a05da54db92d6dd27b4f5ede78888cf75d4e1e10ce0923e1d38a5", "searchTerms": [], + "slug": "4-pendual", "title": "Level 4 (PENDUAL)", - "where": "chart.level = '4'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15595,9 +17204,10 @@ "inactive": false, "legacyFolderID": "F2b0dd9ae8b4d96baa6a91496c910a31f84cae215dea2129e7ef6827da7482f06", "searchTerms": [], + "slug": "4-pinkycrush-omni", "title": "Level 4 (Pinky Crush Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15605,9 +17215,10 @@ "inactive": false, "legacyFolderID": "Fc3dde78cc02ca22ffcf1e20ea6673f7d55f5a1595a61fc96028097ec32fa9d57", "searchTerms": [], + "slug": "4-pinkycrush", "title": "Level 4 (Pinky Crush)", - "where": "chart.level = '4'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15615,9 +17226,10 @@ "inactive": true, "legacyFolderID": "F48c35137077a2f7c712e6c9877bbdc672d0ff02918991632dacdf02d4d179548", "searchTerms": [], + "slug": "4-resident-omni", "title": "Level 4 (Resident Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15625,9 +17237,10 @@ "inactive": true, "legacyFolderID": "Ff4efa0aeb48ee072de2bb896b3f6e9afe374ff3a0d5f954390b9b446fb69a71f", "searchTerms": [], + "slug": "4-resident", "title": "Level 4 (Resident)", - "where": "chart.level = '4'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15635,9 +17248,10 @@ "inactive": true, "legacyFolderID": "F4e718a25408ced009ff041dc8f8cd64b499e7e33b5ecb3d704da27caa8b209c4", "searchTerms": [], + "slug": "4-rootage-omni", "title": "Level 4 (ROOTAGE Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15645,9 +17259,10 @@ "inactive": true, "legacyFolderID": "F0872393de630d23d9ccee8fb1d7afddcf981370e7ef6203a7fdb2a9dd2affe5a", "searchTerms": [], + "slug": "4-rootage", "title": "Level 4 (ROOTAGE)", - "where": "chart.level = '4'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15655,9 +17270,10 @@ "inactive": true, "legacyFolderID": "Fcdb53f16b0ba38aab12dc4d8b80f42001d2bd4a3a52d25400d105ecb6e02af42", "searchTerms": [], + "slug": "4-sinobuz", "title": "Level 4 (SINOBUZ)", - "where": "chart.level = '4'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15665,9 +17281,10 @@ "inactive": true, "legacyFolderID": "F063f4bed59b1e3645dbe6951e0ad59107876e3048a4d3e9ea7758147be1c8e7f", "searchTerms": [], + "slug": "4-spada", "title": "Level 4 (SPADA)", - "where": "chart.level = '4'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15675,9 +17292,10 @@ "inactive": false, "legacyFolderID": "F6430adbf9da78a57eef2f5b65629d8f1effe400eae4a9e84ee33fd55b9075df1", "searchTerms": [], + "slug": "4-sparkleshower", "title": "Level 4 (Sparkle Shower)", - "where": "chart.level = '4'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15685,9 +17303,10 @@ "inactive": true, "legacyFolderID": "Fab83e0b99dbb488266cd4fd6a7921f0916e0b60b50fec8675c0b98d88b32f3d9", "searchTerms": [], + "slug": "4-tricoro", "title": "Level 4 (tricoro)", - "where": "chart.level = '4'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '4'" }, { "game": "iidx-dp", @@ -15695,9 +17314,10 @@ "inactive": true, "legacyFolderID": "F1eb176f26457b1cfe76122d78efba763d8bf27ee2041c3a38b26cac239bfb201", "searchTerms": [], + "slug": "5-10th-cs", "title": "Level 5 (10th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15705,9 +17325,10 @@ "inactive": true, "legacyFolderID": "F620f4dddf9b8350157609ac9cc53a5ba3d01a2013b97f9826e7a7027ff8faa69", "searchTerms": [], + "slug": "5-3rd-cs", "title": "Level 5 (3rd Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15715,9 +17336,10 @@ "inactive": true, "legacyFolderID": "Fdffdea9de0891bfe92cf83fdcaa742e80c6f2c68c27c995848a01b1bf96ffc66", "searchTerms": [], + "slug": "5-4th-cs", "title": "Level 5 (4th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15725,9 +17347,10 @@ "inactive": true, "legacyFolderID": "F707721d9de7a3535d0e6c968d9e63c9fc137e04cc773a30f7dd9bf10e4b4bf6c", "searchTerms": [], + "slug": "5-5th-cs", "title": "Level 5 (5th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15735,9 +17358,10 @@ "inactive": true, "legacyFolderID": "F39c18cc8eb6f10557fd4e54e9f14707e1818b39e72993d66674caf9eeacd5249", "searchTerms": [], + "slug": "5-6th-cs", "title": "Level 5 (6th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15745,9 +17369,10 @@ "inactive": true, "legacyFolderID": "F5c7a54d570cfde9c5acc9b09bd6c11d25daf51baa7cc6fe4687e637e350d530b", "searchTerms": [], + "slug": "5-7th-cs", "title": "Level 5 (7th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15755,9 +17380,10 @@ "inactive": true, "legacyFolderID": "F5ab112d46b1d2fcb589475cd49c82133b1ad796448b2badd5ca92b941369e02d", "searchTerms": [], + "slug": "5-8th-cs", "title": "Level 5 (8th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15765,9 +17391,10 @@ "inactive": true, "legacyFolderID": "F97cc950231faa74620342c907cfb5e54e40f969296cfab86f84becd1167cd95c", "searchTerms": [], + "slug": "5-9th-cs", "title": "Level 5 (9th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15775,9 +17402,10 @@ "inactive": true, "legacyFolderID": "F321f59667afe274440113a11f3ab9a23cf28fba62af957ee7f85f380c3b01b17", "searchTerms": [], + "slug": "5-bmus", "title": "Level 5 (BEATMANIA US)", - "where": "chart.level = '5'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15785,9 +17413,10 @@ "inactive": true, "legacyFolderID": "F50eb1350ace30a0f5dd3d0ff7b9fdb021ec77e65292687b413ee238018cd3d57", "searchTerms": [], + "slug": "5-bistrover-omni", "title": "Level 5 (BISTROVER Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15795,9 +17424,10 @@ "inactive": true, "legacyFolderID": "F9ec8b058c71b1e2b5bed1855df2918d8522f90e2997bfeeeb018fbd71a38dda8", "searchTerms": [], + "slug": "5-bistrover", "title": "Level 5 (BISTROVER)", - "where": "chart.level = '5'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15805,9 +17435,10 @@ "inactive": true, "legacyFolderID": "F5882f03bc0b83d2d53656967c19f3925fa20e8fb0ab4df82e10495959a6b5c00", "searchTerms": [], + "slug": "5-cannonballers", "title": "Level 5 (CANNON BALLERS)", - "where": "chart.level = '5'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15815,9 +17446,10 @@ "inactive": true, "legacyFolderID": "F400947833d51c60846afd3f8b80c82e54112a1a0efd836d71d52fc74bb5d7af4", "searchTerms": [], + "slug": "5-casthour-omni", "title": "Level 5 (CastHour Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15825,9 +17457,10 @@ "inactive": true, "legacyFolderID": "F3da580f81fb7f401375ff8f652289c61541452df1659e4277d462c9fffca0fef", "searchTerms": [], + "slug": "5-casthour", "title": "Level 5 (CastHour)", - "where": "chart.level = '5'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15835,9 +17468,10 @@ "inactive": true, "legacyFolderID": "F96d566ed7565118e4bd0e1e2c58c4d1bf1a6ce624e99e7dfcb48df05e0b2f40b", "searchTerms": [], + "slug": "5-copula", "title": "Level 5 (copula)", - "where": "chart.level = '5'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15845,9 +17479,10 @@ "inactive": true, "legacyFolderID": "F46e6aac37a691006342a43c3d751f0da98e82c9ddee43c2085701d54d9efbad6", "searchTerms": [], + "slug": "5-13th-cs", "title": "Level 5 (DISTORTED CS)", - "where": "chart.level = '5'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15855,9 +17490,10 @@ "inactive": true, "legacyFolderID": "F566eee8ce920099802c966271b7e816477e07f9bdcfcae27018364c1a98fcfbb", "searchTerms": [], + "slug": "5-15th-cs", "title": "Level 5 (DJ TROOPERS CS)", - "where": "chart.level = '5'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15865,9 +17501,10 @@ "inactive": true, "legacyFolderID": "Fcb8d4d4f3b096383b53b9f088105036bcc4092e0ed9e623e60c549d8faf32947", "searchTerms": [], + "slug": "5-16th-cs", "title": "Level 5 (EMPRESS CS)", - "where": "chart.level = '5'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15875,9 +17512,10 @@ "inactive": true, "legacyFolderID": "F2e59dcd38a44d2db14ef3d0961bd3a74c0efd93b60ce55af4082d55e70bd19b2", "searchTerms": [], + "slug": "5-epolis-omni", "title": "Level 5 (Epolis Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15885,9 +17523,10 @@ "inactive": true, "legacyFolderID": "Fba8ebce8e849a290a5e50457761661a6def785120125ec77a821bc9d516f7e76", "searchTerms": [], + "slug": "5-epolis", "title": "Level 5 (EPOLIS)", - "where": "chart.level = '5'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15895,9 +17534,10 @@ "inactive": true, "legacyFolderID": "Fa4096a645ab8e7091a0e396b9f6155ecf9c699239e8e7ae995543852a79263c0", "searchTerms": [], + "slug": "5-14th-cs", "title": "Level 5 (GOLD CS)", - "where": "chart.level = '5'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15905,9 +17545,10 @@ "inactive": true, "legacyFolderID": "F0499db32fd202dad8ff7be64d5ed77d5a74d0698b46e7df9447fb0e0012fe07c", "searchTerms": [], + "slug": "5-12th-cs", "title": "Level 5 (HAPPY SKY CS)", - "where": "chart.level = '5'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15915,9 +17556,10 @@ "inactive": true, "legacyFolderID": "Faf697b7ac03a8da4c3c8e74f62dfe20644b552bb58d782d434e01f65c7f87e94", "searchTerms": [], + "slug": "5-heroicverse-2dxtra", "title": "Level 5 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '5'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15925,9 +17567,10 @@ "inactive": true, "legacyFolderID": "F1ca863011a7be6c4901e24118211bc021d4d3724c95c3b608a9fb8a5d75bca3e", "searchTerms": [], + "slug": "5-heroicverse-omni", "title": "Level 5 (HEROIC VERSE Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15935,9 +17578,10 @@ "inactive": true, "legacyFolderID": "Fed79ce8da9d833415e88377a436a7d06816e8a3c0e051b11a8c46ff2d39e94c7", "searchTerms": [], + "slug": "5-heroicverse", "title": "Level 5 (HEROIC VERSE)", - "where": "chart.level = '5'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15945,9 +17589,10 @@ "inactive": true, "legacyFolderID": "F37af429386c30eb8d8c40d4bf375c8975182984ffa4bbbd275b457b64721f856", "searchTerms": [], + "slug": "5-11th-cs", "title": "Level 5 (IIDX RED CS)", - "where": "chart.level = '5'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15955,9 +17600,10 @@ "inactive": false, "legacyFolderID": "Fa926a63ec19f0ea16cbd236d213fdd40ca63c3c41e993d6b58a32606f950afd6", "searchTerms": [], + "slug": "5-inf", "title": "Level 5 (INFINITAS)", - "where": "chart.level = '5'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15965,9 +17611,10 @@ "inactive": true, "legacyFolderID": "Fbfb7faae9b6fd9fe3666ebd1377d98f33db982697d3809077ea4c4b988452c88", "searchTerms": [], + "slug": "5-pendual", "title": "Level 5 (PENDUAL)", - "where": "chart.level = '5'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15975,9 +17622,10 @@ "inactive": false, "legacyFolderID": "Fb97b3afab645b55257ca632244e8b8648d0be248abfbd12ec96dcc84b400e413", "searchTerms": [], + "slug": "5-pinkycrush-omni", "title": "Level 5 (Pinky Crush Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15985,9 +17633,10 @@ "inactive": false, "legacyFolderID": "Fb98a7db6546c42f3c015a5ba2bc846b5363dccaf1c9ef2804de81a16290d5c03", "searchTerms": [], + "slug": "5-pinkycrush", "title": "Level 5 (Pinky Crush)", - "where": "chart.level = '5'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -15995,9 +17644,10 @@ "inactive": true, "legacyFolderID": "F621bb8be1f078964d89badb33a17bf43725423beda57f2b842a914b71949caeb", "searchTerms": [], + "slug": "5-resident-omni", "title": "Level 5 (Resident Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -16005,9 +17655,10 @@ "inactive": true, "legacyFolderID": "Fa0fc607ab004e3db7171be042b8924d82ed53833c1f1d335ebb17e20cbf996f1", "searchTerms": [], + "slug": "5-resident", "title": "Level 5 (Resident)", - "where": "chart.level = '5'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -16015,9 +17666,10 @@ "inactive": true, "legacyFolderID": "F198de8af7ec35e846d1b69a8fd02d9579fa6ce40f3464a876c27f6d6a5d3d18d", "searchTerms": [], + "slug": "5-rootage-omni", "title": "Level 5 (ROOTAGE Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -16025,9 +17677,10 @@ "inactive": true, "legacyFolderID": "Fe3e70975d13aade7fe5c21d3e67a72fa7dd1568aa9f5b7a0f223d8eb7821d825", "searchTerms": [], + "slug": "5-rootage", "title": "Level 5 (ROOTAGE)", - "where": "chart.level = '5'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -16035,9 +17688,10 @@ "inactive": true, "legacyFolderID": "F67a2ff59592c9565ba5365c974ad32f5d8d39a2747ef04064d4a91db4ba6346f", "searchTerms": [], + "slug": "5-sinobuz", "title": "Level 5 (SINOBUZ)", - "where": "chart.level = '5'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -16045,9 +17699,10 @@ "inactive": true, "legacyFolderID": "F5ad49fa4b474528d0fee92318a82b3e0d26c2f424c51ee9ae37e1b370b333b4b", "searchTerms": [], + "slug": "5-spada", "title": "Level 5 (SPADA)", - "where": "chart.level = '5'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -16055,9 +17710,10 @@ "inactive": false, "legacyFolderID": "F6a6742d0d3573b60098f212684d2a8805570a24a842fc90f89a5898cb234624e", "searchTerms": [], + "slug": "5-sparkleshower", "title": "Level 5 (Sparkle Shower)", - "where": "chart.level = '5'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -16065,9 +17721,10 @@ "inactive": true, "legacyFolderID": "F3b3f6712f4d1bd2475b7ddde4aec7a87537709ddfd8fd95c7650b8a70f05e189", "searchTerms": [], + "slug": "5-tricoro", "title": "Level 5 (tricoro)", - "where": "chart.level = '5'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '5'" }, { "game": "iidx-dp", @@ -16075,9 +17732,10 @@ "inactive": true, "legacyFolderID": "F10c0bff356a875284fe74f670a360daac5ed9200805b26d1025c6bb21cb7bf40", "searchTerms": [], + "slug": "6-10th-cs", "title": "Level 6 (10th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16085,9 +17743,10 @@ "inactive": true, "legacyFolderID": "F42a9b79366e17667ad6c182b93691d43b2970d87e1b64a240354c3cad7bc64cd", "searchTerms": [], + "slug": "6-3rd-cs", "title": "Level 6 (3rd Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16095,9 +17754,10 @@ "inactive": true, "legacyFolderID": "Feec9ffbeecd181daf23bece25e5b6106b7b833635a43ccb3be9dccaf46dbae35", "searchTerms": [], + "slug": "6-4th-cs", "title": "Level 6 (4th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16105,9 +17765,10 @@ "inactive": true, "legacyFolderID": "F077e24951dd2992abd1838a487d8820dc1f432ec3c2b843ef099a0290fb97bad", "searchTerms": [], + "slug": "6-5th-cs", "title": "Level 6 (5th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16115,9 +17776,10 @@ "inactive": true, "legacyFolderID": "F37ab284b14a35ba6597a935ec48f452dbc76f3e70948efc58a76ec4a4e2081ed", "searchTerms": [], + "slug": "6-6th-cs", "title": "Level 6 (6th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16125,9 +17787,10 @@ "inactive": true, "legacyFolderID": "Fc39d5224c0073bbd17ab23cd8ed9bfa2444ce2fc0e125b323e128f8eda697d50", "searchTerms": [], + "slug": "6-7th-cs", "title": "Level 6 (7th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16135,9 +17798,10 @@ "inactive": true, "legacyFolderID": "F270e231c590f13b670054d517d3c64ca663e2284cd939721c378b2f006237854", "searchTerms": [], + "slug": "6-8th-cs", "title": "Level 6 (8th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16145,9 +17809,10 @@ "inactive": true, "legacyFolderID": "F97987c40976f88539bb1d61592547ebdd06f5fde0ef46671c7f8b626a2e6b5cd", "searchTerms": [], + "slug": "6-9th-cs", "title": "Level 6 (9th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16155,9 +17820,10 @@ "inactive": true, "legacyFolderID": "F75c76cb4b7fee1b4438db17e217a1831e5c3249c0aea9a5a3097d30e1792ee77", "searchTerms": [], + "slug": "6-bmus", "title": "Level 6 (BEATMANIA US)", - "where": "chart.level = '6'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16165,9 +17831,10 @@ "inactive": true, "legacyFolderID": "Fe612eb2d66eb2f77e6c55356c76f7d880e01fc249d6bce71b4562ab0059cb9d5", "searchTerms": [], + "slug": "6-bistrover-omni", "title": "Level 6 (BISTROVER Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16175,9 +17842,10 @@ "inactive": true, "legacyFolderID": "Fc40fa32cc4e760096f60f268c0913e4333396737ef0861dee7651d1031fd45df", "searchTerms": [], + "slug": "6-bistrover", "title": "Level 6 (BISTROVER)", - "where": "chart.level = '6'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16185,9 +17853,10 @@ "inactive": true, "legacyFolderID": "F6166384edbf38805a42d9b7eecdc517c6e45e56ce07f8d6bae06d3de16009b1e", "searchTerms": [], + "slug": "6-cannonballers", "title": "Level 6 (CANNON BALLERS)", - "where": "chart.level = '6'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16195,9 +17864,10 @@ "inactive": true, "legacyFolderID": "F6c4da6e56c3f7c44969885edd070776ac2fae041da74fd33b5a5e06ee1525ec3", "searchTerms": [], + "slug": "6-casthour-omni", "title": "Level 6 (CastHour Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16205,9 +17875,10 @@ "inactive": true, "legacyFolderID": "Faf3f8439a2756250728da827b4144649314e01ce320785971b4372538d6b6696", "searchTerms": [], + "slug": "6-casthour", "title": "Level 6 (CastHour)", - "where": "chart.level = '6'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16215,9 +17886,10 @@ "inactive": true, "legacyFolderID": "F54d4b57b8d6d6d9cdb0d99e574a128e8b8422dae41b0374f732d295b6a2189be", "searchTerms": [], + "slug": "6-copula", "title": "Level 6 (copula)", - "where": "chart.level = '6'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16225,9 +17897,10 @@ "inactive": true, "legacyFolderID": "Fd1630d9f6b8b8f665c9ce138f5c72cde20afd98b86647c24e91421a240728d7b", "searchTerms": [], + "slug": "6-13th-cs", "title": "Level 6 (DISTORTED CS)", - "where": "chart.level = '6'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16235,9 +17908,10 @@ "inactive": true, "legacyFolderID": "Fa0ac8c0ed2886c9b405517b42239ee3f156171fdc774a004cb4a6574c3df804d", "searchTerms": [], + "slug": "6-15th-cs", "title": "Level 6 (DJ TROOPERS CS)", - "where": "chart.level = '6'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16245,9 +17919,10 @@ "inactive": true, "legacyFolderID": "F446747b43f85609af9765b69a98bce76f487ffbddaee067fdf612d42c8f239d0", "searchTerms": [], + "slug": "6-16th-cs", "title": "Level 6 (EMPRESS CS)", - "where": "chart.level = '6'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16255,9 +17930,10 @@ "inactive": true, "legacyFolderID": "Ffebfc8e96592a0bac5e9fdaf52a8f490fb1b94c480e0cfb7a6e95e03f746d7f2", "searchTerms": [], + "slug": "6-epolis-omni", "title": "Level 6 (Epolis Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16265,9 +17941,10 @@ "inactive": true, "legacyFolderID": "Fcfef278758f7e22d1670c1c9e57a9bc7de1b1b533b135eeeb541513d6502a7cc", "searchTerms": [], + "slug": "6-epolis", "title": "Level 6 (EPOLIS)", - "where": "chart.level = '6'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16275,9 +17952,10 @@ "inactive": true, "legacyFolderID": "Fb46e5e2a42df20ac3ef5ce7279d5345ec63dc5cd9f67a744c16ebf421d0f5f59", "searchTerms": [], + "slug": "6-14th-cs", "title": "Level 6 (GOLD CS)", - "where": "chart.level = '6'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16285,9 +17963,10 @@ "inactive": true, "legacyFolderID": "F6cdb65904b25e9225bbe1fe5c0a2a6cc36139d885b2e2857b1580eb7bcefa545", "searchTerms": [], + "slug": "6-12th-cs", "title": "Level 6 (HAPPY SKY CS)", - "where": "chart.level = '6'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16295,9 +17974,10 @@ "inactive": true, "legacyFolderID": "F9be102722146a00f7eb2ee61b566197754307e430ec0e2b94bcf2e5f416d725b", "searchTerms": [], + "slug": "6-heroicverse-2dxtra", "title": "Level 6 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '6'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16305,9 +17985,10 @@ "inactive": true, "legacyFolderID": "Fdcd81ac78a2d56ec7e489808241d0ebfee3b7ca120ff977f0e527e3a7ad78528", "searchTerms": [], + "slug": "6-heroicverse-omni", "title": "Level 6 (HEROIC VERSE Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16315,9 +17996,10 @@ "inactive": true, "legacyFolderID": "F114aa547591a94ca4555154d9112eab2b7e303f556350b291b0acd1d294b7ae9", "searchTerms": [], + "slug": "6-heroicverse", "title": "Level 6 (HEROIC VERSE)", - "where": "chart.level = '6'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16325,9 +18007,10 @@ "inactive": true, "legacyFolderID": "F907b1036abb1498c579e304db6a2a23fbfba54bcddcc9a2211bcb04c56d8bb77", "searchTerms": [], + "slug": "6-11th-cs", "title": "Level 6 (IIDX RED CS)", - "where": "chart.level = '6'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16335,9 +18018,10 @@ "inactive": false, "legacyFolderID": "Fd5e23098a0c179dfae14ae0b41df31eba03d872fda88cdf3070421d43706417a", "searchTerms": [], + "slug": "6-inf", "title": "Level 6 (INFINITAS)", - "where": "chart.level = '6'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16345,9 +18029,10 @@ "inactive": true, "legacyFolderID": "F0feba06e782c3281e74e35fb56a42d8b2026a0a3962e4ba6c2475501a7542c6f", "searchTerms": [], + "slug": "6-pendual", "title": "Level 6 (PENDUAL)", - "where": "chart.level = '6'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16355,9 +18040,10 @@ "inactive": false, "legacyFolderID": "Fe7b34714f0acb4fe0f7e209cb34c2c015f5899998360685cdd192d55380ce5e0", "searchTerms": [], + "slug": "6-pinkycrush-omni", "title": "Level 6 (Pinky Crush Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16365,9 +18051,10 @@ "inactive": false, "legacyFolderID": "F623a451f7495805159079174a46fca394252b49ad9e2b96fbd8c9c79b39cc32c", "searchTerms": [], + "slug": "6-pinkycrush", "title": "Level 6 (Pinky Crush)", - "where": "chart.level = '6'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16375,9 +18062,10 @@ "inactive": true, "legacyFolderID": "Fca26035746a97558a28c975e6b40c312acd53e285205feb35b6b1e3e5d52281d", "searchTerms": [], + "slug": "6-resident-omni", "title": "Level 6 (Resident Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16385,9 +18073,10 @@ "inactive": true, "legacyFolderID": "Fc631ee868c7ae1ff52179d7d6ddc14d21afde020786b9d7e338d2dc5c351a687", "searchTerms": [], + "slug": "6-resident", "title": "Level 6 (Resident)", - "where": "chart.level = '6'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16395,9 +18084,10 @@ "inactive": true, "legacyFolderID": "F0503c8327197fb699954ec4a851c2fe1cac90669c1f2354fd3100538863c84d4", "searchTerms": [], + "slug": "6-rootage-omni", "title": "Level 6 (ROOTAGE Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16405,9 +18095,10 @@ "inactive": true, "legacyFolderID": "Fc4576effd560835e4d93b8be4ccc9334419625cc58544a3ba3f8ed7f92c396d7", "searchTerms": [], + "slug": "6-rootage", "title": "Level 6 (ROOTAGE)", - "where": "chart.level = '6'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16415,9 +18106,10 @@ "inactive": true, "legacyFolderID": "Ff96e9088a85f6cb8b12f2c12d109f133a101318cfaf310d48b2e0e6155c3c389", "searchTerms": [], + "slug": "6-sinobuz", "title": "Level 6 (SINOBUZ)", - "where": "chart.level = '6'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16425,9 +18117,10 @@ "inactive": true, "legacyFolderID": "Fcb206a5f09086e447ed1e7d76ad947608302bfe7973f8edf833da17562f9d031", "searchTerms": [], + "slug": "6-spada", "title": "Level 6 (SPADA)", - "where": "chart.level = '6'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16435,9 +18128,10 @@ "inactive": false, "legacyFolderID": "F451fe6994b97642585cb3c0a50296ae9e3c20bc81d9d855be709bfc47c2c5452", "searchTerms": [], + "slug": "6-sparkleshower", "title": "Level 6 (Sparkle Shower)", - "where": "chart.level = '6'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16445,9 +18139,10 @@ "inactive": true, "legacyFolderID": "F850dcb1f5b4c6350b50ba4f97ec5eee0a5ca267b4fc8d053fba4ecf929a473d2", "searchTerms": [], + "slug": "6-tricoro", "title": "Level 6 (tricoro)", - "where": "chart.level = '6'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '6'" }, { "game": "iidx-dp", @@ -16455,9 +18150,10 @@ "inactive": true, "legacyFolderID": "Fd31d074298b601564e0ba819d3ba20a9386b20b998d19512b1ccba302a926343", "searchTerms": [], + "slug": "7-10th-cs", "title": "Level 7 (10th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16465,9 +18161,10 @@ "inactive": true, "legacyFolderID": "Fab888f6a979d0f2af2af2c7d37cb7685704753b0535173a1dfb13f635eb4b5ea", "searchTerms": [], + "slug": "7-3rd-cs", "title": "Level 7 (3rd Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16475,9 +18172,10 @@ "inactive": true, "legacyFolderID": "F0dba215a5814e8af41a8238d6fff13b98b90d1c82d99e4fb86605765e977ce6c", "searchTerms": [], + "slug": "7-4th-cs", "title": "Level 7 (4th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16485,9 +18183,10 @@ "inactive": true, "legacyFolderID": "Fec0890a301cacdd2d73bdf00ad483ae6fe8756b851b93a0ef8150335cf51ba17", "searchTerms": [], + "slug": "7-5th-cs", "title": "Level 7 (5th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16495,9 +18194,10 @@ "inactive": true, "legacyFolderID": "F07b33d62acbc9494b265ea8ef274dc020105dd1ff5051b6c73196dd7fcda752c", "searchTerms": [], + "slug": "7-6th-cs", "title": "Level 7 (6th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16505,9 +18205,10 @@ "inactive": true, "legacyFolderID": "Fca1853510e08e66edbcd09f2e7cc5d964d713c0092dafcd594806c68520da729", "searchTerms": [], + "slug": "7-7th-cs", "title": "Level 7 (7th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16515,9 +18216,10 @@ "inactive": true, "legacyFolderID": "Ff9d06410323e7adf7ca7198f8d6741ed46aef03bbd58f4d197dcacb659d8e9b0", "searchTerms": [], + "slug": "7-8th-cs", "title": "Level 7 (8th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16525,9 +18227,10 @@ "inactive": true, "legacyFolderID": "F19865e75c83e5ba5c42c7033f8bec5f7890a22b8711ac5138c5da321ce12c2ad", "searchTerms": [], + "slug": "7-9th-cs", "title": "Level 7 (9th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16535,9 +18238,10 @@ "inactive": true, "legacyFolderID": "Fb52fbac12a77d8fb7d2e8438c1bae6562f3cc2620d3799d20fb3dad77b1567e2", "searchTerms": [], + "slug": "7-bmus", "title": "Level 7 (BEATMANIA US)", - "where": "chart.level = '7'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16545,9 +18249,10 @@ "inactive": true, "legacyFolderID": "F136651ce116059a37427edb4fbf2b0de914c7dd7d55c8cbebd8c166d1cd493fd", "searchTerms": [], + "slug": "7-bistrover-omni", "title": "Level 7 (BISTROVER Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16555,9 +18260,10 @@ "inactive": true, "legacyFolderID": "Fb4e16fc4ea77a04662fc250a22f5a9fb98fe23113551bde99dbd3b4fb30d865d", "searchTerms": [], + "slug": "7-bistrover", "title": "Level 7 (BISTROVER)", - "where": "chart.level = '7'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16565,9 +18271,10 @@ "inactive": true, "legacyFolderID": "F3e52c9178a35ea4d53f4101f70e04f54286e4f322cb6da305135379b91aa61aa", "searchTerms": [], + "slug": "7-cannonballers", "title": "Level 7 (CANNON BALLERS)", - "where": "chart.level = '7'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16575,9 +18282,10 @@ "inactive": true, "legacyFolderID": "F6ff2668e9f28bf5a72cdd7ed27e5e6fc351e4c4039028348bd79bcf23c633e18", "searchTerms": [], + "slug": "7-casthour-omni", "title": "Level 7 (CastHour Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16585,9 +18293,10 @@ "inactive": true, "legacyFolderID": "F05b5d24b55e673283f67f3150d8724807bc9ce472392eebaa2ed06dcc700cea2", "searchTerms": [], + "slug": "7-casthour", "title": "Level 7 (CastHour)", - "where": "chart.level = '7'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16595,9 +18304,10 @@ "inactive": true, "legacyFolderID": "Ff30ee2f120b00c6323509192414114b0b13777456e2e908cf5cc43766fa09b7f", "searchTerms": [], + "slug": "7-copula", "title": "Level 7 (copula)", - "where": "chart.level = '7'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16605,9 +18315,10 @@ "inactive": true, "legacyFolderID": "F6a14fcc4277c749970599573f0bd00c25e3de7f959c827483d366180b43530b2", "searchTerms": [], + "slug": "7-13th-cs", "title": "Level 7 (DISTORTED CS)", - "where": "chart.level = '7'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16615,9 +18326,10 @@ "inactive": true, "legacyFolderID": "Fd421c0415cd907dde8fdd53388b33135a46e2c9c826c4e86f47c0d4689794b86", "searchTerms": [], + "slug": "7-15th-cs", "title": "Level 7 (DJ TROOPERS CS)", - "where": "chart.level = '7'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16625,9 +18337,10 @@ "inactive": true, "legacyFolderID": "Ffe880c3bf450d3df93b9b512614d927ec68ff1892bb197448022adabe7b55f91", "searchTerms": [], + "slug": "7-16th-cs", "title": "Level 7 (EMPRESS CS)", - "where": "chart.level = '7'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16635,9 +18348,10 @@ "inactive": true, "legacyFolderID": "F136af5d00ccaa0e948140d66fd6645f9ad438e16f1a20d3eb1750fd628ba83c2", "searchTerms": [], + "slug": "7-epolis-omni", "title": "Level 7 (Epolis Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16645,9 +18359,10 @@ "inactive": true, "legacyFolderID": "F958bb839b8b6d7af5a3dd3628c8f1852b906e9cc308fd51abfa394ea8dd768a2", "searchTerms": [], + "slug": "7-epolis", "title": "Level 7 (EPOLIS)", - "where": "chart.level = '7'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16655,9 +18370,10 @@ "inactive": true, "legacyFolderID": "F29d9ddd5b9ecef3b02be91a0284853882f4949e1fee80face9b347c8eb2316a2", "searchTerms": [], + "slug": "7-14th-cs", "title": "Level 7 (GOLD CS)", - "where": "chart.level = '7'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16665,9 +18381,10 @@ "inactive": true, "legacyFolderID": "Fcdb65db83eb97dedeb4728ff428b3fa9444713925b261df8d0bbe76b91b29f80", "searchTerms": [], + "slug": "7-12th-cs", "title": "Level 7 (HAPPY SKY CS)", - "where": "chart.level = '7'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16675,9 +18392,10 @@ "inactive": true, "legacyFolderID": "F3e364cec26c491d014d49924853861e73724c8c29f53d733d037dbd9213f65bf", "searchTerms": [], + "slug": "7-heroicverse-2dxtra", "title": "Level 7 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '7'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16685,9 +18403,10 @@ "inactive": true, "legacyFolderID": "F1716cef0e3fba150fd31f515610bbb3de00618befdf8a74f492e0d3d9467dc54", "searchTerms": [], + "slug": "7-heroicverse-omni", "title": "Level 7 (HEROIC VERSE Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16695,9 +18414,10 @@ "inactive": true, "legacyFolderID": "F2b9e1bb0b519fdcf91f3deb8c76e0dd169167808a7993ba4898fc943734e0ed9", "searchTerms": [], + "slug": "7-heroicverse", "title": "Level 7 (HEROIC VERSE)", - "where": "chart.level = '7'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16705,9 +18425,10 @@ "inactive": true, "legacyFolderID": "F2c58b7c11d42a26ca22213a1959031cf3ab73a65e6a6c915a3dae9a26f214d8e", "searchTerms": [], + "slug": "7-11th-cs", "title": "Level 7 (IIDX RED CS)", - "where": "chart.level = '7'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16715,9 +18436,10 @@ "inactive": false, "legacyFolderID": "F7495982b3dfe8569b9034cc26224a725e8cc245348763f59d52922affdf89518", "searchTerms": [], + "slug": "7-inf", "title": "Level 7 (INFINITAS)", - "where": "chart.level = '7'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16725,9 +18447,10 @@ "inactive": true, "legacyFolderID": "F9815b9b43aa5d571459c3cd753cb97306c33883fb12d2d16b50ecf8e9bae36a8", "searchTerms": [], + "slug": "7-pendual", "title": "Level 7 (PENDUAL)", - "where": "chart.level = '7'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16735,9 +18458,10 @@ "inactive": false, "legacyFolderID": "Fa775e3c2062c80cab6bed74a12ee10f35d7bce32b2a7b50d9ef9217c8f27a588", "searchTerms": [], + "slug": "7-pinkycrush-omni", "title": "Level 7 (Pinky Crush Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16745,9 +18469,10 @@ "inactive": false, "legacyFolderID": "F7fbee99fdeffde18a9691ba4889b728eedb26d6bdfd62ae7dca7d2ebd843db9c", "searchTerms": [], + "slug": "7-pinkycrush", "title": "Level 7 (Pinky Crush)", - "where": "chart.level = '7'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16755,9 +18480,10 @@ "inactive": true, "legacyFolderID": "F637669b160cb74f26b2a36de16a32985141b3e1eaddca9efacca1e4b1f8f8c4d", "searchTerms": [], + "slug": "7-resident-omni", "title": "Level 7 (Resident Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16765,9 +18491,10 @@ "inactive": true, "legacyFolderID": "F0eeb939f674588d20c9fc506a7c5cc7f7eab744cd920e0d64411a83a553c67fa", "searchTerms": [], + "slug": "7-resident", "title": "Level 7 (Resident)", - "where": "chart.level = '7'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16775,9 +18502,10 @@ "inactive": true, "legacyFolderID": "F08eb5e96dde9e86708f94cc656b34d2475cc2af764e30d18cbaa7d2c19f083ef", "searchTerms": [], + "slug": "7-rootage-omni", "title": "Level 7 (ROOTAGE Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16785,9 +18513,10 @@ "inactive": true, "legacyFolderID": "F32072b04f9a25e3c39681ac3be2e70721f84f5370817b7b45332dca6fb6175f9", "searchTerms": [], + "slug": "7-rootage", "title": "Level 7 (ROOTAGE)", - "where": "chart.level = '7'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16795,9 +18524,10 @@ "inactive": true, "legacyFolderID": "F329f6ed6caf11937fa4b8c495d8601fef1d864e61497299121877d28c65c3f4a", "searchTerms": [], + "slug": "7-sinobuz", "title": "Level 7 (SINOBUZ)", - "where": "chart.level = '7'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16805,9 +18535,10 @@ "inactive": true, "legacyFolderID": "F41c4b2178ac7173d056491165278d2124aabb9b8df87cc336af7793e4e2209d2", "searchTerms": [], + "slug": "7-spada", "title": "Level 7 (SPADA)", - "where": "chart.level = '7'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16815,9 +18546,10 @@ "inactive": false, "legacyFolderID": "Fdb2b0d04a24ea0f66421a4f176a3cb5951d621a6b01f7b6b07cae3eaa8f28ea7", "searchTerms": [], + "slug": "7-sparkleshower", "title": "Level 7 (Sparkle Shower)", - "where": "chart.level = '7'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16825,9 +18557,10 @@ "inactive": true, "legacyFolderID": "F1f6de2847d350b28691d2b9d05b24e18efc7976d70f9330c81366f1d2289eec3", "searchTerms": [], + "slug": "7-tricoro", "title": "Level 7 (tricoro)", - "where": "chart.level = '7'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '7'" }, { "game": "iidx-dp", @@ -16835,9 +18568,10 @@ "inactive": true, "legacyFolderID": "F265593877ed214b03b2dff31ca530aa6ef26d692840fcfb277f07578478779dd", "searchTerms": [], + "slug": "8-10th-cs", "title": "Level 8 (10th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16845,9 +18579,10 @@ "inactive": true, "legacyFolderID": "F36405af19a09a99c6e9c7a7589ffeaf32ac881306124c82ebc8a3d2d26fa55bd", "searchTerms": [], + "slug": "8-3rd-cs", "title": "Level 8 (3rd Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16855,9 +18590,10 @@ "inactive": true, "legacyFolderID": "F1255526a6a12f01535284dbb0fb1a47472d5b630a7381822481fe7ec3a4286ab", "searchTerms": [], + "slug": "8-4th-cs", "title": "Level 8 (4th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16865,9 +18601,10 @@ "inactive": true, "legacyFolderID": "F7e6827084b60a063452f7de8af2f68b7a6d786e2d72c3a8c2273c4644d5d556c", "searchTerms": [], + "slug": "8-5th-cs", "title": "Level 8 (5th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16875,9 +18612,10 @@ "inactive": true, "legacyFolderID": "Fb6fdcb9ca401818e600308f0b5a1aad4330f1ef9a9f43c8c100c0966a9b821a8", "searchTerms": [], + "slug": "8-6th-cs", "title": "Level 8 (6th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16885,9 +18623,10 @@ "inactive": true, "legacyFolderID": "Fb37dd38cdf6758f8cc83b9a065166861753a13173c3f813813906b4c5c5fa4e3", "searchTerms": [], + "slug": "8-7th-cs", "title": "Level 8 (7th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16895,9 +18634,10 @@ "inactive": true, "legacyFolderID": "Ff0305aaa6a3fc28e08888e9b784d98cc64b3d017ab611218e84658121c2d18d9", "searchTerms": [], + "slug": "8-8th-cs", "title": "Level 8 (8th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16905,9 +18645,10 @@ "inactive": true, "legacyFolderID": "Fbd40fbbd19713700ab05af5c2b734cd0a2fd287e74bdc2b069ee9e8f3de15e97", "searchTerms": [], + "slug": "8-9th-cs", "title": "Level 8 (9th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16915,9 +18656,10 @@ "inactive": true, "legacyFolderID": "F965edb0adcf7828a769b13fe86de2364dafb4ae39377a256eed1d16d4ab8fa3c", "searchTerms": [], + "slug": "8-bmus", "title": "Level 8 (BEATMANIA US)", - "where": "chart.level = '8'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16925,9 +18667,10 @@ "inactive": true, "legacyFolderID": "Fe244e55ca60ff4f634b612bb23010f440facbb6f14f68c0634af4a594c9b57ec", "searchTerms": [], + "slug": "8-bistrover-omni", "title": "Level 8 (BISTROVER Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16935,9 +18678,10 @@ "inactive": true, "legacyFolderID": "Ff70d028a436a64070603ee892629be74478ffd379ca7f54e3c5f1c4a376f42f8", "searchTerms": [], + "slug": "8-bistrover", "title": "Level 8 (BISTROVER)", - "where": "chart.level = '8'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16945,9 +18689,10 @@ "inactive": true, "legacyFolderID": "Fbca378951a485305779cc94a9c20c80a9514d081be858787faccf1aed26a4ff7", "searchTerms": [], + "slug": "8-cannonballers", "title": "Level 8 (CANNON BALLERS)", - "where": "chart.level = '8'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16955,9 +18700,10 @@ "inactive": true, "legacyFolderID": "F1cb14427be337f8d7148b2e147af8ef50406b1a744a739757b7ef3356d756b35", "searchTerms": [], + "slug": "8-casthour-omni", "title": "Level 8 (CastHour Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16965,9 +18711,10 @@ "inactive": true, "legacyFolderID": "F726f7c2589fb3ee62a9bf17641d41d618b581966ecf19108d7b5f31035de4f02", "searchTerms": [], + "slug": "8-casthour", "title": "Level 8 (CastHour)", - "where": "chart.level = '8'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16975,9 +18722,10 @@ "inactive": true, "legacyFolderID": "Fdd86e5962c5c0cba125d05b85f3cab2d048aebe247647823208e5709ff35681b", "searchTerms": [], + "slug": "8-copula", "title": "Level 8 (copula)", - "where": "chart.level = '8'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16985,9 +18733,10 @@ "inactive": true, "legacyFolderID": "Fad4e5abb7b41f8393f5e3602421fce77c50946917f6eb7b1fc9e76961687fc01", "searchTerms": [], + "slug": "8-13th-cs", "title": "Level 8 (DISTORTED CS)", - "where": "chart.level = '8'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -16995,9 +18744,10 @@ "inactive": true, "legacyFolderID": "F9407456dd93924fca546a35e2f6289bff427b27766ffcfab081915898bd39492", "searchTerms": [], + "slug": "8-15th-cs", "title": "Level 8 (DJ TROOPERS CS)", - "where": "chart.level = '8'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17005,9 +18755,10 @@ "inactive": true, "legacyFolderID": "Fef8bd9bc654d64f513634a0af6a4290cb3fba25e82e50cc81a81a28e7d16bbc2", "searchTerms": [], + "slug": "8-16th-cs", "title": "Level 8 (EMPRESS CS)", - "where": "chart.level = '8'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17015,9 +18766,10 @@ "inactive": true, "legacyFolderID": "Fa99b566ee02d66922a81ccc938047a345a3cf632a0857db1cf46572444efa0cc", "searchTerms": [], + "slug": "8-epolis-omni", "title": "Level 8 (Epolis Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17025,9 +18777,10 @@ "inactive": true, "legacyFolderID": "F4abbb092a1e14c1d26c31049d8344cb99f163e3bf170fb26f3f0671d86e82b4c", "searchTerms": [], + "slug": "8-epolis", "title": "Level 8 (EPOLIS)", - "where": "chart.level = '8'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17035,9 +18788,10 @@ "inactive": true, "legacyFolderID": "Fdfd8d33d6ae81702e2323b29a697cf4e06f4205534cea8d4a197b3a2db6f4b3b", "searchTerms": [], + "slug": "8-14th-cs", "title": "Level 8 (GOLD CS)", - "where": "chart.level = '8'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17045,9 +18799,10 @@ "inactive": true, "legacyFolderID": "F4eae71b6b5ae57418c7de43467478d6a2ea0d23d412d7f8ec83605cac56d7761", "searchTerms": [], + "slug": "8-12th-cs", "title": "Level 8 (HAPPY SKY CS)", - "where": "chart.level = '8'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17055,9 +18810,10 @@ "inactive": true, "legacyFolderID": "F3a0e8e1e13ce00fe16680f9288a3d242e9415668d34e30cc99f7215bb3b3115a", "searchTerms": [], + "slug": "8-heroicverse-2dxtra", "title": "Level 8 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '8'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17065,9 +18821,10 @@ "inactive": true, "legacyFolderID": "F9ded05f639b778573502e10efae8050cf260b30eb6c54e71b106086c14560785", "searchTerms": [], + "slug": "8-heroicverse-omni", "title": "Level 8 (HEROIC VERSE Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17075,9 +18832,10 @@ "inactive": true, "legacyFolderID": "Fd72d450b83c531a6e2ca25b0fb475c18bc16106a728b27f104796403fe09ca75", "searchTerms": [], + "slug": "8-heroicverse", "title": "Level 8 (HEROIC VERSE)", - "where": "chart.level = '8'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17085,9 +18843,10 @@ "inactive": true, "legacyFolderID": "F63161dbe7f3f0b139cf41afe9ab726c57521932aa31f8c6d7e6336b3a741fdd5", "searchTerms": [], + "slug": "8-11th-cs", "title": "Level 8 (IIDX RED CS)", - "where": "chart.level = '8'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17095,9 +18854,10 @@ "inactive": false, "legacyFolderID": "F12ec9bc65043aabd6b8f726b0466c8c01111c5eff7dd69d0e99884e71cf92871", "searchTerms": [], + "slug": "8-inf", "title": "Level 8 (INFINITAS)", - "where": "chart.level = '8'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17105,9 +18865,10 @@ "inactive": true, "legacyFolderID": "F85613bc9ce7ada3a78c5a85f3ab81f8ddfd3b4f724ae9126a3f8a9ea18418106", "searchTerms": [], + "slug": "8-pendual", "title": "Level 8 (PENDUAL)", - "where": "chart.level = '8'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17115,9 +18876,10 @@ "inactive": false, "legacyFolderID": "F73bd3e42cf134b79cac734257c7f4e2a30f19351a0ce1aa812cffa3025ff95e8", "searchTerms": [], + "slug": "8-pinkycrush-omni", "title": "Level 8 (Pinky Crush Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17125,9 +18887,10 @@ "inactive": false, "legacyFolderID": "Fb8af9c89a5c1403788857a49a6adc33b498cbb992661149b27ebfe5dfce87d3b", "searchTerms": [], + "slug": "8-pinkycrush", "title": "Level 8 (Pinky Crush)", - "where": "chart.level = '8'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17135,9 +18898,10 @@ "inactive": true, "legacyFolderID": "F9d8efd8f6382861fc04ef3dbb53a2ac42e9187cea52341422ecf47395885f4d9", "searchTerms": [], + "slug": "8-resident-omni", "title": "Level 8 (Resident Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17145,9 +18909,10 @@ "inactive": true, "legacyFolderID": "F767970d0fdad6f2859f41b4e12a6c9061457c6dca642d717b4ff4ac104711185", "searchTerms": [], + "slug": "8-resident", "title": "Level 8 (Resident)", - "where": "chart.level = '8'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17155,9 +18920,10 @@ "inactive": true, "legacyFolderID": "Ff91c90925e87aa820d0a2d008b224c26705325ea1ee9edf97db78b6fdf538024", "searchTerms": [], + "slug": "8-rootage-omni", "title": "Level 8 (ROOTAGE Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17165,9 +18931,10 @@ "inactive": true, "legacyFolderID": "F302968f73f90ae6c7cb2ff3e7617f899fd64e58d47d991b2337b22ed28fed596", "searchTerms": [], + "slug": "8-rootage", "title": "Level 8 (ROOTAGE)", - "where": "chart.level = '8'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17175,9 +18942,10 @@ "inactive": true, "legacyFolderID": "F2664b700eb1bef9582ce2746b34bd25d536cd29c388106666856049e3c444728", "searchTerms": [], + "slug": "8-sinobuz", "title": "Level 8 (SINOBUZ)", - "where": "chart.level = '8'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17185,9 +18953,10 @@ "inactive": true, "legacyFolderID": "F7224fed1b581d48a840bcabad6e2397a0c2174f4b930d0ec296dba491490c174", "searchTerms": [], + "slug": "8-spada", "title": "Level 8 (SPADA)", - "where": "chart.level = '8'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17195,9 +18964,10 @@ "inactive": false, "legacyFolderID": "F22cb3ed62c98cc0738e38c0900cd9498cb69d30c5ced40e4ad7584c275c919a2", "searchTerms": [], + "slug": "8-sparkleshower", "title": "Level 8 (Sparkle Shower)", - "where": "chart.level = '8'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17205,9 +18975,10 @@ "inactive": true, "legacyFolderID": "Ff0595593f987c389c1f8c0d6ed491dc8443f4f4e60e1f28be43b1b038c077037", "searchTerms": [], + "slug": "8-tricoro", "title": "Level 8 (tricoro)", - "where": "chart.level = '8'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '8'" }, { "game": "iidx-dp", @@ -17215,9 +18986,10 @@ "inactive": true, "legacyFolderID": "F4d124d2f94b0c505db23220a0fbb8366c48c8d86ac8dd30c95a727ba94b0a579", "searchTerms": [], + "slug": "9-10th-cs", "title": "Level 9 (10th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17225,9 +18997,10 @@ "inactive": true, "legacyFolderID": "F4407c74126a741be527d16e2bfb02b43e3871bc59889aa6c3a7fd499fcaa23d0", "searchTerms": [], + "slug": "9-3rd-cs", "title": "Level 9 (3rd Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17235,9 +19008,10 @@ "inactive": true, "legacyFolderID": "Fa15101803656ef33bff02a3c60a5d96ff8f612e608f5d5b715542f4a39e3b208", "searchTerms": [], + "slug": "9-4th-cs", "title": "Level 9 (4th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17245,9 +19019,10 @@ "inactive": true, "legacyFolderID": "Fe535b44e321ec353958bb074f2847d68ff3e01275074bce2bc454ce7e67123b5", "searchTerms": [], + "slug": "9-5th-cs", "title": "Level 9 (5th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17255,9 +19030,10 @@ "inactive": true, "legacyFolderID": "F8aae7a3f85763c9912c33f54af700e43eaa73303a285fdd1d56f750430eeb1a4", "searchTerms": [], + "slug": "9-6th-cs", "title": "Level 9 (6th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17265,9 +19041,10 @@ "inactive": true, "legacyFolderID": "F802f0677fbd7fc8a4704cbfaa65c81a850c4e53f35af48b1c0929ef529c2b156", "searchTerms": [], + "slug": "9-7th-cs", "title": "Level 9 (7th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17275,9 +19052,10 @@ "inactive": true, "legacyFolderID": "Fee09d3e7df7c7aa60c2cb593a310b164c73bb3508ce5c6daee999486908ef29c", "searchTerms": [], + "slug": "9-8th-cs", "title": "Level 9 (8th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17285,9 +19063,10 @@ "inactive": true, "legacyFolderID": "F0b6cfd04ac937ba0369f2b2a5128b48458c3af9a7c5b11124e86f0c93eb7b2e4", "searchTerms": [], + "slug": "9-9th-cs", "title": "Level 9 (9th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17295,9 +19074,10 @@ "inactive": true, "legacyFolderID": "F66a37cb0c8e0a07ce395f742a2f98adc8957449327cdd58769545b3c635242b8", "searchTerms": [], + "slug": "9-bmus", "title": "Level 9 (BEATMANIA US)", - "where": "chart.level = '9'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17305,9 +19085,10 @@ "inactive": true, "legacyFolderID": "Fc5af6c297a47d8c386f9b7cfe72b1b43d3acb5e943ec64edf36f7656811d0ecd", "searchTerms": [], + "slug": "9-bistrover-omni", "title": "Level 9 (BISTROVER Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17315,9 +19096,10 @@ "inactive": true, "legacyFolderID": "F6789678650650ef9820a42cde92dd6a540d03c5dc55ca95be71089c8f013bcf2", "searchTerms": [], + "slug": "9-bistrover", "title": "Level 9 (BISTROVER)", - "where": "chart.level = '9'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17325,9 +19107,10 @@ "inactive": true, "legacyFolderID": "F9c4feb4a3c750ca572641df0ce307da4d9c9c86eef5aafd5c250f62bcc738e4d", "searchTerms": [], + "slug": "9-cannonballers", "title": "Level 9 (CANNON BALLERS)", - "where": "chart.level = '9'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17335,9 +19118,10 @@ "inactive": true, "legacyFolderID": "F809c601ce0a5f57d10b0eea514c370f4db42aa08a56b8d7589c2eb153626c30e", "searchTerms": [], + "slug": "9-casthour-omni", "title": "Level 9 (CastHour Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17345,9 +19129,10 @@ "inactive": true, "legacyFolderID": "Fe2939c8b39ee4e1ef23db36e7cfdc87868ded759f0e1f7b1dea598bc11d4154b", "searchTerms": [], + "slug": "9-casthour", "title": "Level 9 (CastHour)", - "where": "chart.level = '9'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17355,9 +19140,10 @@ "inactive": true, "legacyFolderID": "F15b8932f29f7632cd61f603a0541d6fce8247a760a7e3713d00bbdfb1d41eab0", "searchTerms": [], + "slug": "9-copula", "title": "Level 9 (copula)", - "where": "chart.level = '9'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17365,9 +19151,10 @@ "inactive": true, "legacyFolderID": "Fe7cac9abec80c6a9e09519426d792d0e19cc8ddf8577c456e69a180ef0590656", "searchTerms": [], + "slug": "9-13th-cs", "title": "Level 9 (DISTORTED CS)", - "where": "chart.level = '9'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17375,9 +19162,10 @@ "inactive": true, "legacyFolderID": "F99f45fc042ee4781e610f467e053fca6b37225aad1c1bf097899f24672282935", "searchTerms": [], + "slug": "9-15th-cs", "title": "Level 9 (DJ TROOPERS CS)", - "where": "chart.level = '9'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17385,9 +19173,10 @@ "inactive": true, "legacyFolderID": "F0f432aa137f56a761e96f705a277e3e34467981261d820115d50a84f42e5483b", "searchTerms": [], + "slug": "9-16th-cs", "title": "Level 9 (EMPRESS CS)", - "where": "chart.level = '9'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17395,9 +19184,10 @@ "inactive": true, "legacyFolderID": "F48736df4ff40d1e15bfd710587f2febd1096e9c8f6016fa5c00585c07735bdb7", "searchTerms": [], + "slug": "9-epolis-omni", "title": "Level 9 (Epolis Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17405,9 +19195,10 @@ "inactive": true, "legacyFolderID": "F93c7ad860631314b85e54be7ba4cd1fb5fc7279d94583182806e872303cbacf7", "searchTerms": [], + "slug": "9-epolis", "title": "Level 9 (EPOLIS)", - "where": "chart.level = '9'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17415,9 +19206,10 @@ "inactive": true, "legacyFolderID": "F1b55cd4230ffee615834d77189aa3b3dd305b916659e9698601ebeb4bb8467a5", "searchTerms": [], + "slug": "9-14th-cs", "title": "Level 9 (GOLD CS)", - "where": "chart.level = '9'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17425,9 +19217,10 @@ "inactive": true, "legacyFolderID": "Fe318e56ef8d35b017655c3e5ff710905e644682e56c7a0139875594ba4373d3d", "searchTerms": [], + "slug": "9-12th-cs", "title": "Level 9 (HAPPY SKY CS)", - "where": "chart.level = '9'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17435,9 +19228,10 @@ "inactive": true, "legacyFolderID": "Faec46d726d1767a9d7553b0bf9524e80cebf94aea048c75a5184cf2ce29d2b28", "searchTerms": [], + "slug": "9-heroicverse-2dxtra", "title": "Level 9 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '9'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17445,9 +19239,10 @@ "inactive": true, "legacyFolderID": "F77dfcc03bc75971490d51145a458a1c2b1761309189b650eacd5394afc8991bb", "searchTerms": [], + "slug": "9-heroicverse-omni", "title": "Level 9 (HEROIC VERSE Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17455,9 +19250,10 @@ "inactive": true, "legacyFolderID": "Fa5d75b776ac46a0c5f072956a69a38de811cd67b7ea74cbfa68d20794a00e9f1", "searchTerms": [], + "slug": "9-heroicverse", "title": "Level 9 (HEROIC VERSE)", - "where": "chart.level = '9'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17465,9 +19261,10 @@ "inactive": true, "legacyFolderID": "F4ad8025378ab0b352c271f742b9ec3eb9dac0fc6927a9355152dba9169e10a17", "searchTerms": [], + "slug": "9-11th-cs", "title": "Level 9 (IIDX RED CS)", - "where": "chart.level = '9'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17475,9 +19272,10 @@ "inactive": false, "legacyFolderID": "F1d4c830e4c7ff6c75c24c5f1a1b52769409417820cf3840bf657f1679db671bb", "searchTerms": [], + "slug": "9-inf", "title": "Level 9 (INFINITAS)", - "where": "chart.level = '9'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17485,9 +19283,10 @@ "inactive": true, "legacyFolderID": "Fde26e2b7d6c602cb8127541af4835b8e481181275ee4d77f618e2013cf2ff2c2", "searchTerms": [], + "slug": "9-pendual", "title": "Level 9 (PENDUAL)", - "where": "chart.level = '9'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17495,9 +19294,10 @@ "inactive": false, "legacyFolderID": "F0a5bdb6b242fe775a30d235a88d3d98d14cc92e50911cc6d64d189a0b98db82c", "searchTerms": [], + "slug": "9-pinkycrush-omni", "title": "Level 9 (Pinky Crush Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17505,9 +19305,10 @@ "inactive": false, "legacyFolderID": "F05f2f8e88514747d601ed0f62fe9774267741222ed9680a9f05388e788d270c6", "searchTerms": [], + "slug": "9-pinkycrush", "title": "Level 9 (Pinky Crush)", - "where": "chart.level = '9'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17515,9 +19316,10 @@ "inactive": true, "legacyFolderID": "Fea4d31db72d354155d069e15cc51aa80493fd599fa0388f916086f9eb736556d", "searchTerms": [], + "slug": "9-resident-omni", "title": "Level 9 (Resident Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17525,9 +19327,10 @@ "inactive": true, "legacyFolderID": "F2195f643591e3bc90df35ae0a9938d33849a157052e15f2987159dc2b97c8549", "searchTerms": [], + "slug": "9-resident", "title": "Level 9 (Resident)", - "where": "chart.level = '9'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17535,9 +19338,10 @@ "inactive": true, "legacyFolderID": "F7fd8477dc6395c5f791571de07d87d36c4fd3fe59be42dd030c253d04a548f20", "searchTerms": [], + "slug": "9-rootage-omni", "title": "Level 9 (ROOTAGE Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17545,9 +19349,10 @@ "inactive": true, "legacyFolderID": "F29b27a3ab9e2ac819997bc12723ff81217e582d6021ba4c69baed84682bb7c75", "searchTerms": [], + "slug": "9-rootage", "title": "Level 9 (ROOTAGE)", - "where": "chart.level = '9'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17555,9 +19360,10 @@ "inactive": true, "legacyFolderID": "Ff749a26413e8cfbb62565e3d71ef272161a7447691003f49fd8d2d859b809809", "searchTerms": [], + "slug": "9-sinobuz", "title": "Level 9 (SINOBUZ)", - "where": "chart.level = '9'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17565,9 +19371,10 @@ "inactive": true, "legacyFolderID": "F9e1b1680dd34ce94a52edac35b4f112526888bd2976dfb165dac4c7a1a7063be", "searchTerms": [], + "slug": "9-spada", "title": "Level 9 (SPADA)", - "where": "chart.level = '9'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17575,9 +19382,10 @@ "inactive": false, "legacyFolderID": "F3299323feb5be91d8c25546dfbf3717c64fb1b2023f371699ba8537c2a606987", "searchTerms": [], + "slug": "9-sparkleshower", "title": "Level 9 (Sparkle Shower)", - "where": "chart.level = '9'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '9'" }, { "game": "iidx-dp", @@ -17585,9 +19393,10 @@ "inactive": true, "legacyFolderID": "F06084f17d450c914daf1b9a8bee1577ab2a368a7cbe5f114875067dff6836340", "searchTerms": [], + "slug": "9-tricoro", "title": "Level 9 (tricoro)", - "where": "chart.level = '9'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -17595,9 +19404,10 @@ "inactive": true, "legacyFolderID": "F132363598bdf3d3e575fb6caa5e48a85f7d43efe0934718b863820133e1478dc", "searchTerms": [], + "slug": "1-10th-cs", "title": "Level 1 (10th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17605,9 +19415,10 @@ "inactive": true, "legacyFolderID": "F62be6a3a8500e079458068fd7fcdad2ae1231b90bee978fb60bb046b749ffbbc", "searchTerms": [], + "slug": "1-3rd-cs", "title": "Level 1 (3rd Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17615,9 +19426,10 @@ "inactive": true, "legacyFolderID": "F031f9f8146fbd1a1a3d9d47eee9ee7b87f492611847ee94c95efb1f887fd1b01", "searchTerms": [], + "slug": "1-4th-cs", "title": "Level 1 (4th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17625,9 +19437,10 @@ "inactive": true, "legacyFolderID": "Fb31d427ce4d1dae4de4a5ba54925db87f98c4d828d4c122498909485c1239501", "searchTerms": [], + "slug": "1-5th-cs", "title": "Level 1 (5th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17635,9 +19448,10 @@ "inactive": true, "legacyFolderID": "F7c769666afd3ccdbc92a7410d01f29f69786b07cbc1052ccd34b1e15f0763ff0", "searchTerms": [], + "slug": "1-6th-cs", "title": "Level 1 (6th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17645,9 +19459,10 @@ "inactive": true, "legacyFolderID": "Fc17cedd5ad39020645fad5a794949ba755e8b78411f0d973e097f8276255c015", "searchTerms": [], + "slug": "1-7th-cs", "title": "Level 1 (7th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17655,9 +19470,10 @@ "inactive": true, "legacyFolderID": "F5b3544ca63c29fe0036134d8acb7c407e8399a34ddfed32df5cfd01e114f66cc", "searchTerms": [], + "slug": "1-8th-cs", "title": "Level 1 (8th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17665,9 +19481,10 @@ "inactive": true, "legacyFolderID": "Fe6d0f8f01f169abaf815fdb2ff90e9cb4058a0b4dbd1af3235966c9bd86c1610", "searchTerms": [], + "slug": "1-9th-cs", "title": "Level 1 (9th Style CS)", - "where": "chart.level = '1'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17675,9 +19492,10 @@ "inactive": true, "legacyFolderID": "F9db3ab4536dda590da0ae557992e24540a21cd8659c509a004f3875d70b68c77", "searchTerms": [], + "slug": "1-bmus", "title": "Level 1 (BEATMANIA US)", - "where": "chart.level = '1'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17685,9 +19503,10 @@ "inactive": true, "legacyFolderID": "Fe131fe937e9e58b42a71a9b1a9a96ee80fb928dc4acc59e32a2ef8e9c5682488", "searchTerms": [], + "slug": "1-bistrover-omni", "title": "Level 1 (BISTROVER Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17695,9 +19514,10 @@ "inactive": true, "legacyFolderID": "Fcc30582cf542a3dee8a4eb571db7a0243e95b11873293b9392b503b2bb27e345", "searchTerms": [], + "slug": "1-bistrover", "title": "Level 1 (BISTROVER)", - "where": "chart.level = '1'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17705,9 +19525,10 @@ "inactive": true, "legacyFolderID": "Fe05d0b3366e5595112394d175970ed7a978c5de0d30cf03d7fa63b545ca87cad", "searchTerms": [], + "slug": "1-cannonballers", "title": "Level 1 (CANNON BALLERS)", - "where": "chart.level = '1'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17715,9 +19536,10 @@ "inactive": true, "legacyFolderID": "Ff603bf43256a072b981830bd0ac1dd4d3777dea026ba9b79be8b77e8abb56fa1", "searchTerms": [], + "slug": "1-casthour-omni", "title": "Level 1 (CastHour Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17725,9 +19547,10 @@ "inactive": true, "legacyFolderID": "F10e3e0e7c0bcbd4dcbb49a1086184d6949fe7a213ecbd6bf1ed7001f2b98a98b", "searchTerms": [], + "slug": "1-casthour", "title": "Level 1 (CastHour)", - "where": "chart.level = '1'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17735,9 +19558,10 @@ "inactive": true, "legacyFolderID": "F204d8a86fb7b58bc0c7892bc6f4fe3216c974f64b36d7971b800587aa603a929", "searchTerms": [], + "slug": "1-copula", "title": "Level 1 (copula)", - "where": "chart.level = '1'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17745,9 +19569,10 @@ "inactive": true, "legacyFolderID": "F8f3c8f4088c910322adc85d89739196a83980e76991ee03760927dd350dc1ac9", "searchTerms": [], + "slug": "1-13th-cs", "title": "Level 1 (DISTORTED CS)", - "where": "chart.level = '1'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17755,9 +19580,10 @@ "inactive": true, "legacyFolderID": "Fdea903b053d3c59f1d5fd0698555566c194a7712150e97ee9401b945e97893f6", "searchTerms": [], + "slug": "1-15th-cs", "title": "Level 1 (DJ TROOPERS CS)", - "where": "chart.level = '1'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17765,9 +19591,10 @@ "inactive": true, "legacyFolderID": "F1d57ae148bbdb8ecfd7c28f62344f133cb2a3210291f311b95db837f7450ce28", "searchTerms": [], + "slug": "1-16th-cs", "title": "Level 1 (EMPRESS CS)", - "where": "chart.level = '1'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17775,9 +19602,10 @@ "inactive": true, "legacyFolderID": "F5d4303ec11f0523aabbf51ebdeb8bd76318c0ee1b84ded0568637a770e5a9e11", "searchTerms": [], + "slug": "1-epolis-omni", "title": "Level 1 (Epolis Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17785,9 +19613,10 @@ "inactive": true, "legacyFolderID": "Feb083d47b06dd6752591125bf41299e35596c168fb4946ba731ad9123b646aac", "searchTerms": [], + "slug": "1-epolis", "title": "Level 1 (EPOLIS)", - "where": "chart.level = '1'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17795,9 +19624,10 @@ "inactive": true, "legacyFolderID": "Fcafbdbef61a09b59b6174ff09a7c948b3f30c688cee1db7e74262613a40ac3c7", "searchTerms": [], + "slug": "1-14th-cs", "title": "Level 1 (GOLD CS)", - "where": "chart.level = '1'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17805,9 +19635,10 @@ "inactive": true, "legacyFolderID": "F405d449ee1369d90079287fd1f5768eafb7a8a7a793cbeb05a2e5c97e4069f81", "searchTerms": [], + "slug": "1-12th-cs", "title": "Level 1 (HAPPY SKY CS)", - "where": "chart.level = '1'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17815,9 +19646,10 @@ "inactive": true, "legacyFolderID": "Fd28268eefbcb7003d0fcec37622fbb942a13e70f710d52a039952817bac180cd", "searchTerms": [], + "slug": "1-heroicverse-2dxtra", "title": "Level 1 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '1'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17825,9 +19657,10 @@ "inactive": true, "legacyFolderID": "Fd5f9d621c4052c6d6b9af777538112d5d6564e68845c9d7983d75a1dc77aa621", "searchTerms": [], + "slug": "1-heroicverse-omni", "title": "Level 1 (HEROIC VERSE Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17835,9 +19668,10 @@ "inactive": true, "legacyFolderID": "Fca672e5e67354ea7d7c3e56036ebbee03b6c4e1355002d1b2037e0af402ea8b0", "searchTerms": [], + "slug": "1-heroicverse", "title": "Level 1 (HEROIC VERSE)", - "where": "chart.level = '1'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17845,9 +19679,10 @@ "inactive": true, "legacyFolderID": "F704b24f77370fbc964bd50f1c68ccae57d5a169e85144ebe8a5f837b3e7ad9bb", "searchTerms": [], + "slug": "1-11th-cs", "title": "Level 1 (IIDX RED CS)", - "where": "chart.level = '1'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17855,9 +19690,10 @@ "inactive": false, "legacyFolderID": "Ff311f174f84caedd50c91801b3f8da7df9ce1a4bc7523c28c094a5e5c98320e1", "searchTerms": [], + "slug": "1-inf", "title": "Level 1 (INFINITAS)", - "where": "chart.level = '1'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17865,9 +19701,10 @@ "inactive": true, "legacyFolderID": "Ff4fd285a2850eee2af8f6f6c9db2a54aab85cb78a113043a59318a16a2c9e290", "searchTerms": [], + "slug": "1-pendual", "title": "Level 1 (PENDUAL)", - "where": "chart.level = '1'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17875,9 +19712,10 @@ "inactive": false, "legacyFolderID": "F4bd1f76d74a9dc216ff0d75b4d806f783a36fc229caa8d3214377b8eb613335e", "searchTerms": [], + "slug": "1-pinkycrush-omni", "title": "Level 1 (Pinky Crush Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17885,9 +19723,10 @@ "inactive": false, "legacyFolderID": "F414a38ddc83bbe981e09331b7f254c3163520e4357286039fe8c33da64808545", "searchTerms": [], + "slug": "1-pinkycrush", "title": "Level 1 (Pinky Crush)", - "where": "chart.level = '1'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17895,9 +19734,10 @@ "inactive": true, "legacyFolderID": "Fc8eff46b03da944c5bbc149a02af7341e454c869cc5657efc069a80ed32d99bc", "searchTerms": [], + "slug": "1-resident-omni", "title": "Level 1 (Resident Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17905,9 +19745,10 @@ "inactive": true, "legacyFolderID": "F59452de8dbd5280583f1a9626229b6f63c08b50dd9da07917d6bf62785f82cdc", "searchTerms": [], + "slug": "1-resident", "title": "Level 1 (Resident)", - "where": "chart.level = '1'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17915,9 +19756,10 @@ "inactive": true, "legacyFolderID": "F226f4eb9264db4034d02d7ff765fd5cfb8adda48667c3a66a1fa513b4708cb5b", "searchTerms": [], + "slug": "1-rootage-omni", "title": "Level 1 (ROOTAGE Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17925,9 +19767,10 @@ "inactive": true, "legacyFolderID": "F1bb772000fb4246ad3e515bf166e977ae23d6baf6d8392cd96e990d1eec72e99", "searchTerms": [], + "slug": "1-rootage", "title": "Level 1 (ROOTAGE)", - "where": "chart.level = '1'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17935,9 +19778,10 @@ "inactive": true, "legacyFolderID": "F228f2c4287f349ba4c90f9577b30f587018eff4bfff6a09ff5b196e65fbb1886", "searchTerms": [], + "slug": "1-sinobuz", "title": "Level 1 (SINOBUZ)", - "where": "chart.level = '1'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17945,9 +19789,10 @@ "inactive": true, "legacyFolderID": "Fb3fa87047caba5c9c51507e20de7959aed4cf847fa23c8838a890f4ce5bc4768", "searchTerms": [], + "slug": "1-spada", "title": "Level 1 (SPADA)", - "where": "chart.level = '1'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17955,9 +19800,10 @@ "inactive": false, "legacyFolderID": "F126b6d34ca92dfab50d56146b7ebe0b608067a247e8fc12c4bef57d33839903b", "searchTerms": [], + "slug": "1-sparkleshower", "title": "Level 1 (Sparkle Shower)", - "where": "chart.level = '1'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17965,9 +19811,10 @@ "inactive": true, "legacyFolderID": "F6095ba01f3315014139aa226347443a7ccd186d97b22735a7bfc1524944f3e44", "searchTerms": [], + "slug": "1-tricoro", "title": "Level 1 (tricoro)", - "where": "chart.level = '1'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '1'" }, { "game": "iidx-sp", @@ -17975,9 +19822,10 @@ "inactive": true, "legacyFolderID": "F902c020f709ed9f99f60a74e534ab9c8c27770c11b4fd2d01a12b23272171b03", "searchTerms": [], + "slug": "10-10th-cs", "title": "Level 10 (10th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -17985,9 +19833,10 @@ "inactive": true, "legacyFolderID": "F18da5d0141873211e9b380542549d4f82e19f65f2fbe56250aeb3b512b9c3435", "searchTerms": [], + "slug": "10-3rd-cs", "title": "Level 10 (3rd Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -17995,9 +19844,10 @@ "inactive": true, "legacyFolderID": "F357d74fdca079246792d793e2ff6260240bd292399b00780c49f5c4b33020f6a", "searchTerms": [], + "slug": "10-4th-cs", "title": "Level 10 (4th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18005,9 +19855,10 @@ "inactive": true, "legacyFolderID": "F56568a2b0819226999d7538e8dc69f075483886bbe2fce1a3e05d9a0cb0d4514", "searchTerms": [], + "slug": "10-5th-cs", "title": "Level 10 (5th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18015,9 +19866,10 @@ "inactive": true, "legacyFolderID": "Fc98dc812bec9bdd9ecb8c6b733b368e21731a673d2f458bb77e5c41b2ad2d90e", "searchTerms": [], + "slug": "10-6th-cs", "title": "Level 10 (6th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18025,9 +19877,10 @@ "inactive": true, "legacyFolderID": "Fe44901f6d7569aaeeee6bb680766c27e1e456dbea334555cd00688a0f3f08fe8", "searchTerms": [], + "slug": "10-7th-cs", "title": "Level 10 (7th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18035,9 +19888,10 @@ "inactive": true, "legacyFolderID": "Fa1483b0605dd0b980f9cf4b33c7a306885679bd3a8aca70c6e3f635fe7bfc301", "searchTerms": [], + "slug": "10-8th-cs", "title": "Level 10 (8th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18045,9 +19899,10 @@ "inactive": true, "legacyFolderID": "Faed5a8ef5c41a1c45438f28a2d6e92e5c2be07b595de00b98676da50f95a60d7", "searchTerms": [], + "slug": "10-9th-cs", "title": "Level 10 (9th Style CS)", - "where": "chart.level = '10'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18055,9 +19910,10 @@ "inactive": true, "legacyFolderID": "Fb68401efe2fbd076ebbee98e322b4cf8735d7504f63c51b7224e1c41e38567c0", "searchTerms": [], + "slug": "10-bmus", "title": "Level 10 (BEATMANIA US)", - "where": "chart.level = '10'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18065,9 +19921,10 @@ "inactive": true, "legacyFolderID": "F3bf7193425494282e5b06b3152193dcb60d1911d356f6b7b30d9632d98e6ae7f", "searchTerms": [], + "slug": "10-bistrover-omni", "title": "Level 10 (BISTROVER Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18075,9 +19932,10 @@ "inactive": true, "legacyFolderID": "F0564c108ae010bc79e8b83139997885aff8fc50e4ecfcdbe6ff6a71a55c224ab", "searchTerms": [], + "slug": "10-bistrover", "title": "Level 10 (BISTROVER)", - "where": "chart.level = '10'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18085,9 +19943,10 @@ "inactive": true, "legacyFolderID": "F509a45b5fb3fb7778378cb063fa065ad083ef796a0eedc3792822b4dc2b3a7fe", "searchTerms": [], + "slug": "10-cannonballers", "title": "Level 10 (CANNON BALLERS)", - "where": "chart.level = '10'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18095,9 +19954,10 @@ "inactive": true, "legacyFolderID": "Fa9d604b60f3c84981ebc6d2a999206beb2d22b2eb3af1b5036d5c19b0d831c7d", "searchTerms": [], + "slug": "10-casthour-omni", "title": "Level 10 (CastHour Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18105,9 +19965,10 @@ "inactive": true, "legacyFolderID": "F844651ec49a21e5c065b1138bbb98b1bfd9f3abd6a0dca444892993b15e8b41d", "searchTerms": [], + "slug": "10-casthour", "title": "Level 10 (CastHour)", - "where": "chart.level = '10'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18115,9 +19976,10 @@ "inactive": true, "legacyFolderID": "F17f75b6c3aedf05ec32cf668455c8f66c97c874ad685d9ab5675f15b2df22f1e", "searchTerms": [], + "slug": "10-copula", "title": "Level 10 (copula)", - "where": "chart.level = '10'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18125,9 +19987,10 @@ "inactive": true, "legacyFolderID": "Fafe0629262da48188832595f305263133322885f266ddf88205e4dc9a21b0492", "searchTerms": [], + "slug": "10-13th-cs", "title": "Level 10 (DISTORTED CS)", - "where": "chart.level = '10'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18135,9 +19998,10 @@ "inactive": true, "legacyFolderID": "Ff83405b168495ec787cd29eb06678c983778699af127354c1fd1530b642675a8", "searchTerms": [], + "slug": "10-15th-cs", "title": "Level 10 (DJ TROOPERS CS)", - "where": "chart.level = '10'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18145,9 +20009,10 @@ "inactive": true, "legacyFolderID": "F0480dd4c749d615b40bea1c908c576a93cecbff183171cf172a58de64d551c85", "searchTerms": [], + "slug": "10-16th-cs", "title": "Level 10 (EMPRESS CS)", - "where": "chart.level = '10'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18155,9 +20020,10 @@ "inactive": true, "legacyFolderID": "Ff086c90a64fe3d4f8d4c879c7585419b916f998c80a400a20dc273e3c1fa721f", "searchTerms": [], + "slug": "10-epolis-omni", "title": "Level 10 (Epolis Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18165,9 +20031,10 @@ "inactive": true, "legacyFolderID": "F35c24c57f47fc0e1e6de909b284129527caba84848dc55e221ebca64febce978", "searchTerms": [], + "slug": "10-epolis", "title": "Level 10 (EPOLIS)", - "where": "chart.level = '10'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18175,9 +20042,10 @@ "inactive": true, "legacyFolderID": "F5e27f18eb1aed20df41554908dcc3494c0535c22f2af58de4ad9feb430dad427", "searchTerms": [], + "slug": "10-14th-cs", "title": "Level 10 (GOLD CS)", - "where": "chart.level = '10'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18185,9 +20053,10 @@ "inactive": true, "legacyFolderID": "F857aef4c1a9a1ce75dee8de9ec00702060335bac0a3d81ee198c0732e60eb71f", "searchTerms": [], + "slug": "10-12th-cs", "title": "Level 10 (HAPPY SKY CS)", - "where": "chart.level = '10'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18195,9 +20064,10 @@ "inactive": true, "legacyFolderID": "F91ce8a813abc004b505a312fa3bd7eebb7cf5d09ecbeea4dc655e8537a1f9379", "searchTerms": [], + "slug": "10-heroicverse-2dxtra", "title": "Level 10 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '10'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18205,9 +20075,10 @@ "inactive": true, "legacyFolderID": "Fc2dfc35a5b9582c2bddba7699400f84661043ee241fd546ae70be4ccd41be88b", "searchTerms": [], + "slug": "10-heroicverse-omni", "title": "Level 10 (HEROIC VERSE Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18215,9 +20086,10 @@ "inactive": true, "legacyFolderID": "Fac64680e6eed0282a4393ea601c170b79b5dd93a74a8727cf89b9eadf9f5ec71", "searchTerms": [], + "slug": "10-heroicverse", "title": "Level 10 (HEROIC VERSE)", - "where": "chart.level = '10'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18225,9 +20097,10 @@ "inactive": true, "legacyFolderID": "F6b950cc3f0bcc78ad701833f3bd0b5d5008bf9be34164c93b47cd832111b52ea", "searchTerms": [], + "slug": "10-11th-cs", "title": "Level 10 (IIDX RED CS)", - "where": "chart.level = '10'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18235,9 +20108,10 @@ "inactive": false, "legacyFolderID": "F79055bd517eae5107b1bc2f8da9d057f814b960f2cf1dc37541c5336a7435fee", "searchTerms": [], + "slug": "10-inf", "title": "Level 10 (INFINITAS)", - "where": "chart.level = '10'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18245,9 +20119,10 @@ "inactive": true, "legacyFolderID": "Fa4f02c4fb69f656b748a8af119a3b1543af30304dd2b7af063d4c2368d95a31f", "searchTerms": [], + "slug": "10-pendual", "title": "Level 10 (PENDUAL)", - "where": "chart.level = '10'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18255,9 +20130,10 @@ "inactive": false, "legacyFolderID": "Fdba364f495ef053d68f4d862649480983558502fb0bef11b44b692d2b616831a", "searchTerms": [], + "slug": "10-pinkycrush-omni", "title": "Level 10 (Pinky Crush Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18265,9 +20141,10 @@ "inactive": false, "legacyFolderID": "F9360022ed3e9a10d14765ce461a23d63b11cd0b9e51a1698d0bba33e1ddb5c7f", "searchTerms": [], + "slug": "10-pinkycrush", "title": "Level 10 (Pinky Crush)", - "where": "chart.level = '10'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18275,9 +20152,10 @@ "inactive": true, "legacyFolderID": "F4d45367f5f15769025d7fc31169b10c96d71ffe9e6e4885fc4fd93a6385fc616", "searchTerms": [], + "slug": "10-resident-omni", "title": "Level 10 (Resident Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18285,9 +20163,10 @@ "inactive": true, "legacyFolderID": "Ff2ab48f201a0421127d6336179e0486f5255bc6a9449dc6e590c7d5ca038ba0e", "searchTerms": [], + "slug": "10-resident", "title": "Level 10 (Resident)", - "where": "chart.level = '10'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18295,9 +20174,10 @@ "inactive": true, "legacyFolderID": "F495c2e2eeea47f1654c6b588b1bbac16608ce54dd01bd62edcd522604999a947", "searchTerms": [], + "slug": "10-rootage-omni", "title": "Level 10 (ROOTAGE Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18305,9 +20185,10 @@ "inactive": true, "legacyFolderID": "Fb0bae9a61eaecf3a261ae3677febb91395d8f9d5691bf76222250df0ace5dc88", "searchTerms": [], + "slug": "10-rootage", "title": "Level 10 (ROOTAGE)", - "where": "chart.level = '10'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18315,9 +20196,10 @@ "inactive": true, "legacyFolderID": "F892fe3c0bfe9cadccd482b3a3e002b4c2dc036a1ff011c2713e40454cd0496d2", "searchTerms": [], + "slug": "10-sinobuz", "title": "Level 10 (SINOBUZ)", - "where": "chart.level = '10'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18325,9 +20207,10 @@ "inactive": true, "legacyFolderID": "F375faa44693c090cc68361111e60490f9e32206f880da95b2474672b5d6620b0", "searchTerms": [], + "slug": "10-spada", "title": "Level 10 (SPADA)", - "where": "chart.level = '10'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18335,9 +20218,10 @@ "inactive": false, "legacyFolderID": "F68eb1793e01cf279e48aedc23be8972dfa0b57bb6c04dfbf03d30346a0940acc", "searchTerms": [], + "slug": "10-sparkleshower", "title": "Level 10 (Sparkle Shower)", - "where": "chart.level = '10'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18345,9 +20229,10 @@ "inactive": true, "legacyFolderID": "F899ba4a02a7093d052918872782c7cdf9979c9df734c284c77adb1442ed430b8", "searchTerms": [], + "slug": "10-tricoro", "title": "Level 10 (tricoro)", - "where": "chart.level = '10'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '10'" }, { "game": "iidx-sp", @@ -18355,9 +20240,10 @@ "inactive": true, "legacyFolderID": "Ff9fae7efa64149e0765a0966b255ccf1d94790823b8808483e34e1e9194a8251", "searchTerms": [], + "slug": "11-10th-cs", "title": "Level 11 (10th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18365,9 +20251,10 @@ "inactive": true, "legacyFolderID": "F1ece88315f4dc791e3e70072710990f931243444c024d1f30402f7ccb8253419", "searchTerms": [], + "slug": "11-3rd-cs", "title": "Level 11 (3rd Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18375,9 +20262,10 @@ "inactive": true, "legacyFolderID": "Fe5bf7b668375967960ac4f07043472da411ee15a844126b22b35309a2b98bbbb", "searchTerms": [], + "slug": "11-4th-cs", "title": "Level 11 (4th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18385,9 +20273,10 @@ "inactive": true, "legacyFolderID": "Fc46ed28361c09630951a4a14da10cb3797a24bc49d61456767aa314069884e65", "searchTerms": [], + "slug": "11-5th-cs", "title": "Level 11 (5th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18395,9 +20284,10 @@ "inactive": true, "legacyFolderID": "Ff1e6d14e73d85eabe232ba4da309dcc5ac6c9aa629140b0f5ae93759b4421d00", "searchTerms": [], + "slug": "11-6th-cs", "title": "Level 11 (6th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18405,9 +20295,10 @@ "inactive": true, "legacyFolderID": "Fbf724b1375bc5c954d7ebf2c0291a085cf2a8332cdfbbc6a9d656397c6d4353d", "searchTerms": [], + "slug": "11-7th-cs", "title": "Level 11 (7th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18415,9 +20306,10 @@ "inactive": true, "legacyFolderID": "F5f4a93b33f399a2747bbd6d186c3fa2aa7059d6dc4c2d0c96434414695f968d9", "searchTerms": [], + "slug": "11-8th-cs", "title": "Level 11 (8th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18425,9 +20317,10 @@ "inactive": true, "legacyFolderID": "Fae7d6791ab5e0b714c001138b28a918823ba8351b888a1f630a72f0a9d6e06d5", "searchTerms": [], + "slug": "11-9th-cs", "title": "Level 11 (9th Style CS)", - "where": "chart.level = '11'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18435,9 +20328,10 @@ "inactive": true, "legacyFolderID": "F208449e8619fbe81e987a3d50b1b6512dba4d27f7fbd65accdecdd4ede2e3bd5", "searchTerms": [], + "slug": "11-bmus", "title": "Level 11 (BEATMANIA US)", - "where": "chart.level = '11'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18445,9 +20339,10 @@ "inactive": true, "legacyFolderID": "Fbb65c2422f52e8bd1889cb2cee5331c09816f85b650f6e26422e2e22f14a3e43", "searchTerms": [], + "slug": "11-bistrover-omni", "title": "Level 11 (BISTROVER Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18455,9 +20350,10 @@ "inactive": true, "legacyFolderID": "Feb9037d73057a7e55d49a9cf87d3da9673c35ded6604913025b1c0d0b44de3b8", "searchTerms": [], + "slug": "11-bistrover", "title": "Level 11 (BISTROVER)", - "where": "chart.level = '11'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18465,9 +20361,10 @@ "inactive": true, "legacyFolderID": "Fc254be64fba255044b4c7cee734ec5cb2f18652a0b304873c5b5f05981a31492", "searchTerms": [], + "slug": "11-cannonballers", "title": "Level 11 (CANNON BALLERS)", - "where": "chart.level = '11'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18475,9 +20372,10 @@ "inactive": true, "legacyFolderID": "F236f08e96932d40bbf93fa7678149fa490b641260f02b9f0418e71f74dacbf32", "searchTerms": [], + "slug": "11-casthour-omni", "title": "Level 11 (CastHour Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18485,9 +20383,10 @@ "inactive": true, "legacyFolderID": "F7aaf1fe3b7bc5a346106a596e67ae2c86462c671d11c7f1db1422d8e7a421735", "searchTerms": [], + "slug": "11-casthour", "title": "Level 11 (CastHour)", - "where": "chart.level = '11'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18495,9 +20394,10 @@ "inactive": true, "legacyFolderID": "F472495fcddc0b11ae476acc466103b696f8d3d83ddaabd3699e28ef82c117eeb", "searchTerms": [], + "slug": "11-copula", "title": "Level 11 (copula)", - "where": "chart.level = '11'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18505,9 +20405,10 @@ "inactive": true, "legacyFolderID": "Fd58d587b538a09fd9d85f039265e79f4aeca1017f996712b5209632a22941acc", "searchTerms": [], + "slug": "11-13th-cs", "title": "Level 11 (DISTORTED CS)", - "where": "chart.level = '11'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18515,9 +20416,10 @@ "inactive": true, "legacyFolderID": "F4d65affcb851c984fd2a6e46c50b358a96c27f97e85989e7ca1d47e6340b8860", "searchTerms": [], + "slug": "11-15th-cs", "title": "Level 11 (DJ TROOPERS CS)", - "where": "chart.level = '11'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18525,9 +20427,10 @@ "inactive": true, "legacyFolderID": "Fcb536f5b4a9fc313c2297134881405c136b0356290c8dbff791f3cce5c424a95", "searchTerms": [], + "slug": "11-16th-cs", "title": "Level 11 (EMPRESS CS)", - "where": "chart.level = '11'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18535,9 +20438,10 @@ "inactive": true, "legacyFolderID": "F2d5e0ebaf67f8021e097340e2f553e14eb617db25e89eabebafb1c14f0405f5c", "searchTerms": [], + "slug": "11-epolis-omni", "title": "Level 11 (Epolis Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18545,9 +20449,10 @@ "inactive": true, "legacyFolderID": "F29c545c484d4079de3b40cfcb1e7cca18361a50d158d83dfdff1a14c12efeeae", "searchTerms": [], + "slug": "11-epolis", "title": "Level 11 (EPOLIS)", - "where": "chart.level = '11'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18555,9 +20460,10 @@ "inactive": true, "legacyFolderID": "F5a10427a8b6505ec0e1889926eb831099b39af164f3c0c2ea993b0cd853902fb", "searchTerms": [], + "slug": "11-14th-cs", "title": "Level 11 (GOLD CS)", - "where": "chart.level = '11'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18565,9 +20471,10 @@ "inactive": true, "legacyFolderID": "F64df9dda4e6f2563140aa6f2a1e96804ebbd8e6a80a307521a57c5545ec2965a", "searchTerms": [], + "slug": "11-12th-cs", "title": "Level 11 (HAPPY SKY CS)", - "where": "chart.level = '11'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18575,9 +20482,10 @@ "inactive": true, "legacyFolderID": "Fd00a31c3baddac4d71a195c652ea644ee7fa47dbb46c772b930f2788100a568e", "searchTerms": [], + "slug": "11-heroicverse-2dxtra", "title": "Level 11 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '11'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18585,9 +20493,10 @@ "inactive": true, "legacyFolderID": "F5cd644ec8dd2871a3c3696934898d65ac2ebd7c971c5a022dc434d8b915fd7b7", "searchTerms": [], + "slug": "11-heroicverse-omni", "title": "Level 11 (HEROIC VERSE Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18595,9 +20504,10 @@ "inactive": true, "legacyFolderID": "F96a8ddcf5374385486ebe599ad3eef234cbf1eba0a19c73071de6cdd1afc6f66", "searchTerms": [], + "slug": "11-heroicverse", "title": "Level 11 (HEROIC VERSE)", - "where": "chart.level = '11'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18605,9 +20515,10 @@ "inactive": true, "legacyFolderID": "F2c9c27a83930d40666cbdd81af9d5ad707fdad3fa9463992a0f8b7e3179a6a04", "searchTerms": [], + "slug": "11-11th-cs", "title": "Level 11 (IIDX RED CS)", - "where": "chart.level = '11'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18615,9 +20526,10 @@ "inactive": false, "legacyFolderID": "Fc9789a84430d1a299c711cf4a27feff8ed164a74a54fc954e3a20b337cd85f67", "searchTerms": [], + "slug": "11-inf", "title": "Level 11 (INFINITAS)", - "where": "chart.level = '11'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18625,9 +20537,10 @@ "inactive": true, "legacyFolderID": "Fb0336c668b08f210b3cce8393627ecd466c94a2610e86c9ab5ae3c474d00a9f4", "searchTerms": [], + "slug": "11-pendual", "title": "Level 11 (PENDUAL)", - "where": "chart.level = '11'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18635,9 +20548,10 @@ "inactive": false, "legacyFolderID": "F3e767db6448dac5c48b7df3aff0ebeaf7e5dc3a261aa6822691b938d0dcbd7c2", "searchTerms": [], + "slug": "11-pinkycrush-omni", "title": "Level 11 (Pinky Crush Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18645,9 +20559,10 @@ "inactive": false, "legacyFolderID": "F57b065b790f15c0ff80b1f608f2ce470a380788d1a66c555e26c8eb5ffd72130", "searchTerms": [], + "slug": "11-pinkycrush", "title": "Level 11 (Pinky Crush)", - "where": "chart.level = '11'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18655,9 +20570,10 @@ "inactive": true, "legacyFolderID": "F412ec5dc3d521cce489e3804ec2d660f1fcd9bfcebea5223d908f3562bae2622", "searchTerms": [], + "slug": "11-resident-omni", "title": "Level 11 (Resident Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18665,9 +20581,10 @@ "inactive": true, "legacyFolderID": "F133264d22c30a31e04d08a1017219fd3c7c5e5d2d775547e3a659f18ac403efa", "searchTerms": [], + "slug": "11-resident", "title": "Level 11 (Resident)", - "where": "chart.level = '11'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18675,9 +20592,10 @@ "inactive": true, "legacyFolderID": "Ff9dab5c7880bcb7013b0706d6a0ae2daac5bdff8729ef082f2b5420a766d80e2", "searchTerms": [], + "slug": "11-rootage-omni", "title": "Level 11 (ROOTAGE Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18685,9 +20603,10 @@ "inactive": true, "legacyFolderID": "Fc7e3f79af193f70d4eb7dfd9149a2b4d8014da68d00536e4ea7c4477dadb87d6", "searchTerms": [], + "slug": "11-rootage", "title": "Level 11 (ROOTAGE)", - "where": "chart.level = '11'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18695,9 +20614,10 @@ "inactive": true, "legacyFolderID": "F887df4b017d08b2dc9481e9792faf5cd558cc4283b3f50d02ca554e525b18eb1", "searchTerms": [], + "slug": "11-sinobuz", "title": "Level 11 (SINOBUZ)", - "where": "chart.level = '11'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18705,9 +20625,10 @@ "inactive": true, "legacyFolderID": "F402eafd323816bbc92de8c214983ad8120ce6f9733666e7be8701f45e1584f47", "searchTerms": [], + "slug": "11-spada", "title": "Level 11 (SPADA)", - "where": "chart.level = '11'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18715,9 +20636,10 @@ "inactive": false, "legacyFolderID": "F10bbea98c9a6b1cb7a25e3d43a7a9233a08baa0fe1ddf2ec55ecc2ae6a3ea62d", "searchTerms": [], + "slug": "11-sparkleshower", "title": "Level 11 (Sparkle Shower)", - "where": "chart.level = '11'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18725,9 +20647,10 @@ "inactive": true, "legacyFolderID": "Fc91229ef7acc067a7b6fc499f5e2f16c831b11187e961a8029e7a2d4bc55c7b5", "searchTerms": [], + "slug": "11-tricoro", "title": "Level 11 (tricoro)", - "where": "chart.level = '11'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '11'" }, { "game": "iidx-sp", @@ -18735,9 +20658,10 @@ "inactive": true, "legacyFolderID": "Ff4fbeabf754aaa2b78142f608dad50e098c45f67eb0af98e7422aa25bc3143bf", "searchTerms": [], + "slug": "12-10th-cs", "title": "Level 12 (10th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18745,9 +20669,10 @@ "inactive": true, "legacyFolderID": "F1ab78e9c9025fe58658591a13d1391c78143920e2c115ed0f8fde3449bf0837d", "searchTerms": [], + "slug": "12-3rd-cs", "title": "Level 12 (3rd Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18755,9 +20680,10 @@ "inactive": true, "legacyFolderID": "F91d7680baf9c9e8882cc400ff21c7b9524fbdf0a7c5c8e45829f531b4005395e", "searchTerms": [], + "slug": "12-4th-cs", "title": "Level 12 (4th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18765,9 +20691,10 @@ "inactive": true, "legacyFolderID": "Fc4e1c155a700a464b4019b77edcbaddc7edfe714f5c1ca971e29e6bd508c8214", "searchTerms": [], + "slug": "12-5th-cs", "title": "Level 12 (5th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18775,9 +20702,10 @@ "inactive": true, "legacyFolderID": "F6b4320a08fd6fc15453a7af25c9d84ad430fb7bd1a13ffe415cda4e4d27ab9df", "searchTerms": [], + "slug": "12-6th-cs", "title": "Level 12 (6th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18785,9 +20713,10 @@ "inactive": true, "legacyFolderID": "F86d11d139bd4528fcd6f7cfcb6d60334ffc4aa661c1093ee468e2f5f1a7dcc8c", "searchTerms": [], + "slug": "12-7th-cs", "title": "Level 12 (7th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18795,9 +20724,10 @@ "inactive": true, "legacyFolderID": "F134a8c77f08b5bdebcce5d9aae2b3e885280260251102e0f80ef491e33d5d3f9", "searchTerms": [], + "slug": "12-8th-cs", "title": "Level 12 (8th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18805,9 +20735,10 @@ "inactive": true, "legacyFolderID": "Fb0deff82a2af8d1af0e2e9387223fca71116b5c4ba0db5a7a043e995cb20cf0a", "searchTerms": [], + "slug": "12-9th-cs", "title": "Level 12 (9th Style CS)", - "where": "chart.level = '12'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18815,9 +20746,10 @@ "inactive": true, "legacyFolderID": "Fd4ed086b89e00f724319dff8576a93e12badf3e322fccb5d6a0f4bcf209c76a6", "searchTerms": [], + "slug": "12-bmus", "title": "Level 12 (BEATMANIA US)", - "where": "chart.level = '12'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18825,9 +20757,10 @@ "inactive": true, "legacyFolderID": "Fc1a389b0372564b5a776b31ea2eb0ebef233be51e66f379213f5a9467abe3aa2", "searchTerms": [], + "slug": "12-bistrover-omni", "title": "Level 12 (BISTROVER Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18835,9 +20768,10 @@ "inactive": true, "legacyFolderID": "F5d92313eaa5777def84ebe24e9b95ae0ad98cc189e0b0edbfb719bd0769ba23e", "searchTerms": [], + "slug": "12-bistrover", "title": "Level 12 (BISTROVER)", - "where": "chart.level = '12'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18845,9 +20779,10 @@ "inactive": true, "legacyFolderID": "F4df32b502804b97559b3a4987d5e8194232af3efe1d773216910d20299a9ab64", "searchTerms": [], + "slug": "12-cannonballers", "title": "Level 12 (CANNON BALLERS)", - "where": "chart.level = '12'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18855,9 +20790,10 @@ "inactive": true, "legacyFolderID": "Fb997c0c2ee9e2e98fd19c183ef9b8c80b9b0345a226d1824b4d544b0dada2ef0", "searchTerms": [], + "slug": "12-casthour-omni", "title": "Level 12 (CastHour Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18865,9 +20801,10 @@ "inactive": true, "legacyFolderID": "F4641845dcaacfc772a35291197128d6958a0b2551d0831ea1ab6bf2901bad44a", "searchTerms": [], + "slug": "12-casthour", "title": "Level 12 (CastHour)", - "where": "chart.level = '12'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18875,9 +20812,10 @@ "inactive": true, "legacyFolderID": "F5466ddcb9975b35f188bca45e9d53361b411d01e889b3d0d914a8befb97f15e5", "searchTerms": [], + "slug": "12-copula", "title": "Level 12 (copula)", - "where": "chart.level = '12'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18885,9 +20823,10 @@ "inactive": true, "legacyFolderID": "Fd06fae87a4f2b426a0bb3a3f1440a33909ba72cb8f9e9c905fbf16f90da0becf", "searchTerms": [], + "slug": "12-13th-cs", "title": "Level 12 (DISTORTED CS)", - "where": "chart.level = '12'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18895,9 +20834,10 @@ "inactive": true, "legacyFolderID": "Ffdb9b6924c7a3ea85e7f153d4ae0a4cc96bff7ec43fdcc2e5c5e937b71839a6a", "searchTerms": [], + "slug": "12-15th-cs", "title": "Level 12 (DJ TROOPERS CS)", - "where": "chart.level = '12'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18905,9 +20845,10 @@ "inactive": true, "legacyFolderID": "F5983e0c3ef74e970481849c3beb8a18337b11a87c6e96a9293a3dc7395c8520b", "searchTerms": [], + "slug": "12-16th-cs", "title": "Level 12 (EMPRESS CS)", - "where": "chart.level = '12'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18915,9 +20856,10 @@ "inactive": true, "legacyFolderID": "F6266050bc50393bb0a88e7a80b57c800efeaca437f1e95efd08694348d77165f", "searchTerms": [], + "slug": "12-epolis-omni", "title": "Level 12 (Epolis Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18925,9 +20867,10 @@ "inactive": true, "legacyFolderID": "F2b0542125b7def38e87972d7016c11d8751f51671be778669769417cd4c74204", "searchTerms": [], + "slug": "12-epolis", "title": "Level 12 (EPOLIS)", - "where": "chart.level = '12'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18935,9 +20878,10 @@ "inactive": true, "legacyFolderID": "F1c15d2ddbd3dad1d50d06947bd31aa2ea508fd48327f4ac5d92207398fa1f3a6", "searchTerms": [], + "slug": "12-14th-cs", "title": "Level 12 (GOLD CS)", - "where": "chart.level = '12'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18945,9 +20889,10 @@ "inactive": true, "legacyFolderID": "F153d3092abbca947d506b52eddad4a69e05b4d3e29b53a0db50a12a5c41a0945", "searchTerms": [], + "slug": "12-12th-cs", "title": "Level 12 (HAPPY SKY CS)", - "where": "chart.level = '12'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18955,9 +20900,10 @@ "inactive": true, "legacyFolderID": "Fd82a46644e301d9f436a82d9b257c3773d55a76ef2a0f836b266355dd186d01b", "searchTerms": [], + "slug": "12-heroicverse-2dxtra", "title": "Level 12 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '12'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18965,9 +20911,10 @@ "inactive": true, "legacyFolderID": "F16edbf13b022f132c6e959989bb68b8638cf758b7c0d2bbe71e474032c410395", "searchTerms": [], + "slug": "12-heroicverse-omni", "title": "Level 12 (HEROIC VERSE Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18975,9 +20922,10 @@ "inactive": true, "legacyFolderID": "F15e18af44e5f244abbdf99ccdbe11973ce223518beff8a9bda95d7b759c215d2", "searchTerms": [], + "slug": "12-heroicverse", "title": "Level 12 (HEROIC VERSE)", - "where": "chart.level = '12'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18985,9 +20933,10 @@ "inactive": true, "legacyFolderID": "F04a8cc5f02393b66e82cb491803fc8ef113e05c3f9f6119be3359f1066a646fe", "searchTerms": [], + "slug": "12-11th-cs", "title": "Level 12 (IIDX RED CS)", - "where": "chart.level = '12'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -18995,9 +20944,10 @@ "inactive": false, "legacyFolderID": "F2c8a415a89a2f9c2be999a5d97eec6540817a47f6e873c90027d8d91e965f845", "searchTerms": [], + "slug": "12-inf", "title": "Level 12 (INFINITAS)", - "where": "chart.level = '12'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19005,9 +20955,10 @@ "inactive": true, "legacyFolderID": "F3473606f6d99a25a5f270540a99212c681cd1561f7030cbda346cd496e2069f9", "searchTerms": [], + "slug": "12-pendual", "title": "Level 12 (PENDUAL)", - "where": "chart.level = '12'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19015,9 +20966,10 @@ "inactive": false, "legacyFolderID": "F0931e5c31673322dadcf0e21d01a152347d50560081083f9389bf4893de74389", "searchTerms": [], + "slug": "12-pinkycrush-omni", "title": "Level 12 (Pinky Crush Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19025,9 +20977,10 @@ "inactive": false, "legacyFolderID": "F9792eaf29418651dd05d6b6a9b98d470d3b3b637247c65237d8344ba662517f4", "searchTerms": [], + "slug": "12-pinkycrush", "title": "Level 12 (Pinky Crush)", - "where": "chart.level = '12'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19035,9 +20988,10 @@ "inactive": true, "legacyFolderID": "F2b886412954844875e7dc96de42c8ff11703344436fb580e3b3ff76b3d7424ff", "searchTerms": [], + "slug": "12-resident-omni", "title": "Level 12 (Resident Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19045,9 +20999,10 @@ "inactive": true, "legacyFolderID": "Ff922056a52212d7e1be2a37a13dcbb886ba46ae34f89c981241a3d982dad834f", "searchTerms": [], + "slug": "12-resident", "title": "Level 12 (Resident)", - "where": "chart.level = '12'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19055,9 +21010,10 @@ "inactive": true, "legacyFolderID": "Fa772aa76a574f1c18f0edf4410d7a4dfeda6aaadf45124c8e1421b82df5f64e3", "searchTerms": [], + "slug": "12-rootage-omni", "title": "Level 12 (ROOTAGE Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19065,9 +21021,10 @@ "inactive": true, "legacyFolderID": "F27c85ea655d1667061db967834efb749728a4793e69c1bfdfd306ab2ca1ea8cd", "searchTerms": [], + "slug": "12-rootage", "title": "Level 12 (ROOTAGE)", - "where": "chart.level = '12'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19075,9 +21032,10 @@ "inactive": true, "legacyFolderID": "Fb80fbd4da1b0ace722ce66218431282cde9846fc16b1453b37dc02d5fbe0bdad", "searchTerms": [], + "slug": "12-sinobuz", "title": "Level 12 (SINOBUZ)", - "where": "chart.level = '12'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19085,9 +21043,10 @@ "inactive": true, "legacyFolderID": "F2687152637411fb91c487a50ab13c27f1e6f8226461a03bd0a46ecf603dc6360", "searchTerms": [], + "slug": "12-spada", "title": "Level 12 (SPADA)", - "where": "chart.level = '12'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19095,9 +21054,10 @@ "inactive": false, "legacyFolderID": "F54097f009126cb586a0c640ad4fe4e9368560416fd5beae44d0a369565788df5", "searchTerms": [], + "slug": "12-sparkleshower", "title": "Level 12 (Sparkle Shower)", - "where": "chart.level = '12'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19105,9 +21065,10 @@ "inactive": true, "legacyFolderID": "F3d3ee03f2064c75b0ed3413a3b90d71a1547119170854bec9c5d3bc17d0f2379", "searchTerms": [], + "slug": "12-tricoro", "title": "Level 12 (tricoro)", - "where": "chart.level = '12'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '12'" }, { "game": "iidx-sp", @@ -19115,9 +21076,10 @@ "inactive": true, "legacyFolderID": "F49a9cd7de77e522f6d97a922897f165fb796ad229a37f45bb3d79945a51269e4", "searchTerms": [], + "slug": "2-10th-cs", "title": "Level 2 (10th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19125,9 +21087,10 @@ "inactive": true, "legacyFolderID": "F4b4ebd471eea51a8ef9ef59ce732d06e0aa212da6372a4d66a096b6ed30d0f6a", "searchTerms": [], + "slug": "2-3rd-cs", "title": "Level 2 (3rd Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19135,9 +21098,10 @@ "inactive": true, "legacyFolderID": "F68fc134e0ce39fbad19038093332729bd724a1a21785e6482fa688cfeeb7d97e", "searchTerms": [], + "slug": "2-4th-cs", "title": "Level 2 (4th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19145,9 +21109,10 @@ "inactive": true, "legacyFolderID": "Faec066572b059ee6d374905fac7950cd0e51bd171d1f4815e3d1e033506ea359", "searchTerms": [], + "slug": "2-5th-cs", "title": "Level 2 (5th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19155,9 +21120,10 @@ "inactive": true, "legacyFolderID": "Fb0deb61e78f2228b922dde883cc26484b27dd9d2f61da77a9e4b725666fe427b", "searchTerms": [], + "slug": "2-6th-cs", "title": "Level 2 (6th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19165,9 +21131,10 @@ "inactive": true, "legacyFolderID": "Fe9fc7da6b9b659bcc77b06075c8b0b5f92b708d0f3293fba14393f7a8f794360", "searchTerms": [], + "slug": "2-7th-cs", "title": "Level 2 (7th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19175,9 +21142,10 @@ "inactive": true, "legacyFolderID": "F369550e7af389a714aefca0cf17846c71010805698122330588d83a65d56185f", "searchTerms": [], + "slug": "2-8th-cs", "title": "Level 2 (8th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19185,9 +21153,10 @@ "inactive": true, "legacyFolderID": "F942b1dc10c65f9a04ef348942ce940638f08ac31698aa4cfc0843dcc74733b5a", "searchTerms": [], + "slug": "2-9th-cs", "title": "Level 2 (9th Style CS)", - "where": "chart.level = '2'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19195,9 +21164,10 @@ "inactive": true, "legacyFolderID": "F1a3a62275fec4dca5139d51fcada8cac7686f0f742d52e5accb91bd2db3d6e81", "searchTerms": [], + "slug": "2-bmus", "title": "Level 2 (BEATMANIA US)", - "where": "chart.level = '2'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19205,9 +21175,10 @@ "inactive": true, "legacyFolderID": "F7203cf2e33b8c9bd57dee504c84723b1cd3a41d0f23eb9ca36f6a958a9878239", "searchTerms": [], + "slug": "2-bistrover-omni", "title": "Level 2 (BISTROVER Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19215,9 +21186,10 @@ "inactive": true, "legacyFolderID": "Fe0c707b8fbe896ccb73766bffdf828758e0071aad38c93796d7406bdf34038e8", "searchTerms": [], + "slug": "2-bistrover", "title": "Level 2 (BISTROVER)", - "where": "chart.level = '2'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19225,9 +21197,10 @@ "inactive": true, "legacyFolderID": "F04725858e0175e0fef4348759e0813d168e8b973c74ed934cd7b71afe4b5beed", "searchTerms": [], + "slug": "2-cannonballers", "title": "Level 2 (CANNON BALLERS)", - "where": "chart.level = '2'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19235,9 +21208,10 @@ "inactive": true, "legacyFolderID": "F3966599dbd3f3a1ab8d07c1cfeb6eea7e0b5f415e529e1842af80409b79dd5bd", "searchTerms": [], + "slug": "2-casthour-omni", "title": "Level 2 (CastHour Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19245,9 +21219,10 @@ "inactive": true, "legacyFolderID": "Fc70b831a128ec65509903180ef080015ca89aecb023530bdcd0daf6bbd07b4dc", "searchTerms": [], + "slug": "2-casthour", "title": "Level 2 (CastHour)", - "where": "chart.level = '2'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19255,9 +21230,10 @@ "inactive": true, "legacyFolderID": "F2c6dc648f7a632b1586dda6612bdd6be503961e69d7270efbb8baa69b25f11a3", "searchTerms": [], + "slug": "2-copula", "title": "Level 2 (copula)", - "where": "chart.level = '2'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19265,9 +21241,10 @@ "inactive": true, "legacyFolderID": "F8f74dfd6bb2e75050c9274c68d8f8a81e2513370c599de2a5db513fe6c99ee22", "searchTerms": [], + "slug": "2-13th-cs", "title": "Level 2 (DISTORTED CS)", - "where": "chart.level = '2'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19275,9 +21252,10 @@ "inactive": true, "legacyFolderID": "Ffda725209670312d0567d38e5f8fb2ee4129f7372abf1878efefe908a0a8c8f4", "searchTerms": [], + "slug": "2-15th-cs", "title": "Level 2 (DJ TROOPERS CS)", - "where": "chart.level = '2'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19285,9 +21263,10 @@ "inactive": true, "legacyFolderID": "F065fa3ff8ef5d6c9de444e7aae5232cc3e670fab7cd626811ee6ce063e5d4d7a", "searchTerms": [], + "slug": "2-16th-cs", "title": "Level 2 (EMPRESS CS)", - "where": "chart.level = '2'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19295,9 +21274,10 @@ "inactive": true, "legacyFolderID": "Fdc44254cb16ad94b0714d4a9e69dd7a07aa06b65d03bf685452e5effd1f6c5a4", "searchTerms": [], + "slug": "2-epolis-omni", "title": "Level 2 (Epolis Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19305,9 +21285,10 @@ "inactive": true, "legacyFolderID": "Fec975ebc2bd28de6cc6d12174e8825963f52596534d72a5eed73ca5283622439", "searchTerms": [], + "slug": "2-epolis", "title": "Level 2 (EPOLIS)", - "where": "chart.level = '2'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19315,9 +21296,10 @@ "inactive": true, "legacyFolderID": "Fb80de22bbd3ae7602c516b0e50595bba7f5a837eb1eb4cb4e67506b70f9d4f0a", "searchTerms": [], + "slug": "2-14th-cs", "title": "Level 2 (GOLD CS)", - "where": "chart.level = '2'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19325,9 +21307,10 @@ "inactive": true, "legacyFolderID": "Fa7682c57afb47799ef84bd1a2a6ea2c766650e2acadae96cd0e641c3a93698ee", "searchTerms": [], + "slug": "2-12th-cs", "title": "Level 2 (HAPPY SKY CS)", - "where": "chart.level = '2'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19335,9 +21318,10 @@ "inactive": true, "legacyFolderID": "Ffbdebe150d3c50e8c1f1f093859217c176313363df261f5c2448838a7618bd28", "searchTerms": [], + "slug": "2-heroicverse-2dxtra", "title": "Level 2 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '2'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19345,9 +21329,10 @@ "inactive": true, "legacyFolderID": "F649d4109689abfd12109a17f895bd279a0648cca55d99ed5264b90798d296956", "searchTerms": [], + "slug": "2-heroicverse-omni", "title": "Level 2 (HEROIC VERSE Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19355,9 +21340,10 @@ "inactive": true, "legacyFolderID": "Fbe976e04ba31601f0727716060397e82890eb696d82d9061a5b6bbc43d1c7923", "searchTerms": [], + "slug": "2-heroicverse", "title": "Level 2 (HEROIC VERSE)", - "where": "chart.level = '2'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19365,9 +21351,10 @@ "inactive": true, "legacyFolderID": "F5ed5afd6405820688020eec1d90b5671f807acebaecf69ba9f3d1472e156c4f1", "searchTerms": [], + "slug": "2-11th-cs", "title": "Level 2 (IIDX RED CS)", - "where": "chart.level = '2'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19375,9 +21362,10 @@ "inactive": false, "legacyFolderID": "F2cbab285fb20dbf8926aa081c6091a3f4fbc84fbd91e688a3f4ad5e231f14a7d", "searchTerms": [], + "slug": "2-inf", "title": "Level 2 (INFINITAS)", - "where": "chart.level = '2'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19385,9 +21373,10 @@ "inactive": true, "legacyFolderID": "F8817af52c94889db4b4aa506ce39bde7bdcddf6a042e90f6703252c5a6a24be5", "searchTerms": [], + "slug": "2-pendual", "title": "Level 2 (PENDUAL)", - "where": "chart.level = '2'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19395,9 +21384,10 @@ "inactive": false, "legacyFolderID": "F7899766fea7350ae34e7b3f630078da2e459b16cb85edefbb1e1ac9ef5042ae3", "searchTerms": [], + "slug": "2-pinkycrush-omni", "title": "Level 2 (Pinky Crush Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19405,9 +21395,10 @@ "inactive": false, "legacyFolderID": "Fc08cb877c833790324ccb0afac9bca1dc88d4d46f105826bf8ff4bbb59aedc74", "searchTerms": [], + "slug": "2-pinkycrush", "title": "Level 2 (Pinky Crush)", - "where": "chart.level = '2'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19415,9 +21406,10 @@ "inactive": true, "legacyFolderID": "F91865a3cdebfb5d54a276d1efb8953bdd74229deed086051eee979102e1244f9", "searchTerms": [], + "slug": "2-resident-omni", "title": "Level 2 (Resident Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19425,9 +21417,10 @@ "inactive": true, "legacyFolderID": "F9e8974e0908c0b2aa66a633e1fd87db4cd4a49de76eaa1c1dbbcbf4b289afd3d", "searchTerms": [], + "slug": "2-resident", "title": "Level 2 (Resident)", - "where": "chart.level = '2'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19435,9 +21428,10 @@ "inactive": true, "legacyFolderID": "F327d088f5332901ac22c301e1e365ab6e34955c708e867b82e1dcd1085a87515", "searchTerms": [], + "slug": "2-rootage-omni", "title": "Level 2 (ROOTAGE Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19445,9 +21439,10 @@ "inactive": true, "legacyFolderID": "Ffca7d0c9c2f696cb49ec25231c2c29831ae6c5b20d2dc4b6b56345bf03e98e3c", "searchTerms": [], + "slug": "2-rootage", "title": "Level 2 (ROOTAGE)", - "where": "chart.level = '2'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19455,9 +21450,10 @@ "inactive": true, "legacyFolderID": "Fb3e8a194a181840eb872618f43993c42957d37c17e35908746007a32d97dabbb", "searchTerms": [], + "slug": "2-sinobuz", "title": "Level 2 (SINOBUZ)", - "where": "chart.level = '2'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19465,9 +21461,10 @@ "inactive": true, "legacyFolderID": "Fbc80115e3b8b4ddded77fae6862a1053322c1dc8121bbe701eeb950996a8259e", "searchTerms": [], + "slug": "2-spada", "title": "Level 2 (SPADA)", - "where": "chart.level = '2'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19475,9 +21472,10 @@ "inactive": false, "legacyFolderID": "F9998d47390bdf77e57a1f948f9f67a5eb1f2771880334521e0d58645dab110d8", "searchTerms": [], + "slug": "2-sparkleshower", "title": "Level 2 (Sparkle Shower)", - "where": "chart.level = '2'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19485,9 +21483,10 @@ "inactive": true, "legacyFolderID": "F1e512811461894e6ba3f350094a0c2f45eecfbc46367073e43519305b8916bc2", "searchTerms": [], + "slug": "2-tricoro", "title": "Level 2 (tricoro)", - "where": "chart.level = '2'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '2'" }, { "game": "iidx-sp", @@ -19495,9 +21494,10 @@ "inactive": true, "legacyFolderID": "F00650675436cc503731f7ad2718c83c9019b807ad1051b5e6daba3a551714bbe", "searchTerms": [], + "slug": "3-10th-cs", "title": "Level 3 (10th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19505,9 +21505,10 @@ "inactive": true, "legacyFolderID": "Fa51e836f93849685bb372a12b3d2ed6d43cba69d27fae5e4ff3056e1b595f8c9", "searchTerms": [], + "slug": "3-3rd-cs", "title": "Level 3 (3rd Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19515,9 +21516,10 @@ "inactive": true, "legacyFolderID": "Fb970ce97b2dc0d48b2f13e8e214b5755737dcacb39598da4874ea6d20130b05c", "searchTerms": [], + "slug": "3-4th-cs", "title": "Level 3 (4th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19525,9 +21527,10 @@ "inactive": true, "legacyFolderID": "Fdc5980ab4ef192e52a6ca315d5ca528308cd617cca153b05381ede81c6f82c1c", "searchTerms": [], + "slug": "3-5th-cs", "title": "Level 3 (5th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19535,9 +21538,10 @@ "inactive": true, "legacyFolderID": "F27e785e86b70c5a4d68ec95e36006edec2a03e82b6feaeeac9f9def79a4fe3c5", "searchTerms": [], + "slug": "3-6th-cs", "title": "Level 3 (6th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19545,9 +21549,10 @@ "inactive": true, "legacyFolderID": "F97d24899b2b9a3636c583c42ae094de72a0378eb4f190623f35e25ffb8b559c0", "searchTerms": [], + "slug": "3-7th-cs", "title": "Level 3 (7th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19555,9 +21560,10 @@ "inactive": true, "legacyFolderID": "Ffda7db7bffd6004c37a98a6334ba8d92f5efb0418d5cbce2046eeb3c57d0fa85", "searchTerms": [], + "slug": "3-8th-cs", "title": "Level 3 (8th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19565,9 +21571,10 @@ "inactive": true, "legacyFolderID": "F3e140fe8d14d0c86ea91ff481898ca5182f646c9488fa77b4a143a56ae19ac7d", "searchTerms": [], + "slug": "3-9th-cs", "title": "Level 3 (9th Style CS)", - "where": "chart.level = '3'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19575,9 +21582,10 @@ "inactive": true, "legacyFolderID": "F43194401f8f2da1865d500cd4e94faab9e34cd9b3cfd31657c7254434de315df", "searchTerms": [], + "slug": "3-bmus", "title": "Level 3 (BEATMANIA US)", - "where": "chart.level = '3'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19585,9 +21593,10 @@ "inactive": true, "legacyFolderID": "F92a4f4b906afe8a87cc9456ce68c113282bbd7dbe747fc628943d861a2763c6d", "searchTerms": [], + "slug": "3-bistrover-omni", "title": "Level 3 (BISTROVER Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19595,9 +21604,10 @@ "inactive": true, "legacyFolderID": "F565aeb5534d2408630b32121e54817e9fcd8e70037515287d864aff25fe011dd", "searchTerms": [], + "slug": "3-bistrover", "title": "Level 3 (BISTROVER)", - "where": "chart.level = '3'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19605,9 +21615,10 @@ "inactive": true, "legacyFolderID": "F7607a6a7ffb013c6b42cfa6ba54ab441a125e01b8a55d0d9b1aecd45a2c5b180", "searchTerms": [], + "slug": "3-cannonballers", "title": "Level 3 (CANNON BALLERS)", - "where": "chart.level = '3'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19615,9 +21626,10 @@ "inactive": true, "legacyFolderID": "F7b8f6a9e69fc7d525cc3dd1ff2b8752e2da599ffd91dfcf1cbdda7737a598746", "searchTerms": [], + "slug": "3-casthour-omni", "title": "Level 3 (CastHour Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19625,9 +21637,10 @@ "inactive": true, "legacyFolderID": "Fd5355b99effb7110b25289cc5811bfdb6a67b2ed8740e31a3d21cdcd314c471a", "searchTerms": [], + "slug": "3-casthour", "title": "Level 3 (CastHour)", - "where": "chart.level = '3'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19635,9 +21648,10 @@ "inactive": true, "legacyFolderID": "F10b1d613752d9196fd4acd6376d0409a8cccd1d51ec0c829305ee848f04c0ca5", "searchTerms": [], + "slug": "3-copula", "title": "Level 3 (copula)", - "where": "chart.level = '3'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19645,9 +21659,10 @@ "inactive": true, "legacyFolderID": "F24826fe5f22521849926d805b08a7fb6cbf9f373732494d437863087319e4087", "searchTerms": [], + "slug": "3-13th-cs", "title": "Level 3 (DISTORTED CS)", - "where": "chart.level = '3'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19655,9 +21670,10 @@ "inactive": true, "legacyFolderID": "F695fab4c809486d97ae1ff68703339aa5c85ffffcef33dfbdee6b1bc0be447ae", "searchTerms": [], + "slug": "3-15th-cs", "title": "Level 3 (DJ TROOPERS CS)", - "where": "chart.level = '3'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19665,9 +21681,10 @@ "inactive": true, "legacyFolderID": "Fedd1ff4bef3706725a5ed10ce6fda4b27ca7a3ecad00de4d3ed9329c4fe19e25", "searchTerms": [], + "slug": "3-16th-cs", "title": "Level 3 (EMPRESS CS)", - "where": "chart.level = '3'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19675,9 +21692,10 @@ "inactive": true, "legacyFolderID": "F0be057a81f2e35336643032105b3ebc61abe95d92dc2d0e3a407b5a03b38f8a7", "searchTerms": [], + "slug": "3-epolis-omni", "title": "Level 3 (Epolis Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19685,9 +21703,10 @@ "inactive": true, "legacyFolderID": "F705f2fcc57ea4844e5698ae520cd5dc5ceb1e6fee24592e03dbead7560372f45", "searchTerms": [], + "slug": "3-epolis", "title": "Level 3 (EPOLIS)", - "where": "chart.level = '3'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19695,9 +21714,10 @@ "inactive": true, "legacyFolderID": "Faa285f1dc105d2fa3df03fe22f8596b8d1e4363b83727c7555dd94bde2bc7768", "searchTerms": [], + "slug": "3-14th-cs", "title": "Level 3 (GOLD CS)", - "where": "chart.level = '3'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19705,9 +21725,10 @@ "inactive": true, "legacyFolderID": "F1d61ac8103f133b2a3a5f43175b2be252213e200c8fe97101a09c47de810ba11", "searchTerms": [], + "slug": "3-12th-cs", "title": "Level 3 (HAPPY SKY CS)", - "where": "chart.level = '3'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19715,9 +21736,10 @@ "inactive": true, "legacyFolderID": "F8966e0f663b128aa699a31b8c03d41c2f5126a16007397751b45506f4cbf06b4", "searchTerms": [], + "slug": "3-heroicverse-2dxtra", "title": "Level 3 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '3'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19725,9 +21747,10 @@ "inactive": true, "legacyFolderID": "Fce90e3e364d6087f99f45814e0b32c4679cee40b67919014b7951783b80776eb", "searchTerms": [], + "slug": "3-heroicverse-omni", "title": "Level 3 (HEROIC VERSE Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19735,9 +21758,10 @@ "inactive": true, "legacyFolderID": "F13562cb98589e848c472830095099db44c1209edae5174ddd55dc54b1a74c153", "searchTerms": [], + "slug": "3-heroicverse", "title": "Level 3 (HEROIC VERSE)", - "where": "chart.level = '3'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19745,9 +21769,10 @@ "inactive": true, "legacyFolderID": "Fbd850bf13df147f895aeef1c1c4efec7c7b8409474c02e1227665e7fa539fd6d", "searchTerms": [], + "slug": "3-11th-cs", "title": "Level 3 (IIDX RED CS)", - "where": "chart.level = '3'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19755,9 +21780,10 @@ "inactive": false, "legacyFolderID": "Ff2d3a546f7c1d51d2ab66ef882da929090a9de6718b3df8e9eb7e4ee3da0d76c", "searchTerms": [], + "slug": "3-inf", "title": "Level 3 (INFINITAS)", - "where": "chart.level = '3'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19765,9 +21791,10 @@ "inactive": true, "legacyFolderID": "Fa36610fe060166e4d3f6501ef49e9f35eb8c680ba427da34c91ffd8b8e737d7f", "searchTerms": [], + "slug": "3-pendual", "title": "Level 3 (PENDUAL)", - "where": "chart.level = '3'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19775,9 +21802,10 @@ "inactive": false, "legacyFolderID": "Fe118f348ee709196e57074ae654fdb4f93de4f26c9f9447eafcd29525c249efc", "searchTerms": [], + "slug": "3-pinkycrush-omni", "title": "Level 3 (Pinky Crush Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19785,9 +21813,10 @@ "inactive": false, "legacyFolderID": "Fabda68ee64002b6f5df4438f85b90c664c92553aee52c69a181f9e1f3183ee0b", "searchTerms": [], + "slug": "3-pinkycrush", "title": "Level 3 (Pinky Crush)", - "where": "chart.level = '3'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19795,9 +21824,10 @@ "inactive": true, "legacyFolderID": "Fccce99caba93454ce72a0ed614bd411fc3ad92f18053c39ca0e8b593b4bf142b", "searchTerms": [], + "slug": "3-resident-omni", "title": "Level 3 (Resident Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19805,9 +21835,10 @@ "inactive": true, "legacyFolderID": "Fb90b54a097e5752c046361381045d40e5a588ca0ba8b5bc79ec33ceef7afe9a0", "searchTerms": [], + "slug": "3-resident", "title": "Level 3 (Resident)", - "where": "chart.level = '3'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19815,9 +21846,10 @@ "inactive": true, "legacyFolderID": "F8c51d7de607ae35c6165245fc149cf770d98866af08b688d45fc21606277bb13", "searchTerms": [], + "slug": "3-rootage-omni", "title": "Level 3 (ROOTAGE Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19825,9 +21857,10 @@ "inactive": true, "legacyFolderID": "F0867e4ff50dc23c479ca7ca80ec5382e29c3f12e5c876395ceac2a8627a8609d", "searchTerms": [], + "slug": "3-rootage", "title": "Level 3 (ROOTAGE)", - "where": "chart.level = '3'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19835,9 +21868,10 @@ "inactive": true, "legacyFolderID": "F2c8e64fd6867a762f05b16c8f1ce82e083bcdc93b254f68db58c2a1008f0c88e", "searchTerms": [], + "slug": "3-sinobuz", "title": "Level 3 (SINOBUZ)", - "where": "chart.level = '3'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19845,9 +21879,10 @@ "inactive": true, "legacyFolderID": "F95b2359d50fa34c23eeedf745133e214ad41736fc5e8178b5d7046a39d4f5fea", "searchTerms": [], + "slug": "3-spada", "title": "Level 3 (SPADA)", - "where": "chart.level = '3'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19855,9 +21890,10 @@ "inactive": false, "legacyFolderID": "F435953751febda524e554a9c2b4cfc544398f80628bdbd69844f6c290e250129", "searchTerms": [], + "slug": "3-sparkleshower", "title": "Level 3 (Sparkle Shower)", - "where": "chart.level = '3'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19865,9 +21901,10 @@ "inactive": true, "legacyFolderID": "F614315bd7ec65ef9e48a0050329a49a4caadc9251de022c4ef1de271f4a44f77", "searchTerms": [], + "slug": "3-tricoro", "title": "Level 3 (tricoro)", - "where": "chart.level = '3'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '3'" }, { "game": "iidx-sp", @@ -19875,9 +21912,10 @@ "inactive": true, "legacyFolderID": "F0ecb395a0527690ea8105a0a84db7464466c4ebb562adf2040ed77b7f20d4938", "searchTerms": [], + "slug": "4-10th-cs", "title": "Level 4 (10th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19885,9 +21923,10 @@ "inactive": true, "legacyFolderID": "F0d421ae3c51060f0da2067f50e9168c70fec121cef668802d932a0d3cca35384", "searchTerms": [], + "slug": "4-3rd-cs", "title": "Level 4 (3rd Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19895,9 +21934,10 @@ "inactive": true, "legacyFolderID": "F1c6cb7a648ad0db6ee0f716a96861668e2a3e5c767acbc5e3e7c86ea6743bd56", "searchTerms": [], + "slug": "4-4th-cs", "title": "Level 4 (4th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19905,9 +21945,10 @@ "inactive": true, "legacyFolderID": "F2f6c46501e2eecf4b3268a621d256313de535f2ddb965fd0f0941067da9d35bd", "searchTerms": [], + "slug": "4-5th-cs", "title": "Level 4 (5th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19915,9 +21956,10 @@ "inactive": true, "legacyFolderID": "F281c201f4d04817330924559c0de4a28d47fe6a7ffb97d6210e7c72c30c15336", "searchTerms": [], + "slug": "4-6th-cs", "title": "Level 4 (6th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19925,9 +21967,10 @@ "inactive": true, "legacyFolderID": "F26dbc9be094f2e3cbf02285c6249e1d8e3c167ae8eb7037730bf2a9043c0e24f", "searchTerms": [], + "slug": "4-7th-cs", "title": "Level 4 (7th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19935,9 +21978,10 @@ "inactive": true, "legacyFolderID": "Fa2eeb201634ba765f477275a7d0e23a609f84028ce78d0787e36863336d14e07", "searchTerms": [], + "slug": "4-8th-cs", "title": "Level 4 (8th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19945,9 +21989,10 @@ "inactive": true, "legacyFolderID": "F3f82a4448d92f404b8acc564ebcaeede9b1ff7dfdc0dd49b23d5078523b392af", "searchTerms": [], + "slug": "4-9th-cs", "title": "Level 4 (9th Style CS)", - "where": "chart.level = '4'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19955,9 +22000,10 @@ "inactive": true, "legacyFolderID": "F491d39eecd46164d7186052ccfc6066aa8d902b1036a53d942b05af01b51318a", "searchTerms": [], + "slug": "4-bmus", "title": "Level 4 (BEATMANIA US)", - "where": "chart.level = '4'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19965,9 +22011,10 @@ "inactive": true, "legacyFolderID": "F7c160c0bfc49235886a368cd79614ade877608fb62c709cc80f47fd05cfdb1d7", "searchTerms": [], + "slug": "4-bistrover-omni", "title": "Level 4 (BISTROVER Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19975,9 +22022,10 @@ "inactive": true, "legacyFolderID": "F916fed14d847b54be6dca112990fa6556d9f50a306260d90abe2000d1baf64e7", "searchTerms": [], + "slug": "4-bistrover", "title": "Level 4 (BISTROVER)", - "where": "chart.level = '4'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19985,9 +22033,10 @@ "inactive": true, "legacyFolderID": "Fa887294f56eedb6c82d9b23e485125f336fed3ee9a3f471d5d04fc44ea5cda57", "searchTerms": [], + "slug": "4-cannonballers", "title": "Level 4 (CANNON BALLERS)", - "where": "chart.level = '4'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -19995,9 +22044,10 @@ "inactive": true, "legacyFolderID": "F575067eb26b072b322b0f412f05d6f736750bb3eac98ad197771de8015961559", "searchTerms": [], + "slug": "4-casthour-omni", "title": "Level 4 (CastHour Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20005,9 +22055,10 @@ "inactive": true, "legacyFolderID": "F90919af86e48c407529d64be1384c124643a90ba40b6eedad9426fad9191209c", "searchTerms": [], + "slug": "4-casthour", "title": "Level 4 (CastHour)", - "where": "chart.level = '4'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20015,9 +22066,10 @@ "inactive": true, "legacyFolderID": "F7ac9cc5b9a7218db850ec1f8a442c62ed6bc649605fef16fe4cdfaf1d445a35d", "searchTerms": [], + "slug": "4-copula", "title": "Level 4 (copula)", - "where": "chart.level = '4'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20025,9 +22077,10 @@ "inactive": true, "legacyFolderID": "Fca357b94db8e3a9a97e0e5efe7c93c0271cc4804ba21104fc602a89cbdf0b511", "searchTerms": [], + "slug": "4-13th-cs", "title": "Level 4 (DISTORTED CS)", - "where": "chart.level = '4'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20035,9 +22088,10 @@ "inactive": true, "legacyFolderID": "Face888d04441d8d50086fff64e964a506842095c9af6f6f9cff92e08e53abb93", "searchTerms": [], + "slug": "4-15th-cs", "title": "Level 4 (DJ TROOPERS CS)", - "where": "chart.level = '4'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20045,9 +22099,10 @@ "inactive": true, "legacyFolderID": "F9b036672abe36b02122731a8f0c3ec96837276efea821a36934480d30955f17b", "searchTerms": [], + "slug": "4-16th-cs", "title": "Level 4 (EMPRESS CS)", - "where": "chart.level = '4'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20055,9 +22110,10 @@ "inactive": true, "legacyFolderID": "Fdcd6ecae79ad5ab1d269a69ea3a71b7139269119cf9cef33c7c1295724e9921e", "searchTerms": [], + "slug": "4-epolis-omni", "title": "Level 4 (Epolis Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20065,9 +22121,10 @@ "inactive": true, "legacyFolderID": "F6f44d23e0b72a4fec04d8cb2c7e7b059459e84d589e43675eb9e84607c63572b", "searchTerms": [], + "slug": "4-epolis", "title": "Level 4 (EPOLIS)", - "where": "chart.level = '4'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20075,9 +22132,10 @@ "inactive": true, "legacyFolderID": "F05c9c8393a3849cebe513254724485f9810439ea4fc071125fce35aca8410813", "searchTerms": [], + "slug": "4-14th-cs", "title": "Level 4 (GOLD CS)", - "where": "chart.level = '4'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20085,9 +22143,10 @@ "inactive": true, "legacyFolderID": "F09c60ae9bfecec2bd2cd1aff3bda1b4712dce9aeb9ac4e832b057412f46334df", "searchTerms": [], + "slug": "4-12th-cs", "title": "Level 4 (HAPPY SKY CS)", - "where": "chart.level = '4'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20095,9 +22154,10 @@ "inactive": true, "legacyFolderID": "F8e6b0d6eb956d5491c6385f3983f7f277a0624db6d35d022db17e0d93da16dbc", "searchTerms": [], + "slug": "4-heroicverse-2dxtra", "title": "Level 4 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '4'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20105,9 +22165,10 @@ "inactive": true, "legacyFolderID": "Fb73a16c9715034ee5a4d1cc1eb77e498a5c431b1bb9a2cc13b6785f395164594", "searchTerms": [], + "slug": "4-heroicverse-omni", "title": "Level 4 (HEROIC VERSE Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20115,9 +22176,10 @@ "inactive": true, "legacyFolderID": "Ff3003427b3caa46fdeb3c7dd98a5b930445158af1a309dcd24dbafbdd7397ae2", "searchTerms": [], + "slug": "4-heroicverse", "title": "Level 4 (HEROIC VERSE)", - "where": "chart.level = '4'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20125,9 +22187,10 @@ "inactive": true, "legacyFolderID": "F238474e895cd7681ef785a914c8cc264186f35a83a6d8e249c0f63cfb474015b", "searchTerms": [], + "slug": "4-11th-cs", "title": "Level 4 (IIDX RED CS)", - "where": "chart.level = '4'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20135,9 +22198,10 @@ "inactive": false, "legacyFolderID": "Feb49e15cbdd3d55f1466efabd65776dbd7895da923053218cc292b26746117ab", "searchTerms": [], + "slug": "4-inf", "title": "Level 4 (INFINITAS)", - "where": "chart.level = '4'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20145,9 +22209,10 @@ "inactive": true, "legacyFolderID": "Ff1e98422bf159dd76d721fc818dc4208b451594942f061732495507b24e98915", "searchTerms": [], + "slug": "4-pendual", "title": "Level 4 (PENDUAL)", - "where": "chart.level = '4'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20155,9 +22220,10 @@ "inactive": false, "legacyFolderID": "F0c1739ece3377be4e17303a556ffe8220aa2b78d4a0e1a4df1720230caffb57e", "searchTerms": [], + "slug": "4-pinkycrush-omni", "title": "Level 4 (Pinky Crush Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20165,9 +22231,10 @@ "inactive": false, "legacyFolderID": "F3b8224cb7b11dde3ca4db9fcdc2e368afbbd10605bf6de6d7674df1338708c44", "searchTerms": [], + "slug": "4-pinkycrush", "title": "Level 4 (Pinky Crush)", - "where": "chart.level = '4'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20175,9 +22242,10 @@ "inactive": true, "legacyFolderID": "F90adf2d5125d0d7550d991631e8c3d19b5d18a3b0627e0bcca68330808c9c085", "searchTerms": [], + "slug": "4-resident-omni", "title": "Level 4 (Resident Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20185,9 +22253,10 @@ "inactive": true, "legacyFolderID": "Fc551356526d26eae601714be6a43cdf13d8fedde52a648b6843cdaa2eade1f1a", "searchTerms": [], + "slug": "4-resident", "title": "Level 4 (Resident)", - "where": "chart.level = '4'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20195,9 +22264,10 @@ "inactive": true, "legacyFolderID": "F43f1d129c29c800ca753a36173c8d5a8386a915a710bf85c2ca4fc525ac8c33f", "searchTerms": [], + "slug": "4-rootage-omni", "title": "Level 4 (ROOTAGE Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20205,9 +22275,10 @@ "inactive": true, "legacyFolderID": "Fcca98d69d7f9d20ff3430f4146f371aedef6f31e87a46902e075e41f7da891e8", "searchTerms": [], + "slug": "4-rootage", "title": "Level 4 (ROOTAGE)", - "where": "chart.level = '4'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20215,9 +22286,10 @@ "inactive": true, "legacyFolderID": "Fcbc666b004b1b9afd3f1a9839a49b404a86e97ed0747313901adc543e800a2c1", "searchTerms": [], + "slug": "4-sinobuz", "title": "Level 4 (SINOBUZ)", - "where": "chart.level = '4'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20225,9 +22297,10 @@ "inactive": true, "legacyFolderID": "F84a800d2dffa4d790b8412c2b6f69d4820a517418ae1f535ea02dcacaafdf7f6", "searchTerms": [], + "slug": "4-spada", "title": "Level 4 (SPADA)", - "where": "chart.level = '4'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20235,9 +22308,10 @@ "inactive": false, "legacyFolderID": "F95af0fa33691cd756266b8505d1c2f0b92ef47e1270ead1d4d55392d9ff316de", "searchTerms": [], + "slug": "4-sparkleshower", "title": "Level 4 (Sparkle Shower)", - "where": "chart.level = '4'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20245,9 +22319,10 @@ "inactive": true, "legacyFolderID": "Fa3262625ffde2765ef9f081b26966d24a58a80b5a6ab9be6ef425768cf62f6a3", "searchTerms": [], + "slug": "4-tricoro", "title": "Level 4 (tricoro)", - "where": "chart.level = '4'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '4'" }, { "game": "iidx-sp", @@ -20255,9 +22330,10 @@ "inactive": true, "legacyFolderID": "F36c5a513d3503032465af03398fad8e7ba21c1243b6dcfdad967171ba4274ea7", "searchTerms": [], + "slug": "5-10th-cs", "title": "Level 5 (10th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20265,9 +22341,10 @@ "inactive": true, "legacyFolderID": "F9fff77ca46153357c1a75f63570b38e870574bad847d9e748c8452a1471990d1", "searchTerms": [], + "slug": "5-3rd-cs", "title": "Level 5 (3rd Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20275,9 +22352,10 @@ "inactive": true, "legacyFolderID": "F91255e4f5203f2693037671ccdb7740e09ac08694b56a739a3f2cc7c049f2e05", "searchTerms": [], + "slug": "5-4th-cs", "title": "Level 5 (4th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20285,9 +22363,10 @@ "inactive": true, "legacyFolderID": "F455c80caa2bbf848ef16d4ffe312e2786929df6d24a0ea33a6b08558d551e12b", "searchTerms": [], + "slug": "5-5th-cs", "title": "Level 5 (5th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20295,9 +22374,10 @@ "inactive": true, "legacyFolderID": "F849e031c22f3b98ed5395bedeb05102f174697365d9d4bbd6142eef6e937815c", "searchTerms": [], + "slug": "5-6th-cs", "title": "Level 5 (6th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20305,9 +22385,10 @@ "inactive": true, "legacyFolderID": "F1a391dbcadc3e1c3dac410dd9c83f17552e278a101775c94213e615f76850185", "searchTerms": [], + "slug": "5-7th-cs", "title": "Level 5 (7th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20315,9 +22396,10 @@ "inactive": true, "legacyFolderID": "F38a5662322842e26305963ff2fcde9a7cb9c9cd246f58324ea8daeb9a21e41ee", "searchTerms": [], + "slug": "5-8th-cs", "title": "Level 5 (8th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20325,9 +22407,10 @@ "inactive": true, "legacyFolderID": "F119fc3d710d295e03639d81ef67c081ce4cb5d134330ae98ddac57fadfc6de5c", "searchTerms": [], + "slug": "5-9th-cs", "title": "Level 5 (9th Style CS)", - "where": "chart.level = '5'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20335,9 +22418,10 @@ "inactive": true, "legacyFolderID": "F5c07878f3d0288113d4cbe5c76295c7384eba658542a869b17fb35d3457e913b", "searchTerms": [], + "slug": "5-bmus", "title": "Level 5 (BEATMANIA US)", - "where": "chart.level = '5'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20345,9 +22429,10 @@ "inactive": true, "legacyFolderID": "Fc8db47a2ad9b339bae9d59db59a334e43f97883bb1cd33d66dc543078bbc0bac", "searchTerms": [], + "slug": "5-bistrover-omni", "title": "Level 5 (BISTROVER Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20355,9 +22440,10 @@ "inactive": true, "legacyFolderID": "F4a2c86a524b5c40be19383d9ceb9ebb7954d7a0920d24c6466ac8169c3066ae9", "searchTerms": [], + "slug": "5-bistrover", "title": "Level 5 (BISTROVER)", - "where": "chart.level = '5'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20365,9 +22451,10 @@ "inactive": true, "legacyFolderID": "Fac35447c563824ee233f61f50f8d48c3e4e6c3dd2e9191fc614ea323ffd7dd34", "searchTerms": [], + "slug": "5-cannonballers", "title": "Level 5 (CANNON BALLERS)", - "where": "chart.level = '5'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20375,9 +22462,10 @@ "inactive": true, "legacyFolderID": "Fdc77449105a13a79bf168378968a9fd7e4dd65016368a7224a6f2afef87a2f36", "searchTerms": [], + "slug": "5-casthour-omni", "title": "Level 5 (CastHour Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20385,9 +22473,10 @@ "inactive": true, "legacyFolderID": "F4059ac0dfb11e27cbd71798f5f1482dabff9f57c220bb2236ac82f2ea79a7836", "searchTerms": [], + "slug": "5-casthour", "title": "Level 5 (CastHour)", - "where": "chart.level = '5'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20395,9 +22484,10 @@ "inactive": true, "legacyFolderID": "F03c176efec8553a28424fc0ac63f9848b12acdbfe2cbe890999454dce89fcbed", "searchTerms": [], + "slug": "5-copula", "title": "Level 5 (copula)", - "where": "chart.level = '5'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20405,9 +22495,10 @@ "inactive": true, "legacyFolderID": "Fa1c0cad73fc6b9e55f6e9e362ed64d1e84450936702cdcbda80d831dfdb1fb0a", "searchTerms": [], + "slug": "5-13th-cs", "title": "Level 5 (DISTORTED CS)", - "where": "chart.level = '5'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20415,9 +22506,10 @@ "inactive": true, "legacyFolderID": "F1eceb041b53b9815920a5c96426e99f99c61e4bfdf1dde5461e34b81ca774e91", "searchTerms": [], + "slug": "5-15th-cs", "title": "Level 5 (DJ TROOPERS CS)", - "where": "chart.level = '5'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20425,9 +22517,10 @@ "inactive": true, "legacyFolderID": "Ff8554fa2beb7aa966f8ae45e1a083568ebdf4c149ee1d20e93ce24304df1d964", "searchTerms": [], + "slug": "5-16th-cs", "title": "Level 5 (EMPRESS CS)", - "where": "chart.level = '5'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20435,9 +22528,10 @@ "inactive": true, "legacyFolderID": "F27b50d2715c17d27100a446488a22c35b735a250dcd2261bac280913e1c82d41", "searchTerms": [], + "slug": "5-epolis-omni", "title": "Level 5 (Epolis Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20445,9 +22539,10 @@ "inactive": true, "legacyFolderID": "F732113e45ebdede9bbd785aec4f602f529e8685ebcd7e6fa6f7e082967e47646", "searchTerms": [], + "slug": "5-epolis", "title": "Level 5 (EPOLIS)", - "where": "chart.level = '5'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20455,9 +22550,10 @@ "inactive": true, "legacyFolderID": "Ff1fa91be4a48dca2c92a3e76d98ce63e983e82b034bb27ad4c53c1d32164b3c5", "searchTerms": [], + "slug": "5-14th-cs", "title": "Level 5 (GOLD CS)", - "where": "chart.level = '5'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20465,9 +22561,10 @@ "inactive": true, "legacyFolderID": "F821c2bd4600e53ba6d26c918a96fe5bee08c93de924a4ecf127c27487d688952", "searchTerms": [], + "slug": "5-12th-cs", "title": "Level 5 (HAPPY SKY CS)", - "where": "chart.level = '5'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20475,9 +22572,10 @@ "inactive": true, "legacyFolderID": "Fcadf3f484fc0910b00094b72d079e669afadeb8835f4b4e31abe7d60acd88166", "searchTerms": [], + "slug": "5-heroicverse-2dxtra", "title": "Level 5 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '5'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20485,9 +22583,10 @@ "inactive": true, "legacyFolderID": "F55c45e9489f072b134e9ef936fbb245965b7ab6683320cc97a68e53058a29b74", "searchTerms": [], + "slug": "5-heroicverse-omni", "title": "Level 5 (HEROIC VERSE Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20495,9 +22594,10 @@ "inactive": true, "legacyFolderID": "Fd70857c704b48619ffcb549cc5d691791d50cc9b6e340b6fc3830b5abcdf2463", "searchTerms": [], + "slug": "5-heroicverse", "title": "Level 5 (HEROIC VERSE)", - "where": "chart.level = '5'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20505,9 +22605,10 @@ "inactive": true, "legacyFolderID": "Fddeed66592c3c4fca1e4112d235d7aabe97dcc4f295bc68c54a5236a0ffb64ef", "searchTerms": [], + "slug": "5-11th-cs", "title": "Level 5 (IIDX RED CS)", - "where": "chart.level = '5'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20515,9 +22616,10 @@ "inactive": false, "legacyFolderID": "Fcdc68220c65f6639fdb2c67c3b2f319887280a3dbeed517c15a104557813a94a", "searchTerms": [], + "slug": "5-inf", "title": "Level 5 (INFINITAS)", - "where": "chart.level = '5'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20525,9 +22627,10 @@ "inactive": true, "legacyFolderID": "F1658cab1ead0bdf269770fe35b9071ad40a6507995068f0d49faa5b0943bda7e", "searchTerms": [], + "slug": "5-pendual", "title": "Level 5 (PENDUAL)", - "where": "chart.level = '5'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20535,9 +22638,10 @@ "inactive": false, "legacyFolderID": "Fd15733d1f7ba07ba321c5aa57670eb821c29e3d70653e8024949a7daa18f9b97", "searchTerms": [], + "slug": "5-pinkycrush-omni", "title": "Level 5 (Pinky Crush Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20545,9 +22649,10 @@ "inactive": false, "legacyFolderID": "F7a3a7de4e067bd524ae191f6597923c692f30909708afe6f72bfc5c8dc6477d5", "searchTerms": [], + "slug": "5-pinkycrush", "title": "Level 5 (Pinky Crush)", - "where": "chart.level = '5'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20555,9 +22660,10 @@ "inactive": true, "legacyFolderID": "Fdf8c3ec3a65a21acdcfc2addd1d64b38e48fd627a0206ba42bdde6e96c83e3fc", "searchTerms": [], + "slug": "5-resident-omni", "title": "Level 5 (Resident Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20565,9 +22671,10 @@ "inactive": true, "legacyFolderID": "F05d9f5f9cc6081e0828131fbd363fd315f58fb0626755868ed8b4ded6db76ebc", "searchTerms": [], + "slug": "5-resident", "title": "Level 5 (Resident)", - "where": "chart.level = '5'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20575,9 +22682,10 @@ "inactive": true, "legacyFolderID": "Ff68fe8cbf634300fc5dfdd97648269661bd522f889fdac0e406f5b8dd43d564d", "searchTerms": [], + "slug": "5-rootage-omni", "title": "Level 5 (ROOTAGE Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20585,9 +22693,10 @@ "inactive": true, "legacyFolderID": "F6bfdbdb0155872045c5a7a3d0f3b0048d98a5db3c6267c7d2e0a65005bf7abc4", "searchTerms": [], + "slug": "5-rootage", "title": "Level 5 (ROOTAGE)", - "where": "chart.level = '5'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20595,9 +22704,10 @@ "inactive": true, "legacyFolderID": "F047e0928b4693d0ae972e3b48532d1b60065da7fd34d851be69cae3ec4440beb", "searchTerms": [], + "slug": "5-sinobuz", "title": "Level 5 (SINOBUZ)", - "where": "chart.level = '5'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20605,9 +22715,10 @@ "inactive": true, "legacyFolderID": "F62027b88d1ba147227970082883a38d0be091e86dc2b71985e57409891b1ae19", "searchTerms": [], + "slug": "5-spada", "title": "Level 5 (SPADA)", - "where": "chart.level = '5'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20615,9 +22726,10 @@ "inactive": false, "legacyFolderID": "Fde2cd74eebfe6f6002848e2b3904312e2a0a812250dc07944cd9c22972bc0361", "searchTerms": [], + "slug": "5-sparkleshower", "title": "Level 5 (Sparkle Shower)", - "where": "chart.level = '5'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20625,9 +22737,10 @@ "inactive": true, "legacyFolderID": "F286bfbfc1680f7f9a9b08e88e6e441581d7887873c238aed50cf0d6d675d5e33", "searchTerms": [], + "slug": "5-tricoro", "title": "Level 5 (tricoro)", - "where": "chart.level = '5'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '5'" }, { "game": "iidx-sp", @@ -20635,9 +22748,10 @@ "inactive": true, "legacyFolderID": "Ff0f44303857b4c63dd0e8884a2f6213724e09e5c2f62b97257ae5dc316a41fad", "searchTerms": [], + "slug": "6-10th-cs", "title": "Level 6 (10th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20645,9 +22759,10 @@ "inactive": true, "legacyFolderID": "Fbbd6edec4bb5bbeed6bef774deed4d50d2600bec5c2811e59705d68dd2205f2f", "searchTerms": [], + "slug": "6-3rd-cs", "title": "Level 6 (3rd Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20655,9 +22770,10 @@ "inactive": true, "legacyFolderID": "Fa405c1b1a9350eb9d38f18ad2c16f0ed0d64319347e1cf89d4b75640ef66e8a5", "searchTerms": [], + "slug": "6-4th-cs", "title": "Level 6 (4th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20665,9 +22781,10 @@ "inactive": true, "legacyFolderID": "Fa4e0908d452c4b577de0a314e9eb98629993abc0d361783a27d53ba940cb3467", "searchTerms": [], + "slug": "6-5th-cs", "title": "Level 6 (5th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20675,9 +22792,10 @@ "inactive": true, "legacyFolderID": "Fc7a9be700b013e5977441655a8829de7951b3e2705545b92398531e03dc6f603", "searchTerms": [], + "slug": "6-6th-cs", "title": "Level 6 (6th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20685,9 +22803,10 @@ "inactive": true, "legacyFolderID": "F9e52ade7fd6f395fc96a9551bc41794158c2be522efdf6f8aed86a8479ebc1d6", "searchTerms": [], + "slug": "6-7th-cs", "title": "Level 6 (7th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20695,9 +22814,10 @@ "inactive": true, "legacyFolderID": "Ff0e805a8922de88e149c46e0e99d220a9f4e0dcc233207cf4c4c3b7deae1d2a6", "searchTerms": [], + "slug": "6-8th-cs", "title": "Level 6 (8th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20705,9 +22825,10 @@ "inactive": true, "legacyFolderID": "F89951345ad929ddb6b2d51a010701e4ef53b022b5688a4a85109a8240dd7e4aa", "searchTerms": [], + "slug": "6-9th-cs", "title": "Level 6 (9th Style CS)", - "where": "chart.level = '6'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20715,9 +22836,10 @@ "inactive": true, "legacyFolderID": "F627dff5cbcc04bf8bde0024b207b905a851c1525cfde6a0ffb47fd2da3c3aef5", "searchTerms": [], + "slug": "6-bmus", "title": "Level 6 (BEATMANIA US)", - "where": "chart.level = '6'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20725,9 +22847,10 @@ "inactive": true, "legacyFolderID": "F8fa7cbe386e05b2f419d8bfe4b8d912eb72aca446dccd719f2bf0dce4e5df7cd", "searchTerms": [], + "slug": "6-bistrover-omni", "title": "Level 6 (BISTROVER Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20735,9 +22858,10 @@ "inactive": true, "legacyFolderID": "Fa1d30f3dddffb778bc3bb6331ffa3ce4f190f1319065f9de1b809e754bbe1703", "searchTerms": [], + "slug": "6-bistrover", "title": "Level 6 (BISTROVER)", - "where": "chart.level = '6'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20745,9 +22869,10 @@ "inactive": true, "legacyFolderID": "F34a2fb97002457b006507d608b2b4ebf8cf94b084d452acb6b94342d006eaa5b", "searchTerms": [], + "slug": "6-cannonballers", "title": "Level 6 (CANNON BALLERS)", - "where": "chart.level = '6'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20755,9 +22880,10 @@ "inactive": true, "legacyFolderID": "Fdd875f6ef90148dcc9d40e1069fcefc9c8c3f7e05a775f24e39775b556a0f87c", "searchTerms": [], + "slug": "6-casthour-omni", "title": "Level 6 (CastHour Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20765,9 +22891,10 @@ "inactive": true, "legacyFolderID": "F981aa071da24ecc7294bbba9097601f77b1758912970a5d47cd8905621067a5e", "searchTerms": [], + "slug": "6-casthour", "title": "Level 6 (CastHour)", - "where": "chart.level = '6'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20775,9 +22902,10 @@ "inactive": true, "legacyFolderID": "F153394fb75e83dc006f6d1324112fb51f461662f478f96a1a83841ccccc5113d", "searchTerms": [], + "slug": "6-copula", "title": "Level 6 (copula)", - "where": "chart.level = '6'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20785,9 +22913,10 @@ "inactive": true, "legacyFolderID": "F98a9d455679c15bdf4df1b0b40d67b6f332ad1c1d4d6ad30ebfff15161d5e03a", "searchTerms": [], + "slug": "6-13th-cs", "title": "Level 6 (DISTORTED CS)", - "where": "chart.level = '6'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20795,9 +22924,10 @@ "inactive": true, "legacyFolderID": "F5d198cee5f834415517449312b6bb48218f68673b6c20e6e02800f951da6d9d3", "searchTerms": [], + "slug": "6-15th-cs", "title": "Level 6 (DJ TROOPERS CS)", - "where": "chart.level = '6'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20805,9 +22935,10 @@ "inactive": true, "legacyFolderID": "Fcb758d49754f831b9af838d00495df69b2059f0f8da937e33f2d47f1b0066446", "searchTerms": [], + "slug": "6-16th-cs", "title": "Level 6 (EMPRESS CS)", - "where": "chart.level = '6'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20815,9 +22946,10 @@ "inactive": true, "legacyFolderID": "F0315bc5dece0b4786068ead0d52780e24db39fef1a5ba9794eb22e3a9227e966", "searchTerms": [], + "slug": "6-epolis-omni", "title": "Level 6 (Epolis Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20825,9 +22957,10 @@ "inactive": true, "legacyFolderID": "F51160a77a38a043628b0ad132429c465a0fd1eccefa1758deed23f02f844842f", "searchTerms": [], + "slug": "6-epolis", "title": "Level 6 (EPOLIS)", - "where": "chart.level = '6'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20835,9 +22968,10 @@ "inactive": true, "legacyFolderID": "F4b76ea4509bd723ea4b5a23ee84fc84e6d042a0d7342b89f6efefefbc19fdcfb", "searchTerms": [], + "slug": "6-14th-cs", "title": "Level 6 (GOLD CS)", - "where": "chart.level = '6'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20845,9 +22979,10 @@ "inactive": true, "legacyFolderID": "F03928f72ebb7fcd31f63ac16887fddd60b5af804f2f1fe12967c63f2e097f664", "searchTerms": [], + "slug": "6-12th-cs", "title": "Level 6 (HAPPY SKY CS)", - "where": "chart.level = '6'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20855,9 +22990,10 @@ "inactive": true, "legacyFolderID": "F695d510f9d600352880d44c4ba30d5048f2a2b8b5b1ca2729b1706c6b5d43aa5", "searchTerms": [], + "slug": "6-heroicverse-2dxtra", "title": "Level 6 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '6'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20865,9 +23001,10 @@ "inactive": true, "legacyFolderID": "Fe3205110b3b790eab9596719a9ce495a7bac2e042172cd135980d8b9a3cbcee8", "searchTerms": [], + "slug": "6-heroicverse-omni", "title": "Level 6 (HEROIC VERSE Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20875,9 +23012,10 @@ "inactive": true, "legacyFolderID": "F2b2b46935bf75782adcc4d5003c9e2dcbbf725c73d85cf5cceb01cb98da14317", "searchTerms": [], + "slug": "6-heroicverse", "title": "Level 6 (HEROIC VERSE)", - "where": "chart.level = '6'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20885,9 +23023,10 @@ "inactive": true, "legacyFolderID": "F68ff48d0700f9d4d71411aa1ef6264b16e9e8d123aadef4678f4c3536a8385ab", "searchTerms": [], + "slug": "6-11th-cs", "title": "Level 6 (IIDX RED CS)", - "where": "chart.level = '6'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20895,9 +23034,10 @@ "inactive": false, "legacyFolderID": "F70da524241b4a81306bb94801dacecacda71d448403700b4b18a41d909807100", "searchTerms": [], + "slug": "6-inf", "title": "Level 6 (INFINITAS)", - "where": "chart.level = '6'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20905,9 +23045,10 @@ "inactive": true, "legacyFolderID": "F6bb3bcd350572eb61821069decb7897cc84a29b577525565ce2b0dbfc933ad34", "searchTerms": [], + "slug": "6-pendual", "title": "Level 6 (PENDUAL)", - "where": "chart.level = '6'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20915,9 +23056,10 @@ "inactive": false, "legacyFolderID": "F071539c9beeb901add74407a23df40c897cd36f9572e90a3f2b42695cfdeb31a", "searchTerms": [], + "slug": "6-pinkycrush-omni", "title": "Level 6 (Pinky Crush Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20925,9 +23067,10 @@ "inactive": false, "legacyFolderID": "F0d502fb445f73dd3c432f4ab75a092a84131975b4bd851b071cafe215c74e149", "searchTerms": [], + "slug": "6-pinkycrush", "title": "Level 6 (Pinky Crush)", - "where": "chart.level = '6'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20935,9 +23078,10 @@ "inactive": true, "legacyFolderID": "F4ce32f0b4a2242dd2f26871db885d30c86f24cce356469ba4523282b582a4cb7", "searchTerms": [], + "slug": "6-resident-omni", "title": "Level 6 (Resident Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20945,9 +23089,10 @@ "inactive": true, "legacyFolderID": "Fdd8050f9477441aa6e231589b9dc82c5bc8afc586236759d97d1a789bc9574ec", "searchTerms": [], + "slug": "6-resident", "title": "Level 6 (Resident)", - "where": "chart.level = '6'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20955,9 +23100,10 @@ "inactive": true, "legacyFolderID": "F3190734ad9aa56b3c93d7fda1174557d18c954978d856f16e029fc1b5cb9de57", "searchTerms": [], + "slug": "6-rootage-omni", "title": "Level 6 (ROOTAGE Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20965,9 +23111,10 @@ "inactive": true, "legacyFolderID": "F29409360680b4faaff5ba80f02fc6c825b528804be2f41fb35cb7a9bebec3417", "searchTerms": [], + "slug": "6-rootage", "title": "Level 6 (ROOTAGE)", - "where": "chart.level = '6'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20975,9 +23122,10 @@ "inactive": true, "legacyFolderID": "F79fbcd92b24c48f3879b9e95ae3745c0b920a145dec981490840784ea0a6fc36", "searchTerms": [], + "slug": "6-sinobuz", "title": "Level 6 (SINOBUZ)", - "where": "chart.level = '6'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20985,9 +23133,10 @@ "inactive": true, "legacyFolderID": "Ff87a8f01620edd25e9fe3b24e76d3adaac0333e71450017f6cd3516de7c10b31", "searchTerms": [], + "slug": "6-spada", "title": "Level 6 (SPADA)", - "where": "chart.level = '6'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -20995,9 +23144,10 @@ "inactive": false, "legacyFolderID": "F06e5a7f403c37e967558c3a2d00b08bebf583750bf52948dc732aa453ccb7656", "searchTerms": [], + "slug": "6-sparkleshower", "title": "Level 6 (Sparkle Shower)", - "where": "chart.level = '6'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -21005,9 +23155,10 @@ "inactive": true, "legacyFolderID": "F88e07685f573bd5df966da4c607efefcf0972de3c55b86c4cb6114e65b371cc4", "searchTerms": [], + "slug": "6-tricoro", "title": "Level 6 (tricoro)", - "where": "chart.level = '6'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '6'" }, { "game": "iidx-sp", @@ -21015,9 +23166,10 @@ "inactive": true, "legacyFolderID": "Fe497e14d053f9ae8494649b323d7b64bad6e8e53c3060cf6646c23847ebc006a", "searchTerms": [], + "slug": "7-10th-cs", "title": "Level 7 (10th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21025,9 +23177,10 @@ "inactive": true, "legacyFolderID": "F0c2de69aa380ba809ecf152d25c3b268f19bf442adbf2d2ee0ea09a0771ab889", "searchTerms": [], + "slug": "7-3rd-cs", "title": "Level 7 (3rd Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21035,9 +23188,10 @@ "inactive": true, "legacyFolderID": "Fbaf84ad9e7ee09c935c48e54733939d955d16ee6ee5467f7654013939214f133", "searchTerms": [], + "slug": "7-4th-cs", "title": "Level 7 (4th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21045,9 +23199,10 @@ "inactive": true, "legacyFolderID": "F155fb204f1d462fd572587d4d060f8e98b2af3460205dc91b75b252cde5e98f6", "searchTerms": [], + "slug": "7-5th-cs", "title": "Level 7 (5th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21055,9 +23210,10 @@ "inactive": true, "legacyFolderID": "F84d43a8b41f3146dd20a75de4412e986ebd18562ac3a284892c77c7b46ab43b6", "searchTerms": [], + "slug": "7-6th-cs", "title": "Level 7 (6th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21065,9 +23221,10 @@ "inactive": true, "legacyFolderID": "F03ca87e215e3d9654fb8848d0dfe51e30d9577c2a5dcbaa389d567944fc35596", "searchTerms": [], + "slug": "7-7th-cs", "title": "Level 7 (7th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21075,9 +23232,10 @@ "inactive": true, "legacyFolderID": "F361ab166c7bda27ec1bc2c0e872c15716a34072438d6fda0fb1eb11681bf2111", "searchTerms": [], + "slug": "7-8th-cs", "title": "Level 7 (8th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21085,9 +23243,10 @@ "inactive": true, "legacyFolderID": "F383122e622644a0f5b6cd117396950da9709d23f6a9f9d6aea484001d80a29a2", "searchTerms": [], + "slug": "7-9th-cs", "title": "Level 7 (9th Style CS)", - "where": "chart.level = '7'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21095,9 +23254,10 @@ "inactive": true, "legacyFolderID": "F102876821c4692d541a0af32e56ced2998506849db623ab76fe6d43421b4ba45", "searchTerms": [], + "slug": "7-bmus", "title": "Level 7 (BEATMANIA US)", - "where": "chart.level = '7'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21105,9 +23265,10 @@ "inactive": true, "legacyFolderID": "F466a6032adb5caf89cda1d38f027135322b4b3c19088778f323db229db0564b2", "searchTerms": [], + "slug": "7-bistrover-omni", "title": "Level 7 (BISTROVER Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21115,9 +23276,10 @@ "inactive": true, "legacyFolderID": "F12c02fda2f8bb27fc6a4dddbd3fb537e7825f4a2a63814dedfdf152c31a88645", "searchTerms": [], + "slug": "7-bistrover", "title": "Level 7 (BISTROVER)", - "where": "chart.level = '7'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21125,9 +23287,10 @@ "inactive": true, "legacyFolderID": "Fb7ad66f641a1f1a02c43021df374937af77a43e4131727eeac53788dcde420c1", "searchTerms": [], + "slug": "7-cannonballers", "title": "Level 7 (CANNON BALLERS)", - "where": "chart.level = '7'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21135,9 +23298,10 @@ "inactive": true, "legacyFolderID": "F3f39a2e11d3f88cc81c0105bbf6481eb521b2c2607ed012ebd53932b5d1d2387", "searchTerms": [], + "slug": "7-casthour-omni", "title": "Level 7 (CastHour Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21145,9 +23309,10 @@ "inactive": true, "legacyFolderID": "F7958ccb2cfa095fcf4305b766f00074238c8afe37cae11793296a7e63b064873", "searchTerms": [], + "slug": "7-casthour", "title": "Level 7 (CastHour)", - "where": "chart.level = '7'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21155,9 +23320,10 @@ "inactive": true, "legacyFolderID": "Ffb1c9b14fbe827618d942902247f680513c856f9587d4801b5783c5281ce3c56", "searchTerms": [], + "slug": "7-copula", "title": "Level 7 (copula)", - "where": "chart.level = '7'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21165,9 +23331,10 @@ "inactive": true, "legacyFolderID": "F3de5a50c94c000f8c84f0fd0992cffcadb4c1648b18126e22d6e5bf063bc49b0", "searchTerms": [], + "slug": "7-13th-cs", "title": "Level 7 (DISTORTED CS)", - "where": "chart.level = '7'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21175,9 +23342,10 @@ "inactive": true, "legacyFolderID": "F39fcacb7820812b09d9e17f5a99edb1dcaec28ffece52e91d81dfa0fd9a5ee22", "searchTerms": [], + "slug": "7-15th-cs", "title": "Level 7 (DJ TROOPERS CS)", - "where": "chart.level = '7'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21185,9 +23353,10 @@ "inactive": true, "legacyFolderID": "Fbffc1384bb5a65496e34f01316cc8d752c29ec450fd7fba307d4eac138f77df0", "searchTerms": [], + "slug": "7-16th-cs", "title": "Level 7 (EMPRESS CS)", - "where": "chart.level = '7'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21195,9 +23364,10 @@ "inactive": true, "legacyFolderID": "Fbb09366ec528e2c6c87d79301fcdfeb763b85c961f1438437aca7676a20f514c", "searchTerms": [], + "slug": "7-epolis-omni", "title": "Level 7 (Epolis Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21205,9 +23375,10 @@ "inactive": true, "legacyFolderID": "Fc9269ab7a3b52690208795fb203a91bbfc79e969006adad4f5d3d0fdb1c606fc", "searchTerms": [], + "slug": "7-epolis", "title": "Level 7 (EPOLIS)", - "where": "chart.level = '7'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21215,9 +23386,10 @@ "inactive": true, "legacyFolderID": "Feb013d57b0fe8b858e328b373b458ba80ef1010383720acc5fe3969c73ca3783", "searchTerms": [], + "slug": "7-14th-cs", "title": "Level 7 (GOLD CS)", - "where": "chart.level = '7'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21225,9 +23397,10 @@ "inactive": true, "legacyFolderID": "Fa3fe62ec14a2355a89bbaac3fbfc328c8d2fa9904a421ad3b6f3e20359de15cb", "searchTerms": [], + "slug": "7-12th-cs", "title": "Level 7 (HAPPY SKY CS)", - "where": "chart.level = '7'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21235,9 +23408,10 @@ "inactive": true, "legacyFolderID": "F3871a4562a8273dce8f0513c95175b61833e0e509df9f6af2575c3d7d2bcbd3c", "searchTerms": [], + "slug": "7-heroicverse-2dxtra", "title": "Level 7 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '7'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21245,9 +23419,10 @@ "inactive": true, "legacyFolderID": "Fac29ea1e2463024ed9e1ce039d1bc9f1ab27a737ad14d2669223e106da2a03d9", "searchTerms": [], + "slug": "7-heroicverse-omni", "title": "Level 7 (HEROIC VERSE Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21255,9 +23430,10 @@ "inactive": true, "legacyFolderID": "Ff35034f2142defaefb96a034c884434f1382d3e21b1c9d7b09dadbbec08b2bb8", "searchTerms": [], + "slug": "7-heroicverse", "title": "Level 7 (HEROIC VERSE)", - "where": "chart.level = '7'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21265,9 +23441,10 @@ "inactive": true, "legacyFolderID": "F4341f6e13bd57efd2526b06a340eb7f00b3b8cf5a42ef16c15a046d3003d39e9", "searchTerms": [], + "slug": "7-11th-cs", "title": "Level 7 (IIDX RED CS)", - "where": "chart.level = '7'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21275,9 +23452,10 @@ "inactive": false, "legacyFolderID": "F96fb61bf82b5f1da4faacfc0c4306cfb8c454fd1cfe8b29a2ab88505ae2b119c", "searchTerms": [], + "slug": "7-inf", "title": "Level 7 (INFINITAS)", - "where": "chart.level = '7'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21285,9 +23463,10 @@ "inactive": true, "legacyFolderID": "F21e8abede3d9b9ab4e84070396d8e9f67107904e6f0db3a560121e0c3cf4a3c2", "searchTerms": [], + "slug": "7-pendual", "title": "Level 7 (PENDUAL)", - "where": "chart.level = '7'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21295,9 +23474,10 @@ "inactive": false, "legacyFolderID": "F548e5d7fae8a99802afd81567fabb4a033d3b69803174bf312c88e7fbf979062", "searchTerms": [], + "slug": "7-pinkycrush-omni", "title": "Level 7 (Pinky Crush Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21305,9 +23485,10 @@ "inactive": false, "legacyFolderID": "F7b540271711dae047bb3c2359338d917ec30d607279e094c2fe3d246a408b59f", "searchTerms": [], + "slug": "7-pinkycrush", "title": "Level 7 (Pinky Crush)", - "where": "chart.level = '7'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21315,9 +23496,10 @@ "inactive": true, "legacyFolderID": "Fd493959f12d46fabf56a0ac7da8c4c3a01abec4344ec909e725f35be76d293c1", "searchTerms": [], + "slug": "7-resident-omni", "title": "Level 7 (Resident Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21325,9 +23507,10 @@ "inactive": true, "legacyFolderID": "Fd48754a0ba9c04d0d3059589f0a3f2dbbc9f0debe00f8fd9f96848b3bc78d768", "searchTerms": [], + "slug": "7-resident", "title": "Level 7 (Resident)", - "where": "chart.level = '7'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21335,9 +23518,10 @@ "inactive": true, "legacyFolderID": "Fb80735cbeeedd732d7e4055cfd90c444b39378ce7d3ac6b5d4910847060a5a3a", "searchTerms": [], + "slug": "7-rootage-omni", "title": "Level 7 (ROOTAGE Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21345,9 +23529,10 @@ "inactive": true, "legacyFolderID": "Fa3e095eca72fb4ca692a9406aeee29add54ac6fa0c49b859d5962c839b7a3ebe", "searchTerms": [], + "slug": "7-rootage", "title": "Level 7 (ROOTAGE)", - "where": "chart.level = '7'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21355,9 +23540,10 @@ "inactive": true, "legacyFolderID": "Fd0f74432535933e6a728f2428867c32a6e353362588d9f9a5adfaaa5f53d32ac", "searchTerms": [], + "slug": "7-sinobuz", "title": "Level 7 (SINOBUZ)", - "where": "chart.level = '7'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21365,9 +23551,10 @@ "inactive": true, "legacyFolderID": "Fbca97d59f6976d6dca8853c3dd02c84c63b89e5eeac87d6bb48a12096f781031", "searchTerms": [], + "slug": "7-spada", "title": "Level 7 (SPADA)", - "where": "chart.level = '7'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21375,9 +23562,10 @@ "inactive": false, "legacyFolderID": "F56e5d67046950ee59d98106ebe9dcfc73a332149992d761c72995d7e63be6dd4", "searchTerms": [], + "slug": "7-sparkleshower", "title": "Level 7 (Sparkle Shower)", - "where": "chart.level = '7'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21385,9 +23573,10 @@ "inactive": true, "legacyFolderID": "Ff89ea4240798d94825b117f64034608c45cf4a257029c2cc58ef52933c8eb849", "searchTerms": [], + "slug": "7-tricoro", "title": "Level 7 (tricoro)", - "where": "chart.level = '7'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '7'" }, { "game": "iidx-sp", @@ -21395,9 +23584,10 @@ "inactive": true, "legacyFolderID": "Fe14fdb513a66d85d4e062f1954c3c536ebcc80da192589b778fcdd92dd4942dc", "searchTerms": [], + "slug": "8-10th-cs", "title": "Level 8 (10th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21405,9 +23595,10 @@ "inactive": true, "legacyFolderID": "F71827fd1042603e995ffef698c0f12c2fdff5edba97cb1a2a797fa6d919d03d2", "searchTerms": [], + "slug": "8-3rd-cs", "title": "Level 8 (3rd Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21415,9 +23606,10 @@ "inactive": true, "legacyFolderID": "F3158c0b9315371ebb8269fe2ca694ec67c58b4dec6a964ab675a7291068ce28f", "searchTerms": [], + "slug": "8-4th-cs", "title": "Level 8 (4th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21425,9 +23617,10 @@ "inactive": true, "legacyFolderID": "Fac06e40e5838db847a91546a1cdc282ae84eac5738088157a077241fbce2f04a", "searchTerms": [], + "slug": "8-5th-cs", "title": "Level 8 (5th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21435,9 +23628,10 @@ "inactive": true, "legacyFolderID": "F31abcf4fe4f856daa63fe3b8f8b791256328dad98c0aaee50ab89570494e9186", "searchTerms": [], + "slug": "8-6th-cs", "title": "Level 8 (6th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21445,9 +23639,10 @@ "inactive": true, "legacyFolderID": "F0754adec75f69f03817f8c224923dbc2726ee2b2ba449bc21433eae559ca7547", "searchTerms": [], + "slug": "8-7th-cs", "title": "Level 8 (7th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21455,9 +23650,10 @@ "inactive": true, "legacyFolderID": "F1d4cc74fea70c0a6d5d7e3ed533f4aa7a70ad9449a3ec6a9f5e06a4afb52eb92", "searchTerms": [], + "slug": "8-8th-cs", "title": "Level 8 (8th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21465,9 +23661,10 @@ "inactive": true, "legacyFolderID": "Fe1318e75cdfdb17accd20088e79637e162d6a6c3fb531ad1740ed9a84c8f8f00", "searchTerms": [], + "slug": "8-9th-cs", "title": "Level 8 (9th Style CS)", - "where": "chart.level = '8'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21475,9 +23672,10 @@ "inactive": true, "legacyFolderID": "F6b6fd95adc150d95472310535a2cf74d64bcdd13c2106e73f1f900c07619a393", "searchTerms": [], + "slug": "8-bmus", "title": "Level 8 (BEATMANIA US)", - "where": "chart.level = '8'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21485,9 +23683,10 @@ "inactive": true, "legacyFolderID": "F4fbdfaf8ac8f5c9bf4326296f7e44ec2039ed8e496977022f979ce148dc9ee3e", "searchTerms": [], + "slug": "8-bistrover-omni", "title": "Level 8 (BISTROVER Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21495,9 +23694,10 @@ "inactive": true, "legacyFolderID": "F568166b675f0e975cbf86608f4583ee6d783ec38137621ba79d3f17a31bcf5f3", "searchTerms": [], + "slug": "8-bistrover", "title": "Level 8 (BISTROVER)", - "where": "chart.level = '8'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21505,9 +23705,10 @@ "inactive": true, "legacyFolderID": "F7d9b3218662e84a2fde5d8bf9c0bd53e8fef9fe75f597e1e4486bc4a84fd4550", "searchTerms": [], + "slug": "8-cannonballers", "title": "Level 8 (CANNON BALLERS)", - "where": "chart.level = '8'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21515,9 +23716,10 @@ "inactive": true, "legacyFolderID": "Fee5acaafb89eb9e07a2dfbfa856cf4ee2bd227a0cb08c020cd6b72d338b51ddc", "searchTerms": [], + "slug": "8-casthour-omni", "title": "Level 8 (CastHour Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21525,9 +23727,10 @@ "inactive": true, "legacyFolderID": "F5f38ec57d18e196fa66daba3b12f92a750e56c122542167f68525ff34a5def5a", "searchTerms": [], + "slug": "8-casthour", "title": "Level 8 (CastHour)", - "where": "chart.level = '8'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21535,9 +23738,10 @@ "inactive": true, "legacyFolderID": "F932becba9bc76935adb6137a47111f780b51847be8167d9f7971f139bb85cf24", "searchTerms": [], + "slug": "8-copula", "title": "Level 8 (copula)", - "where": "chart.level = '8'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21545,9 +23749,10 @@ "inactive": true, "legacyFolderID": "F4228253570a623307ea8bc8e64bd7b66c8afabf79b6770aa0733c27aa41e5685", "searchTerms": [], + "slug": "8-13th-cs", "title": "Level 8 (DISTORTED CS)", - "where": "chart.level = '8'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21555,9 +23760,10 @@ "inactive": true, "legacyFolderID": "Fc8540a4111d56722453f03900aaadda55cea85e6e071650065c1fb4a33bb84b2", "searchTerms": [], + "slug": "8-15th-cs", "title": "Level 8 (DJ TROOPERS CS)", - "where": "chart.level = '8'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21565,9 +23771,10 @@ "inactive": true, "legacyFolderID": "F435543cf2ea99bf375e80d2856f4ad3ff098580b50ea4bffe72d959e8c48a90c", "searchTerms": [], + "slug": "8-16th-cs", "title": "Level 8 (EMPRESS CS)", - "where": "chart.level = '8'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21575,9 +23782,10 @@ "inactive": true, "legacyFolderID": "Fa1b4fb7133452d2ee923ddb91aef0823b104db16e17d3d53012754c00515c11b", "searchTerms": [], + "slug": "8-epolis-omni", "title": "Level 8 (Epolis Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21585,9 +23793,10 @@ "inactive": true, "legacyFolderID": "Fcdc6aa3dd48511a6b7c219a3cbcadfad6f1a03583ea530023235a3cc52353e39", "searchTerms": [], + "slug": "8-epolis", "title": "Level 8 (EPOLIS)", - "where": "chart.level = '8'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21595,9 +23804,10 @@ "inactive": true, "legacyFolderID": "Fa4780b10565d604921017cd42c624d67f5f9619cd688f858de59ce24a505edb5", "searchTerms": [], + "slug": "8-14th-cs", "title": "Level 8 (GOLD CS)", - "where": "chart.level = '8'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21605,9 +23815,10 @@ "inactive": true, "legacyFolderID": "F82f03bcaabeae25a693474ef3e938a2b705f9301d031a28b054c59a3ba49c0e1", "searchTerms": [], + "slug": "8-12th-cs", "title": "Level 8 (HAPPY SKY CS)", - "where": "chart.level = '8'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21615,9 +23826,10 @@ "inactive": true, "legacyFolderID": "F1bd2cfb0570fa250b12618a4f05383f140be496c182577ae7d79c7f762461e92", "searchTerms": [], + "slug": "8-heroicverse-2dxtra", "title": "Level 8 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '8'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21625,9 +23837,10 @@ "inactive": true, "legacyFolderID": "F6746ded2a14fd942d7a5552f612f44e4d0dab0d18d9f00903baa3bd5277f83f0", "searchTerms": [], + "slug": "8-heroicverse-omni", "title": "Level 8 (HEROIC VERSE Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21635,9 +23848,10 @@ "inactive": true, "legacyFolderID": "F5ce10ca2c92f649d374058fedc6acbe6cca36691383da85927e4bbf399651a0a", "searchTerms": [], + "slug": "8-heroicverse", "title": "Level 8 (HEROIC VERSE)", - "where": "chart.level = '8'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21645,9 +23859,10 @@ "inactive": true, "legacyFolderID": "Ff041674651c4bcd4affaeb44ec64ebc54edfeb083a6e1948f7c2da8a1e9be39b", "searchTerms": [], + "slug": "8-11th-cs", "title": "Level 8 (IIDX RED CS)", - "where": "chart.level = '8'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21655,9 +23870,10 @@ "inactive": false, "legacyFolderID": "Fb7b22bb2da4e81f91acf2810f8085fb9d36744b9ddea9eb82dc7bd45fd81ae76", "searchTerms": [], + "slug": "8-inf", "title": "Level 8 (INFINITAS)", - "where": "chart.level = '8'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21665,9 +23881,10 @@ "inactive": true, "legacyFolderID": "F3077ac5ae2d03efad0f76e884c69ee2e90687bb3ae2761a7d45efa8678affe2a", "searchTerms": [], + "slug": "8-pendual", "title": "Level 8 (PENDUAL)", - "where": "chart.level = '8'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21675,9 +23892,10 @@ "inactive": false, "legacyFolderID": "F9d108adacc0f704f19e4b63e0f8df9bacad677468a17fb65f97d71d06f9eae88", "searchTerms": [], + "slug": "8-pinkycrush-omni", "title": "Level 8 (Pinky Crush Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21685,9 +23903,10 @@ "inactive": false, "legacyFolderID": "F877376c9f0b76da0be8b85fca69d608d4274fc7da595bb87c955e2d02746e892", "searchTerms": [], + "slug": "8-pinkycrush", "title": "Level 8 (Pinky Crush)", - "where": "chart.level = '8'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21695,9 +23914,10 @@ "inactive": true, "legacyFolderID": "F6bcff59330c8aeb312728de2c4880e4448fc50289b686dfe6dacd1e0734b8410", "searchTerms": [], + "slug": "8-resident-omni", "title": "Level 8 (Resident Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21705,9 +23925,10 @@ "inactive": true, "legacyFolderID": "F1288491e824bad195bc8c14bd6d388eaacb3da26d2ab076235c5c8d662f4e4c4", "searchTerms": [], + "slug": "8-resident", "title": "Level 8 (Resident)", - "where": "chart.level = '8'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21715,9 +23936,10 @@ "inactive": true, "legacyFolderID": "F5eff49cf9dc4499a9f46f1e303cd11cea6db5c2dda8378f8f2f686d260f3b25e", "searchTerms": [], + "slug": "8-rootage-omni", "title": "Level 8 (ROOTAGE Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21725,9 +23947,10 @@ "inactive": true, "legacyFolderID": "F6b8cfca7fc0f4d9cb7aab21983b5cc301230c4f6313c56a645c292e816f7ac73", "searchTerms": [], + "slug": "8-rootage", "title": "Level 8 (ROOTAGE)", - "where": "chart.level = '8'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21735,9 +23958,10 @@ "inactive": true, "legacyFolderID": "F4ace4f177a333a9eb9ca4906d2b78abe22ff6461a9ffdae1d32242533f4e38d3", "searchTerms": [], + "slug": "8-sinobuz", "title": "Level 8 (SINOBUZ)", - "where": "chart.level = '8'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21745,9 +23969,10 @@ "inactive": true, "legacyFolderID": "F471adc53dc82134ff598ba33774d5e678b53c8dde3ce53eccf0b06d0b71a969a", "searchTerms": [], + "slug": "8-spada", "title": "Level 8 (SPADA)", - "where": "chart.level = '8'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21755,9 +23980,10 @@ "inactive": false, "legacyFolderID": "F0268a968013866e93c0d2b57efa872f1e7e0c6f8275bce720dbaf1215611fd02", "searchTerms": [], + "slug": "8-sparkleshower", "title": "Level 8 (Sparkle Shower)", - "where": "chart.level = '8'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21765,9 +23991,10 @@ "inactive": true, "legacyFolderID": "Fbde2bebc4f90df49111bd4f325bec526833198146ba942bc932c2db8b3bbf2e3", "searchTerms": [], + "slug": "8-tricoro", "title": "Level 8 (tricoro)", - "where": "chart.level = '8'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '8'" }, { "game": "iidx-sp", @@ -21775,9 +24002,10 @@ "inactive": true, "legacyFolderID": "Fbce0c9cd3261099a28a211a707d92ef4d83e9e30f40f077049728340a079e6b3", "searchTerms": [], + "slug": "9-10th-cs", "title": "Level 9 (10th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["10-cs"] + "versionFilter": ["10-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21785,9 +24013,10 @@ "inactive": true, "legacyFolderID": "F472c3e57d074f364c9ee4f0b3a5e32e1336170a6391f96cbf5db2eb3eecda870", "searchTerms": [], + "slug": "9-3rd-cs", "title": "Level 9 (3rd Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["3-cs"] + "versionFilter": ["3-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21795,9 +24024,10 @@ "inactive": true, "legacyFolderID": "F06aa9aea2b1510fe8075f2f522fd76a345ccbd9056dc697c07e100ae735e1224", "searchTerms": [], + "slug": "9-4th-cs", "title": "Level 9 (4th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["4-cs"] + "versionFilter": ["4-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21805,9 +24035,10 @@ "inactive": true, "legacyFolderID": "Ffe262c354ab7a8c46e31c7d33888f7099739180bf27b1914361e7f10f980f7a6", "searchTerms": [], + "slug": "9-5th-cs", "title": "Level 9 (5th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["5-cs"] + "versionFilter": ["5-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21815,9 +24046,10 @@ "inactive": true, "legacyFolderID": "F12e885167a8cbe95e02f748cdf71070b83ee775f6f507fc185570074b191c94d", "searchTerms": [], + "slug": "9-6th-cs", "title": "Level 9 (6th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["6-cs"] + "versionFilter": ["6-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21825,9 +24057,10 @@ "inactive": true, "legacyFolderID": "F1c8ac3857560cb4a08faa2029cff8aee0bbcc85fe0b54ecc801e336f1f196c03", "searchTerms": [], + "slug": "9-7th-cs", "title": "Level 9 (7th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["7-cs"] + "versionFilter": ["7-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21835,9 +24068,10 @@ "inactive": true, "legacyFolderID": "F4dc5a8ab061d20cc28d82afe52bc69eb718d1ec8f8ee68e92ab3d0076a0da7f2", "searchTerms": [], + "slug": "9-8th-cs", "title": "Level 9 (8th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["8-cs"] + "versionFilter": ["8-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21845,9 +24079,10 @@ "inactive": true, "legacyFolderID": "F8df766b812465cf1f78563cc0ea971e0a9cbffea0d00fdcd25cb3216895c9fa8", "searchTerms": [], + "slug": "9-9th-cs", "title": "Level 9 (9th Style CS)", - "where": "chart.level = '9'", - "versionFilter": ["9-cs"] + "versionFilter": ["9-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21855,9 +24090,10 @@ "inactive": true, "legacyFolderID": "F3eb08513302568580a2ce319f67c0cacb5267e1a19691f2c1275fcb6f901a9b2", "searchTerms": [], + "slug": "9-bmus", "title": "Level 9 (BEATMANIA US)", - "where": "chart.level = '9'", - "versionFilter": ["bmus"] + "versionFilter": ["bmus"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21865,9 +24101,10 @@ "inactive": true, "legacyFolderID": "Fdeeb0e98aa34b38a2873e58dea3bbcbf9c3a5529f91760512dfda646fe9fead6", "searchTerms": [], + "slug": "9-bistrover-omni", "title": "Level 9 (BISTROVER Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["28-omni"] + "versionFilter": ["28-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21875,9 +24112,10 @@ "inactive": true, "legacyFolderID": "F9e8a7bc74d1ea566d7615c1b3c1b64c439b32f7761c7022eb537866c9927ad23", "searchTerms": [], + "slug": "9-bistrover", "title": "Level 9 (BISTROVER)", - "where": "chart.level = '9'", - "versionFilter": ["28"] + "versionFilter": ["28"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21885,9 +24123,10 @@ "inactive": true, "legacyFolderID": "F0cd305ebdb7f882129415bf30ad283bc4b161f67c59fce57a007004ce794902c", "searchTerms": [], + "slug": "9-cannonballers", "title": "Level 9 (CANNON BALLERS)", - "where": "chart.level = '9'", - "versionFilter": ["25"] + "versionFilter": ["25"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21895,9 +24134,10 @@ "inactive": true, "legacyFolderID": "Faee1a053f0a66e7687eebf784a22e1fd8b7c3472078af9e57d2c3af90d744d71", "searchTerms": [], + "slug": "9-casthour-omni", "title": "Level 9 (CastHour Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["29-omni"] + "versionFilter": ["29-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21905,9 +24145,10 @@ "inactive": true, "legacyFolderID": "F5d3b507b2e00c793b7f7d1196195d33890556abd564beef68d08fbd4be56ae8c", "searchTerms": [], + "slug": "9-casthour", "title": "Level 9 (CastHour)", - "where": "chart.level = '9'", - "versionFilter": ["29"] + "versionFilter": ["29"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21915,9 +24156,10 @@ "inactive": true, "legacyFolderID": "Fa0744721f0adfcadab891c5cbad8591943170a706981a68b0d6e3b5cdc5d498b", "searchTerms": [], + "slug": "9-copula", "title": "Level 9 (copula)", - "where": "chart.level = '9'", - "versionFilter": ["23"] + "versionFilter": ["23"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21925,9 +24167,10 @@ "inactive": true, "legacyFolderID": "F26ba558b814fe1740bd5c715c7ab37e0b8c92c7d1c319882658d6e9a5316635f", "searchTerms": [], + "slug": "9-13th-cs", "title": "Level 9 (DISTORTED CS)", - "where": "chart.level = '9'", - "versionFilter": ["13-cs"] + "versionFilter": ["13-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21935,9 +24178,10 @@ "inactive": true, "legacyFolderID": "F4f35041aa93495ae66d1233c9cebdc1184c3526917935509914c834ddc0c7d71", "searchTerms": [], + "slug": "9-15th-cs", "title": "Level 9 (DJ TROOPERS CS)", - "where": "chart.level = '9'", - "versionFilter": ["15-cs"] + "versionFilter": ["15-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21945,9 +24189,10 @@ "inactive": true, "legacyFolderID": "F979b07cd547d654bb5ce51b860aa1e8072914a2c86be15b3e57c5588f42a1a36", "searchTerms": [], + "slug": "9-16th-cs", "title": "Level 9 (EMPRESS CS)", - "where": "chart.level = '9'", - "versionFilter": ["16-cs"] + "versionFilter": ["16-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21955,9 +24200,10 @@ "inactive": true, "legacyFolderID": "F6c07f8fd553e532e0dc1e2ee3f8f4264d80b6481a66342ce77ffd0bf1a392343", "searchTerms": [], + "slug": "9-epolis-omni", "title": "Level 9 (Epolis Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["31-omni"] + "versionFilter": ["31-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21965,9 +24211,10 @@ "inactive": true, "legacyFolderID": "F437949ea3ab0e2996a999ce3ae5b825f08d2fee6bc14e160402130cc02ce968e", "searchTerms": [], + "slug": "9-epolis", "title": "Level 9 (EPOLIS)", - "where": "chart.level = '9'", - "versionFilter": ["31"] + "versionFilter": ["31"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21975,9 +24222,10 @@ "inactive": true, "legacyFolderID": "Fe1e1a6ae1a3f086e7bd4bfdb74c3d64b485c625165967ba20a732805a0c37972", "searchTerms": [], + "slug": "9-14th-cs", "title": "Level 9 (GOLD CS)", - "where": "chart.level = '9'", - "versionFilter": ["14-cs"] + "versionFilter": ["14-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21985,9 +24233,10 @@ "inactive": true, "legacyFolderID": "F8b9b387663443f3a9529cfce1063f220d78340339e52d8a7fb7e58fa1638971b", "searchTerms": [], + "slug": "9-12th-cs", "title": "Level 9 (HAPPY SKY CS)", - "where": "chart.level = '9'", - "versionFilter": ["12-cs"] + "versionFilter": ["12-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -21995,9 +24244,10 @@ "inactive": true, "legacyFolderID": "F82cd2764ed80a384213a6c72603d577c5b6a48a57f9161220b936161e1dc197c", "searchTerms": [], + "slug": "9-heroicverse-2dxtra", "title": "Level 9 (HEROIC VERSE 2dxtra)", - "where": "chart.level = '9'", - "versionFilter": ["27-2dxtra"] + "versionFilter": ["27-2dxtra"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22005,9 +24255,10 @@ "inactive": true, "legacyFolderID": "Fe78a543b324f5b883ff4e85dee12753cb407793be0bacbda0b5f11a9c6c65654", "searchTerms": [], + "slug": "9-heroicverse-omni", "title": "Level 9 (HEROIC VERSE Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["27-omni"] + "versionFilter": ["27-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22015,9 +24266,10 @@ "inactive": true, "legacyFolderID": "F7baf42e572b1e235d6dda6c493c7d4c91b4b69584a349a97567ac749c4d7c72c", "searchTerms": [], + "slug": "9-heroicverse", "title": "Level 9 (HEROIC VERSE)", - "where": "chart.level = '9'", - "versionFilter": ["27"] + "versionFilter": ["27"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22025,9 +24277,10 @@ "inactive": true, "legacyFolderID": "F8a621c3d0b796f206ad74756d34e84801bf4be74ef4317f485e6cc66040b73da", "searchTerms": [], + "slug": "9-11th-cs", "title": "Level 9 (IIDX RED CS)", - "where": "chart.level = '9'", - "versionFilter": ["11-cs"] + "versionFilter": ["11-cs"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22035,9 +24288,10 @@ "inactive": false, "legacyFolderID": "F3b9ba2932761d8dc37f74c656d4240a6bf15625329063e4c2be291d8352d6d08", "searchTerms": [], + "slug": "9-inf", "title": "Level 9 (INFINITAS)", - "where": "chart.level = '9'", - "versionFilter": ["inf"] + "versionFilter": ["inf"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22045,9 +24299,10 @@ "inactive": true, "legacyFolderID": "F529c72851f5731b3fe0fa51e744a03aacefae835cc45da0fbe5ea4c3cb7d4039", "searchTerms": [], + "slug": "9-pendual", "title": "Level 9 (PENDUAL)", - "where": "chart.level = '9'", - "versionFilter": ["22"] + "versionFilter": ["22"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22055,9 +24310,10 @@ "inactive": false, "legacyFolderID": "F70c7a7b53d91ba7870e7a5dac3986545c5796f4b4000e6d266a010140b2b3560", "searchTerms": [], + "slug": "9-pinkycrush-omni", "title": "Level 9 (Pinky Crush Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["32-omni"] + "versionFilter": ["32-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22065,9 +24321,10 @@ "inactive": false, "legacyFolderID": "Fd9bf1060d183a680bc80892ab83cbb6a63f0288d8db7093eba3e2d6d659f4af8", "searchTerms": [], + "slug": "9-pinkycrush", "title": "Level 9 (Pinky Crush)", - "where": "chart.level = '9'", - "versionFilter": ["32"] + "versionFilter": ["32"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22075,9 +24332,10 @@ "inactive": true, "legacyFolderID": "Fa4b4b04fb2090b24fae6f67711109ca572661804e9c21d219bd3b7caff13ed82", "searchTerms": [], + "slug": "9-resident-omni", "title": "Level 9 (Resident Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["30-omni"] + "versionFilter": ["30-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22085,9 +24343,10 @@ "inactive": true, "legacyFolderID": "F96f40e5b849ecf2be1a215ccd4af4f3df9ed499bd2c5be4b998a8891177ab337", "searchTerms": [], + "slug": "9-resident", "title": "Level 9 (Resident)", - "where": "chart.level = '9'", - "versionFilter": ["30"] + "versionFilter": ["30"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22095,9 +24354,10 @@ "inactive": true, "legacyFolderID": "F4664df84316658aa42090e49f58c6ce9485caee33cf8db8fb7e93d87de0de74c", "searchTerms": [], + "slug": "9-rootage-omni", "title": "Level 9 (ROOTAGE Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["26-omni"] + "versionFilter": ["26-omni"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22105,9 +24365,10 @@ "inactive": true, "legacyFolderID": "Fdbfd071c807d7c8d9adfc180487edc7a483df785b6f8c693a70a2e975242013f", "searchTerms": [], + "slug": "9-rootage", "title": "Level 9 (ROOTAGE)", - "where": "chart.level = '9'", - "versionFilter": ["26"] + "versionFilter": ["26"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22115,9 +24376,10 @@ "inactive": true, "legacyFolderID": "F7138f34d662eae6d723ae951ac4e6bde6aa4ba481e39cd4c757f727963b5a378", "searchTerms": [], + "slug": "9-sinobuz", "title": "Level 9 (SINOBUZ)", - "where": "chart.level = '9'", - "versionFilter": ["24"] + "versionFilter": ["24"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22125,9 +24387,10 @@ "inactive": true, "legacyFolderID": "F1974acc25a26b9d539891e2434a70e88d5b49f3929c55448baeaf93580fe5ee7", "searchTerms": [], + "slug": "9-spada", "title": "Level 9 (SPADA)", - "where": "chart.level = '9'", - "versionFilter": ["21"] + "versionFilter": ["21"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22135,9 +24398,10 @@ "inactive": false, "legacyFolderID": "Fa70d15ab9fc9e57464a295bf1ac5c52b72bd130678f962b0d6787ecf2698741d", "searchTerms": [], + "slug": "9-sparkleshower", "title": "Level 9 (Sparkle Shower)", - "where": "chart.level = '9'", - "versionFilter": ["33"] + "versionFilter": ["33"], + "where": "chart.level = '9'" }, { "game": "iidx-sp", @@ -22145,9 +24409,10 @@ "inactive": true, "legacyFolderID": "F97a41e899a76cd67b3d434925ac36af88030ca5a75d8c029f14c01c541504683", "searchTerms": [], + "slug": "9-tricoro", "title": "Level 9 (tricoro)", - "where": "chart.level = '9'", - "versionFilter": ["20"] + "versionFilter": ["20"], + "where": "chart.level = '9'" }, { "game": "itg-stamina", @@ -22155,6 +24420,7 @@ "inactive": false, "legacyFolderID": "F7ac2c299aa59cc94ecaeb146a20b9d08705102b870817129090911a2f3a85717", "searchTerms": [], + "slug": "level-1", "title": "Level 1", "where": "chart.level = '1'" }, @@ -22164,6 +24430,7 @@ "inactive": false, "legacyFolderID": "Ff6c2f440eac6da71f38053aedb2302bc08f13e6f4f642e3393453a760ab17e89", "searchTerms": [], + "slug": "level-1-all-marathons", "title": "Level 1 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 1 AND (chart.data->>'length')::numeric >= 960" }, @@ -22173,6 +24440,7 @@ "inactive": false, "legacyFolderID": "Ffcb063400cabba1b52d284bdd68936f2a4e4efd2ebd7f5911e12ba150d5cfd08", "searchTerms": [], + "slug": "level-1-ranked-marathons", "title": "Level 1 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 1" }, @@ -22182,6 +24450,7 @@ "inactive": false, "legacyFolderID": "F1e00388891f648c0064aad544b8370ee806590babacecfb8ee2ea4b42e536475", "searchTerms": [], + "slug": "level-1-w-unranked", "title": "Level 1 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 1" }, @@ -22191,6 +24460,7 @@ "inactive": false, "legacyFolderID": "Fe4caa2927972435e785893edf780882f337a2e338f7cc6619b1636edc28ac904", "searchTerms": [], + "slug": "level-1-ranked", "title": "Level 1 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 1" }, @@ -22200,6 +24470,7 @@ "inactive": false, "legacyFolderID": "Fea6cd28a26ed4a098881e5664dd9ca96d033694bb2016af2d0d858d6ec330abd", "searchTerms": [], + "slug": "level-10", "title": "Level 10", "where": "chart.level = '10'" }, @@ -22209,6 +24480,7 @@ "inactive": false, "legacyFolderID": "F00c65252f132101016c5543eb8dd44ee69aa91ca7348625cbcd63cdab6b99742", "searchTerms": [], + "slug": "level-10-all-marathons", "title": "Level 10 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 10 AND (chart.data->>'length')::numeric >= 960" }, @@ -22218,6 +24490,7 @@ "inactive": false, "legacyFolderID": "Fdb15ddf3d68e7ce7d44e17bcd46290b56023ef67b62ebeb32bb61d05fbb7570b", "searchTerms": [], + "slug": "level-10-ranked-marathons", "title": "Level 10 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 10" }, @@ -22227,6 +24500,7 @@ "inactive": false, "legacyFolderID": "F70f52f59ada4f66649ee0d30c79775e7a6671b2f9b5cb981460678ba6dbb45d2", "searchTerms": [], + "slug": "level-10-w-unranked", "title": "Level 10 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 10" }, @@ -22236,6 +24510,7 @@ "inactive": false, "legacyFolderID": "F28d929aab5752baf2ad78372e864ddb1a22726c02e656c02ee9e99bad55d85e7", "searchTerms": [], + "slug": "level-10-ranked", "title": "Level 10 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 10" }, @@ -22245,6 +24520,7 @@ "inactive": false, "legacyFolderID": "F217a7e6b14ad0cc099e17784bc4d880487264c14b57757ee469fd697c82e6b5f", "searchTerms": [], + "slug": "level-11", "title": "Level 11", "where": "chart.level = '11'" }, @@ -22254,6 +24530,7 @@ "inactive": false, "legacyFolderID": "F783b40a78ea5d3fa549b38cd67dea278d9c46a2ef7b56ebc8183ea695d4ccbba", "searchTerms": [], + "slug": "level-11-all-marathons", "title": "Level 11 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 11 AND (chart.data->>'length')::numeric >= 960" }, @@ -22263,6 +24540,7 @@ "inactive": false, "legacyFolderID": "Fe871e8c4181e6524e2fc05e3b5416bfe023bc50fdf2fec93714b34bf5d3c5e59", "searchTerms": [], + "slug": "level-11-ranked-marathons", "title": "Level 11 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 11" }, @@ -22272,6 +24550,7 @@ "inactive": false, "legacyFolderID": "F66da2b88783ef19d4078065ce7442490399db9c86494c1a0a03591b7a17b7fcb", "searchTerms": [], + "slug": "level-11-w-unranked", "title": "Level 11 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 11" }, @@ -22281,6 +24560,7 @@ "inactive": false, "legacyFolderID": "F088823983e86f915a894b63c17c5f95456430145562b7f18abb3e726fe3cdeaa", "searchTerms": [], + "slug": "level-11-ranked", "title": "Level 11 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 11" }, @@ -22290,6 +24570,7 @@ "inactive": false, "legacyFolderID": "F33b5a1fa8e8b74daf86266148bcb3c8be298991a99beb91c46a85e02905f1737", "searchTerms": [], + "slug": "level-12", "title": "Level 12", "where": "chart.level = '12'" }, @@ -22299,6 +24580,7 @@ "inactive": false, "legacyFolderID": "Fb02fbb321c56e5ce02219900c10b81b5cc0b86721aef4688c5e880c2ee948d79", "searchTerms": [], + "slug": "level-12-all-marathons", "title": "Level 12 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 12 AND (chart.data->>'length')::numeric >= 960" }, @@ -22308,6 +24590,7 @@ "inactive": false, "legacyFolderID": "F39743b2f54f28d8e0234390d3bbab7207008f8364c084e22116ac57da77df23e", "searchTerms": [], + "slug": "level-12-ranked-marathons", "title": "Level 12 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 12" }, @@ -22317,6 +24600,7 @@ "inactive": false, "legacyFolderID": "Fb95fed3ae4efd5d455421df620bcef0366ee19226352157f6ae88e2ed44800b2", "searchTerms": [], + "slug": "level-12-w-unranked", "title": "Level 12 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 12" }, @@ -22326,6 +24610,7 @@ "inactive": false, "legacyFolderID": "F6f0379cc82b780a33437a20f672236b32a98e0af6fda229b68ad9ea4882f476e", "searchTerms": [], + "slug": "level-12-ranked", "title": "Level 12 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 12" }, @@ -22335,6 +24620,7 @@ "inactive": false, "legacyFolderID": "F32e27930fe08b06cbc3e1c21e4e68f23323bda2feca85d2c45fab41f24934cd3", "searchTerms": [], + "slug": "level-13", "title": "Level 13", "where": "chart.level = '13'" }, @@ -22344,6 +24630,7 @@ "inactive": false, "legacyFolderID": "F20252719436dc8c2cce4924172f9755d7cb8a0bbb4c0871b91f56183588389fe", "searchTerms": [], + "slug": "level-13-all-marathons", "title": "Level 13 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 13 AND (chart.data->>'length')::numeric >= 960" }, @@ -22353,6 +24640,7 @@ "inactive": false, "legacyFolderID": "Fd63c7d54a6b48eb292fe06ac6c742bfe4eb5c015b12c15d2850df3b1c435248e", "searchTerms": [], + "slug": "level-13-ranked-marathons", "title": "Level 13 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 13" }, @@ -22362,6 +24650,7 @@ "inactive": false, "legacyFolderID": "F4732b5e109fff889886e109f8ba5bc28231e2ba25d86a62a96c7a9d5712e8031", "searchTerms": [], + "slug": "level-13-w-unranked", "title": "Level 13 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 13" }, @@ -22371,6 +24660,7 @@ "inactive": false, "legacyFolderID": "F75b92eccac14c921d7b3a0f4b8a37635cb52d9e0d687496aff7277f0ebbe9dd0", "searchTerms": [], + "slug": "level-13-ranked", "title": "Level 13 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 13" }, @@ -22380,6 +24670,7 @@ "inactive": false, "legacyFolderID": "F98f064b3ed979949ad65b2ed9e80fd4cbc11cdb15e6568d9b495a26246e6ee73", "searchTerms": [], + "slug": "level-14", "title": "Level 14", "where": "chart.level = '14'" }, @@ -22389,6 +24680,7 @@ "inactive": false, "legacyFolderID": "F47709013f8e33fc574076d9f9a23a8bf80ba7565c76491767ad9130888cc1f70", "searchTerms": [], + "slug": "level-14-all-marathons", "title": "Level 14 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 14 AND (chart.data->>'length')::numeric >= 960" }, @@ -22398,6 +24690,7 @@ "inactive": false, "legacyFolderID": "F405183972d13869e0c2f3d25f7c1787c2dcdf762166addf51f3afce89d86c1d9", "searchTerms": [], + "slug": "level-14-ranked-marathons", "title": "Level 14 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 14" }, @@ -22407,6 +24700,7 @@ "inactive": false, "legacyFolderID": "F19a2aa725c8e7718c69f40d9dd2b90536a8c93a817ed2232492442c1368f5bbf", "searchTerms": [], + "slug": "level-14-w-unranked", "title": "Level 14 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 14" }, @@ -22416,6 +24710,7 @@ "inactive": false, "legacyFolderID": "F0f10d16b70f227e7ba4acd86d314a2517cfe5fbf803ac29b12f88ce34f972c30", "searchTerms": [], + "slug": "level-14-ranked", "title": "Level 14 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 14" }, @@ -22425,6 +24720,7 @@ "inactive": false, "legacyFolderID": "Fbf06864443638b703349d9ecde799d2014cbd6f2ec01185b957e55015d396ffd", "searchTerms": [], + "slug": "level-15", "title": "Level 15", "where": "chart.level = '15'" }, @@ -22434,6 +24730,7 @@ "inactive": false, "legacyFolderID": "F6659d848de3e0743c1696e9cfea668c8a6a5704aba07e313fe74b94fce420c20", "searchTerms": [], + "slug": "level-15-all-marathons", "title": "Level 15 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 15 AND (chart.data->>'length')::numeric >= 960" }, @@ -22443,6 +24740,7 @@ "inactive": false, "legacyFolderID": "F036687533e487f25261ded285960de93f7cfd8ac8eee43c006e8282292c634f2", "searchTerms": [], + "slug": "level-15-ranked-marathons", "title": "Level 15 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 15" }, @@ -22452,6 +24750,7 @@ "inactive": false, "legacyFolderID": "F46379e557b2ac62415838ad0ce6c1afbcab6c73a230622d1c8fa0c8bbca45d44", "searchTerms": [], + "slug": "level-15-w-unranked", "title": "Level 15 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 15" }, @@ -22461,6 +24760,7 @@ "inactive": false, "legacyFolderID": "F1007ee5f6de2f7533387e05be1831a33299f8b797033d23f1f4c0cd70c6ac768", "searchTerms": [], + "slug": "level-15-ranked", "title": "Level 15 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 15" }, @@ -22470,6 +24770,7 @@ "inactive": false, "legacyFolderID": "F61e43331944906f89f4670d9682cda0617f150268f15b4a9aa01f7fc315e4864", "searchTerms": [], + "slug": "level-16", "title": "Level 16", "where": "chart.level = '16'" }, @@ -22479,6 +24780,7 @@ "inactive": false, "legacyFolderID": "F522157df588305a3d9ad0956aa8db2446cba055019d076c5f6156af8f301ec2e", "searchTerms": [], + "slug": "level-16-all-marathons", "title": "Level 16 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 16 AND (chart.data->>'length')::numeric >= 960" }, @@ -22488,6 +24790,7 @@ "inactive": false, "legacyFolderID": "Fe4e3d5ec0284473ff9cd63efb352af0042136a47c32efb13dbd5164140852942", "searchTerms": [], + "slug": "level-16-ranked-marathons", "title": "Level 16 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 16" }, @@ -22497,6 +24800,7 @@ "inactive": false, "legacyFolderID": "Ff1129bc34783cd2df6b750b491d576614a94de58b4796f71b0722cbd82ea8941", "searchTerms": [], + "slug": "level-16-w-unranked", "title": "Level 16 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 16" }, @@ -22506,6 +24810,7 @@ "inactive": false, "legacyFolderID": "Ff1450f38bc3416602e36799f51d87bc0535fa1fb36dab9e373de7f646bf6bc26", "searchTerms": [], + "slug": "level-16-ranked", "title": "Level 16 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 16" }, @@ -22515,6 +24820,7 @@ "inactive": false, "legacyFolderID": "Fc24f18835805e3ee8f0dc298f1ff656432a7a8356ee1bf9eb5b158bde9caa431", "searchTerms": [], + "slug": "level-17", "title": "Level 17", "where": "chart.level = '17'" }, @@ -22524,6 +24830,7 @@ "inactive": false, "legacyFolderID": "Fac154f3c25f22585d8fd0f00cee4ddec0cafa8707ea884f1b2a08fe424566b8f", "searchTerms": [], + "slug": "level-17-all-marathons", "title": "Level 17 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 17 AND (chart.data->>'length')::numeric >= 960" }, @@ -22533,6 +24840,7 @@ "inactive": false, "legacyFolderID": "Fa9724abe4d4a4de7730a4e62d7ee738c14d4edf6d1457395160a7808088fc6c4", "searchTerms": [], + "slug": "level-17-ranked-marathons", "title": "Level 17 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 17" }, @@ -22542,6 +24850,7 @@ "inactive": false, "legacyFolderID": "F0c7a4eb7eef4f6239aa13e3dc8fbfdaf14d7863ebeabd789b653869b095b5367", "searchTerms": [], + "slug": "level-17-w-unranked", "title": "Level 17 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 17" }, @@ -22551,6 +24860,7 @@ "inactive": false, "legacyFolderID": "F979f5844dfd38cceafeb42eadf56825ce02c5003999ea4cf8c5fb989d5be4b34", "searchTerms": [], + "slug": "level-17-ranked", "title": "Level 17 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 17" }, @@ -22560,6 +24870,7 @@ "inactive": false, "legacyFolderID": "Fb35972740b0ac17d955221fc829198058324870d2c5c157669e7ace80e0cebf3", "searchTerms": [], + "slug": "level-18", "title": "Level 18", "where": "chart.level = '18'" }, @@ -22569,6 +24880,7 @@ "inactive": false, "legacyFolderID": "Fddd553b7fba8174915b9f8d3f9a2dc56183cdc54a2e756dd4fc78da4315640af", "searchTerms": [], + "slug": "level-18-all-marathons", "title": "Level 18 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 18 AND (chart.data->>'length')::numeric >= 960" }, @@ -22578,6 +24890,7 @@ "inactive": false, "legacyFolderID": "Ffe83e5e28985c21d9ec105da04940a2e0ea96c655c6b011c16dfe5086198128c", "searchTerms": [], + "slug": "level-18-ranked-marathons", "title": "Level 18 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 18" }, @@ -22587,6 +24900,7 @@ "inactive": false, "legacyFolderID": "F9d207658e4b483a8c0a20edf2c0924ad1d191968559306194b57b2bbf36553a2", "searchTerms": [], + "slug": "level-18-w-unranked", "title": "Level 18 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 18" }, @@ -22596,6 +24910,7 @@ "inactive": false, "legacyFolderID": "F2161fe45b90352bc430d22143b473f03d6295aa6f1c2e8e9e4275a1cb82c5f46", "searchTerms": [], + "slug": "level-18-ranked", "title": "Level 18 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 18" }, @@ -22605,6 +24920,7 @@ "inactive": false, "legacyFolderID": "F3ae1e2a883a11c9c3cf1b8aafb2097153c203c64ad508855b44a4bfb11a25068", "searchTerms": [], + "slug": "level-19", "title": "Level 19", "where": "chart.level = '19'" }, @@ -22614,6 +24930,7 @@ "inactive": false, "legacyFolderID": "F35fad145a8b745c850ea2ef60326a1c7b7159ffe11c9739bd8e2ac75cfa5c705", "searchTerms": [], + "slug": "level-19-all-marathons", "title": "Level 19 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 19 AND (chart.data->>'length')::numeric >= 960" }, @@ -22623,6 +24940,7 @@ "inactive": false, "legacyFolderID": "F712fe6b012a04b163d0150ba3c84a0d378db092dddac6cf7969da3ecffdb9847", "searchTerms": [], + "slug": "level-19-ranked-marathons", "title": "Level 19 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 19" }, @@ -22632,6 +24950,7 @@ "inactive": false, "legacyFolderID": "F29abe321a55c3f707b5b194caba92aefd9c973d210fe3cd4a5864613c8a9554a", "searchTerms": [], + "slug": "level-19-w-unranked", "title": "Level 19 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 19" }, @@ -22641,6 +24960,7 @@ "inactive": false, "legacyFolderID": "F8cb9164463c5faa0b4785e0dd8af618410fe47877f45754ea36b2e443317587c", "searchTerms": [], + "slug": "level-19-ranked", "title": "Level 19 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 19" }, @@ -22650,6 +24970,7 @@ "inactive": false, "legacyFolderID": "Feb2df6621fa22099c2b2e8b20e69499a54b03e985267e270185acd7260d16366", "searchTerms": [], + "slug": "level-2", "title": "Level 2", "where": "chart.level = '2'" }, @@ -22659,6 +24980,7 @@ "inactive": false, "legacyFolderID": "F731ac25c42dbc43ba250d61d65da135a0b887a024d78b06fb1a881156878781b", "searchTerms": [], + "slug": "level-2-all-marathons", "title": "Level 2 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 2 AND (chart.data->>'length')::numeric >= 960" }, @@ -22668,6 +24990,7 @@ "inactive": false, "legacyFolderID": "F01df4085b23d68b5c66d0f8546013dc5698d662f34cda1ff5bff8f0b383f5670", "searchTerms": [], + "slug": "level-2-ranked-marathons", "title": "Level 2 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 2" }, @@ -22677,6 +25000,7 @@ "inactive": false, "legacyFolderID": "F97143f07cc1d99a6243d37de0d116980c03c75b6c58ca90eeb53f34fcc986191", "searchTerms": [], + "slug": "level-2-w-unranked", "title": "Level 2 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 2" }, @@ -22686,6 +25010,7 @@ "inactive": false, "legacyFolderID": "Fd3e8d9e21f14131af747210aca4e8d9f8383e7a8fafad38a8c7bcce54dff6883", "searchTerms": [], + "slug": "level-2-ranked", "title": "Level 2 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 2" }, @@ -22695,6 +25020,7 @@ "inactive": false, "legacyFolderID": "Fd14d9ce66056d76c1e154e9b7ad17637a0a3c9c5d09d54f11b7444bb86ee1384", "searchTerms": [], + "slug": "level-20", "title": "Level 20", "where": "chart.level = '20'" }, @@ -22704,6 +25030,7 @@ "inactive": false, "legacyFolderID": "F6852b2a62f7017d5b90ce19845d9ea6a723823c404b87076728dd945719721a4", "searchTerms": [], + "slug": "level-20-all-marathons", "title": "Level 20 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 20 AND (chart.data->>'length')::numeric >= 960" }, @@ -22713,6 +25040,7 @@ "inactive": false, "legacyFolderID": "Fbff7d1a41f9193772fdd49bebbc60278a456e56b23dd7d5b4d932d4df610182a", "searchTerms": [], + "slug": "level-20-ranked-marathons", "title": "Level 20 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 20" }, @@ -22722,6 +25050,7 @@ "inactive": false, "legacyFolderID": "Ffc3a8d38d31fcc9cf81101cfb2feb86c5112eb4b9fe47075e3b8da5cdcd7c45d", "searchTerms": [], + "slug": "level-20-w-unranked", "title": "Level 20 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 20" }, @@ -22731,6 +25060,7 @@ "inactive": false, "legacyFolderID": "F1f9a463fb7843e7d02a22903a17a7209262fd1af88837131c12aec0dfd07d052", "searchTerms": [], + "slug": "level-20-ranked", "title": "Level 20 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 20" }, @@ -22740,6 +25070,7 @@ "inactive": false, "legacyFolderID": "Fa3dac2e3b8186396839524d1334b35aec25da54b1771e7b6cb3cab3761b7a18e", "searchTerms": [], + "slug": "level-21", "title": "Level 21", "where": "chart.level = '21'" }, @@ -22749,6 +25080,7 @@ "inactive": false, "legacyFolderID": "F1d524bbe9f5cd33c2676b065ac9b77df74be4c7e820e12688b4b2bb2e2dccdf2", "searchTerms": [], + "slug": "level-21-all-marathons", "title": "Level 21 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 21 AND (chart.data->>'length')::numeric >= 960" }, @@ -22758,6 +25090,7 @@ "inactive": false, "legacyFolderID": "F4547e80bb44dbbd65b1139ec7f4c36faaac70f6c0ec1b8c1cc2fddb50a5e69aa", "searchTerms": [], + "slug": "level-21-ranked-marathons", "title": "Level 21 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 21" }, @@ -22767,6 +25100,7 @@ "inactive": false, "legacyFolderID": "F2a2be83c93130808dd42e3cc3da92c9e67a5b97e1fdae9500d20d0c6bfbac350", "searchTerms": [], + "slug": "level-21-w-unranked", "title": "Level 21 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 21" }, @@ -22776,6 +25110,7 @@ "inactive": false, "legacyFolderID": "Ff4d74aecd40cfccbde21fd75d08ca25b98173b560f0e41e412ac4b0a0528a204", "searchTerms": [], + "slug": "level-21-ranked", "title": "Level 21 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 21" }, @@ -22785,6 +25120,7 @@ "inactive": false, "legacyFolderID": "F17e333c74eb87310a17967c3387c446b29b69a161df606afd2768efbf813cf6c", "searchTerms": [], + "slug": "level-22", "title": "Level 22", "where": "chart.level = '22'" }, @@ -22794,6 +25130,7 @@ "inactive": false, "legacyFolderID": "F58227d4944d48311f4ffc307436d720d6e232e283806cea7f176ddd00d0e4e9c", "searchTerms": [], + "slug": "level-22-all-marathons", "title": "Level 22 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 22 AND (chart.data->>'length')::numeric >= 960" }, @@ -22803,6 +25140,7 @@ "inactive": false, "legacyFolderID": "F29b57e495f4ea590a7324fd4891106f04bcc94fe0118efb037d56034dce53d9f", "searchTerms": [], + "slug": "level-22-ranked-marathons", "title": "Level 22 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 22" }, @@ -22812,6 +25150,7 @@ "inactive": false, "legacyFolderID": "Fcc2a5503a5e0c0627ca13c8fbe6dacf82bed56d025a77894652bb40e9b99995f", "searchTerms": [], + "slug": "level-22-w-unranked", "title": "Level 22 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 22" }, @@ -22821,6 +25160,7 @@ "inactive": false, "legacyFolderID": "Fa2ed03e9477509d7185ad9991f11006b29f75555b5b713e8fa578aaa77810be3", "searchTerms": [], + "slug": "level-22-ranked", "title": "Level 22 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 22" }, @@ -22830,6 +25170,7 @@ "inactive": false, "legacyFolderID": "F5e10d0ce0ff24db4440e9c9bc049b081d47ade419927772f6afba0e1208e52e7", "searchTerms": [], + "slug": "level-23", "title": "Level 23", "where": "chart.level = '23'" }, @@ -22839,6 +25180,7 @@ "inactive": false, "legacyFolderID": "F1aafd975b9b484e52b706c68e0ba5a950e534a9d501e41774a7944e9df37a0eb", "searchTerms": [], + "slug": "level-23-all-marathons", "title": "Level 23 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 23 AND (chart.data->>'length')::numeric >= 960" }, @@ -22848,6 +25190,7 @@ "inactive": false, "legacyFolderID": "F56d9a5e6d1a6b5cfd60d2adb4fea7886aab92b614e2dd565eb52530e0e47f9ff", "searchTerms": [], + "slug": "level-23-ranked-marathons", "title": "Level 23 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 23" }, @@ -22857,6 +25200,7 @@ "inactive": false, "legacyFolderID": "F139fa033c8f9082e51e94d452732d1e1b6749d5f513717acf440d7cb92a3f49b", "searchTerms": [], + "slug": "level-23-w-unranked", "title": "Level 23 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 23" }, @@ -22866,6 +25210,7 @@ "inactive": false, "legacyFolderID": "F14d97752e0953e267b6ccc96ec493c695b3a9250b9ef6f267d9574aa98e2c47f", "searchTerms": [], + "slug": "level-23-ranked", "title": "Level 23 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 23" }, @@ -22875,6 +25220,7 @@ "inactive": false, "legacyFolderID": "Ff721b0da045fba42848b12f6389ae651a2edcd3a3cbb5a27922d319993397c57", "searchTerms": [], + "slug": "level-24", "title": "Level 24", "where": "chart.level = '24'" }, @@ -22884,6 +25230,7 @@ "inactive": false, "legacyFolderID": "Fb7fc9fb7ddbb841b80b1c781ba71834bb8d9b6764773ae6a9567a42732f87f4a", "searchTerms": [], + "slug": "level-24-all-marathons", "title": "Level 24 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 24 AND (chart.data->>'length')::numeric >= 960" }, @@ -22893,6 +25240,7 @@ "inactive": false, "legacyFolderID": "F1572af46dfc17b32ac0760fb08375ee76cbe1b15a06ac54ce2375656b827e085", "searchTerms": [], + "slug": "level-24-ranked-marathons", "title": "Level 24 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 24" }, @@ -22902,6 +25250,7 @@ "inactive": false, "legacyFolderID": "F5233edf02628bf3bfa8239c610446682ddb7cd6e88501da59aace1436fb5c9c2", "searchTerms": [], + "slug": "level-24-w-unranked", "title": "Level 24 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 24" }, @@ -22911,6 +25260,7 @@ "inactive": false, "legacyFolderID": "F673bfe7cbe6376ca7ca3fe3f97877d4e2350b8f1b09cc22b2cdb1de57c0a7b79", "searchTerms": [], + "slug": "level-24-ranked", "title": "Level 24 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 24" }, @@ -22920,6 +25270,7 @@ "inactive": false, "legacyFolderID": "Ff61b9863219eeaed00e05834879933262f0f80dc25327e3a9df5a3729f7ea0a5", "searchTerms": [], + "slug": "level-25", "title": "Level 25", "where": "chart.level = '25'" }, @@ -22929,6 +25280,7 @@ "inactive": false, "legacyFolderID": "F9efcf89503d3b8dd03ea62e74b80c73dbdcf41671a6a0f96fa2d7f07ee2ad30f", "searchTerms": [], + "slug": "level-25-all-marathons", "title": "Level 25 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 25 AND (chart.data->>'length')::numeric >= 960" }, @@ -22938,6 +25290,7 @@ "inactive": false, "legacyFolderID": "F84e8668fa3e828e9fbbe1622f3a1b7207e3ca5a39285b2d728ecb6a28e2eae76", "searchTerms": [], + "slug": "level-25-ranked-marathons", "title": "Level 25 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 25" }, @@ -22947,6 +25300,7 @@ "inactive": false, "legacyFolderID": "Fc382819019c2595f67404da97c08a0bd7ea04527136f1d611f1c53fe2972e720", "searchTerms": [], + "slug": "level-25-w-unranked", "title": "Level 25 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 25" }, @@ -22956,6 +25310,7 @@ "inactive": false, "legacyFolderID": "F8b356d546789db6ae37bd155f0188445cadfe53014a63c26f11b1de480499feb", "searchTerms": [], + "slug": "level-25-ranked", "title": "Level 25 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 25" }, @@ -22965,6 +25320,7 @@ "inactive": false, "legacyFolderID": "F1149f37af6c9a41cb070d38db9bb97e45c83c232aeb0603fc913de58e139e752", "searchTerms": [], + "slug": "level-26", "title": "Level 26", "where": "chart.level = '26'" }, @@ -22974,6 +25330,7 @@ "inactive": false, "legacyFolderID": "Ff31e6153a3187978b0bdfc00517b58101a1ecb8a68bc419d0e922d6af6fd6c66", "searchTerms": [], + "slug": "level-26-all-marathons", "title": "Level 26 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 26 AND (chart.data->>'length')::numeric >= 960" }, @@ -22983,6 +25340,7 @@ "inactive": false, "legacyFolderID": "F57b4e45d3ee8c78ce24526d0bc260d067971e30dadef2958031364c7fa0c705a", "searchTerms": [], + "slug": "level-26-ranked-marathons", "title": "Level 26 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 26" }, @@ -22992,6 +25350,7 @@ "inactive": false, "legacyFolderID": "F331468cfdd8e99bc8d67eee3840fd60f366f3667871c43ac5e1f8489592cc31e", "searchTerms": [], + "slug": "level-26-w-unranked", "title": "Level 26 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 26" }, @@ -23001,6 +25360,7 @@ "inactive": false, "legacyFolderID": "F2a115e1a35b09e947e2b48bc612a253d83881bf6aa63d7d555f56163b5d8347c", "searchTerms": [], + "slug": "level-26-ranked", "title": "Level 26 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 26" }, @@ -23010,6 +25370,7 @@ "inactive": false, "legacyFolderID": "F9a24f6be0b18f02e75dcb001aee5cb6bd5e20ef1f7968db278d1a936de957f9a", "searchTerms": [], + "slug": "level-27", "title": "Level 27", "where": "chart.level = '27'" }, @@ -23019,6 +25380,7 @@ "inactive": false, "legacyFolderID": "F786d89b39503a5fe962c6e0cadca16c109ed314aa92f30329d37d419be720905", "searchTerms": [], + "slug": "level-27-all-marathons", "title": "Level 27 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 27 AND (chart.data->>'length')::numeric >= 960" }, @@ -23028,6 +25390,7 @@ "inactive": false, "legacyFolderID": "F79c82e1a17173fe351de78279be387c06541c1e9d2fcfe078725d35015f4e05d", "searchTerms": [], + "slug": "level-27-ranked-marathons", "title": "Level 27 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 27" }, @@ -23037,6 +25400,7 @@ "inactive": false, "legacyFolderID": "F2e403c3080fea5d73ab78dc8636d981b7a6be0153d001687d57859bdd503fd37", "searchTerms": [], + "slug": "level-27-w-unranked", "title": "Level 27 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 27" }, @@ -23046,6 +25410,7 @@ "inactive": false, "legacyFolderID": "Fab5457171b9549cfd08936fc3b0d471a32ac18b896a0668d67414992ec340675", "searchTerms": [], + "slug": "level-27-ranked", "title": "Level 27 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 27" }, @@ -23055,6 +25420,7 @@ "inactive": false, "legacyFolderID": "F151321a8c3bc6bf0dcc4e9356c3a943302164522d24308dec6fb302e5509e7b8", "searchTerms": [], + "slug": "level-28", "title": "Level 28", "where": "chart.level = '28'" }, @@ -23064,6 +25430,7 @@ "inactive": false, "legacyFolderID": "F3ae153791f076f5aaae834902484398cfb03808aef493e13d3ca4bd084402b1b", "searchTerms": [], + "slug": "level-28-all-marathons", "title": "Level 28 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 28 AND (chart.data->>'length')::numeric >= 960" }, @@ -23073,6 +25440,7 @@ "inactive": false, "legacyFolderID": "Fb6df21f27cf950d2c01728ac04749317d97dc9f9e68158c342dfd3a3b628caf9", "searchTerms": [], + "slug": "level-28-ranked-marathons", "title": "Level 28 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 28" }, @@ -23082,6 +25450,7 @@ "inactive": false, "legacyFolderID": "F60d7ed3b2dc058d956380db9a08d7955791199d74ff199eb32143ddca5d350c0", "searchTerms": [], + "slug": "level-28-w-unranked", "title": "Level 28 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 28" }, @@ -23091,6 +25460,7 @@ "inactive": false, "legacyFolderID": "F5c4fa3816d7d75cf5314b2bccb0cfa653862dc1d045ecfb879f84da13cadd141", "searchTerms": [], + "slug": "level-28-ranked", "title": "Level 28 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 28" }, @@ -23100,6 +25470,7 @@ "inactive": false, "legacyFolderID": "F5309c48c7b1de84ecf12839b4278ca56fc26ccf5adeb075e3a1c86d46f02d8cb", "searchTerms": [], + "slug": "level-29", "title": "Level 29", "where": "chart.level = '29'" }, @@ -23109,6 +25480,7 @@ "inactive": false, "legacyFolderID": "F05f1541cfac02db29ac74f68259ea734bed9a5197474c2898f8f8b63297a2d0f", "searchTerms": [], + "slug": "level-29-all-marathons", "title": "Level 29 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 29 AND (chart.data->>'length')::numeric >= 960" }, @@ -23118,6 +25490,7 @@ "inactive": false, "legacyFolderID": "F8da72c15bda939efad064ac40d24b35ccdf42e1bff852ae3aa59cbf425bb288c", "searchTerms": [], + "slug": "level-29-ranked-marathons", "title": "Level 29 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 29" }, @@ -23127,6 +25500,7 @@ "inactive": false, "legacyFolderID": "Fb39173281bece4ab54bea4626e311257a97c62fd4711a4993f6291fa9507be2b", "searchTerms": [], + "slug": "level-29-w-unranked", "title": "Level 29 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 29" }, @@ -23136,6 +25510,7 @@ "inactive": false, "legacyFolderID": "Fc573763942305afb4d151ce15e55fa416c9d15b8508b94b8548910639b85be3f", "searchTerms": [], + "slug": "level-29-ranked", "title": "Level 29 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 29" }, @@ -23145,6 +25520,7 @@ "inactive": false, "legacyFolderID": "F146a18188f55b5639e7e3818b669a639c8eead5333c33a0b6bff750452545093", "searchTerms": [], + "slug": "level-3", "title": "Level 3", "where": "chart.level = '3'" }, @@ -23154,6 +25530,7 @@ "inactive": false, "legacyFolderID": "F112eb5659c6d3f24a20e7821aa9fc26d00c30a7b8f9efd15954dcc620f086bd7", "searchTerms": [], + "slug": "level-3-all-marathons", "title": "Level 3 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 3 AND (chart.data->>'length')::numeric >= 960" }, @@ -23163,6 +25540,7 @@ "inactive": false, "legacyFolderID": "Fb54c19ca3c89c98f190a1bc0bbb9fa1a00e0df6662eee751814cb2fa96c9ff95", "searchTerms": [], + "slug": "level-3-ranked-marathons", "title": "Level 3 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 3" }, @@ -23172,6 +25550,7 @@ "inactive": false, "legacyFolderID": "F447ebe882d4bac7cb23733f77efc5873592ea8341fe61b3a462319f9b0c1930e", "searchTerms": [], + "slug": "level-3-w-unranked", "title": "Level 3 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 3" }, @@ -23181,6 +25560,7 @@ "inactive": false, "legacyFolderID": "F6a2f5c31fcdcb3514105e7583526e5b066894d60a82b897bd415738d1421ab9c", "searchTerms": [], + "slug": "level-3-ranked", "title": "Level 3 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 3" }, @@ -23190,6 +25570,7 @@ "inactive": false, "legacyFolderID": "Fd918e7b91caca2090bac84d30170d661e5904d025eb3235fe68bf02e1df87eff", "searchTerms": [], + "slug": "level-30", "title": "Level 30", "where": "chart.level = '30'" }, @@ -23199,6 +25580,7 @@ "inactive": false, "legacyFolderID": "F4ac631ce8982d215f5411e1e4922feb163297aa0cdc5c73de996e2d3f360360d", "searchTerms": [], + "slug": "level-30-all-marathons", "title": "Level 30 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 30 AND (chart.data->>'length')::numeric >= 960" }, @@ -23208,6 +25590,7 @@ "inactive": false, "legacyFolderID": "Fa42db72b8f649db7a692e3aa3c5f5e49de15dcd445bbcc10c874bb74d30f6ce2", "searchTerms": [], + "slug": "level-30-ranked-marathons", "title": "Level 30 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 30" }, @@ -23217,6 +25600,7 @@ "inactive": false, "legacyFolderID": "F5a3c619981f2aacacb6305350ee6bfdf54f6db4f6b7d3a90023cbaa1ecc60ca1", "searchTerms": [], + "slug": "level-30-w-unranked", "title": "Level 30 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 30" }, @@ -23226,6 +25610,7 @@ "inactive": false, "legacyFolderID": "F49de9dabbb64db654f73c0bfec31050d9d44ba10cc07d4df937523446c103611", "searchTerms": [], + "slug": "level-30-ranked", "title": "Level 30 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 30" }, @@ -23235,6 +25620,7 @@ "inactive": false, "legacyFolderID": "Fb9a9159153c0d464312d83161f151874f75daf2d8ac307eba495e04d303eba15", "searchTerms": [], + "slug": "level-31", "title": "Level 31", "where": "chart.level = '31'" }, @@ -23244,6 +25630,7 @@ "inactive": false, "legacyFolderID": "Ff3c7543ab1a114dbedfc959fffcc9f4c0ddeb12eebe31122cb0bcef7bc5fde04", "searchTerms": [], + "slug": "level-31-all-marathons", "title": "Level 31 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 31 AND (chart.data->>'length')::numeric >= 960" }, @@ -23253,6 +25640,7 @@ "inactive": false, "legacyFolderID": "Fa4a387c67ac03e747cebf6fd0c4440f9fa8ecbcfa76f8300674de36e9ccbd7f4", "searchTerms": [], + "slug": "level-31-ranked-marathons", "title": "Level 31 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 31" }, @@ -23262,6 +25650,7 @@ "inactive": false, "legacyFolderID": "F96ab412305035ebee0bb9c8731b2f5a0829c1b2034853033dccb37c0b98fbf1b", "searchTerms": [], + "slug": "level-31-w-unranked", "title": "Level 31 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 31" }, @@ -23271,6 +25660,7 @@ "inactive": false, "legacyFolderID": "F45ef5a193986b548f2e46ae719340c7450b0eb40c1ad0ac1fe46fd0db189d46d", "searchTerms": [], + "slug": "level-31-ranked", "title": "Level 31 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 31" }, @@ -23280,6 +25670,7 @@ "inactive": false, "legacyFolderID": "F94004145dedb9137a256cd889f84ddc05a841d09c86314635bb140ae0b5eb962", "searchTerms": [], + "slug": "level-32", "title": "Level 32", "where": "chart.level = '32'" }, @@ -23289,6 +25680,7 @@ "inactive": false, "legacyFolderID": "Ff94dcfad2eac247dbc103e3e932b9813f519e92aabe49120669aba57fe4f2062", "searchTerms": [], + "slug": "level-32-all-marathons", "title": "Level 32 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 32 AND (chart.data->>'length')::numeric >= 960" }, @@ -23298,6 +25690,7 @@ "inactive": false, "legacyFolderID": "Fb8e16c5c07da400b682bc904524e7d6b27ddf0e7f486db13aa27cd2ae7eca615", "searchTerms": [], + "slug": "level-32-ranked-marathons", "title": "Level 32 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 32" }, @@ -23307,6 +25700,7 @@ "inactive": false, "legacyFolderID": "F0924b3014f8f70c94dfd590d69efde0c3a892756a108ea108484b85b42e07677", "searchTerms": [], + "slug": "level-32-w-unranked", "title": "Level 32 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 32" }, @@ -23316,6 +25710,7 @@ "inactive": false, "legacyFolderID": "Fe91349acaf5a5a30887887e0980c4673d9e0506d9c32cee33b7ad1221da58321", "searchTerms": [], + "slug": "level-32-ranked", "title": "Level 32 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 32" }, @@ -23325,6 +25720,7 @@ "inactive": false, "legacyFolderID": "Fe2425e0e782cc4cb377cf455f64e48fcb402319b50d96f7516b74597cabd89b4", "searchTerms": [], + "slug": "level-33", "title": "Level 33", "where": "chart.level = '33'" }, @@ -23334,6 +25730,7 @@ "inactive": false, "legacyFolderID": "Ffaeb2b49c97e5ded99ea36536c16ec810965b708be38ff6e498a06ded61c32a9", "searchTerms": [], + "slug": "level-33-all-marathons", "title": "Level 33 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 33 AND (chart.data->>'length')::numeric >= 960" }, @@ -23343,6 +25740,7 @@ "inactive": false, "legacyFolderID": "Faccfb8204bd0e40938911c341b5c792f3cc1f45b144a3bbd58551819c7a34251", "searchTerms": [], + "slug": "level-33-ranked-marathons", "title": "Level 33 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 33" }, @@ -23352,6 +25750,7 @@ "inactive": false, "legacyFolderID": "F58da037dbb72fcd7b6b9367c8b035bc675cd3c9ae14c16026e1f7f0b76b368f0", "searchTerms": [], + "slug": "level-33-w-unranked", "title": "Level 33 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 33" }, @@ -23361,6 +25760,7 @@ "inactive": false, "legacyFolderID": "F9ac927e036f9ec26a18f1549fb50f8707e1dce715b6c046bd29f861da64a01f8", "searchTerms": [], + "slug": "level-33-ranked", "title": "Level 33 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 33" }, @@ -23370,6 +25770,7 @@ "inactive": false, "legacyFolderID": "Ff726570cb7c16ec094bee2e835029392e58d07092d1364020bcb41a441b3d7d2", "searchTerms": [], + "slug": "level-34", "title": "Level 34", "where": "chart.level = '34'" }, @@ -23379,6 +25780,7 @@ "inactive": false, "legacyFolderID": "F40e5e80aad0d6778ec523df3fb3fb2d3eaea13b76ef8019121ac2711c94bde8c", "searchTerms": [], + "slug": "level-34-all-marathons", "title": "Level 34 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 34 AND (chart.data->>'length')::numeric >= 960" }, @@ -23388,6 +25790,7 @@ "inactive": false, "legacyFolderID": "F780539f970b6efa592d77466a5197c6e7e44966bafc02c6749bebd92dc7c49a8", "searchTerms": [], + "slug": "level-34-ranked-marathons", "title": "Level 34 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 34" }, @@ -23397,6 +25800,7 @@ "inactive": false, "legacyFolderID": "F8be6516034c48b5653150e6372783df0c11226ba969562219c567bf6c3a80dd1", "searchTerms": [], + "slug": "level-34-w-unranked", "title": "Level 34 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 34" }, @@ -23406,6 +25810,7 @@ "inactive": false, "legacyFolderID": "F5101b7cc12c4612d664b3e8228bb039647abd6f89523ce8be4e212a7622c23a0", "searchTerms": [], + "slug": "level-34-ranked", "title": "Level 34 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 34" }, @@ -23415,6 +25820,7 @@ "inactive": false, "legacyFolderID": "Fe6462467d5bef6fcb82a42d0bb5ec96f6d082e578e5f213ae556197d0a56ebf4", "searchTerms": [], + "slug": "level-35", "title": "Level 35", "where": "chart.level = '35'" }, @@ -23424,6 +25830,7 @@ "inactive": false, "legacyFolderID": "F672f44e5a8fc62277e4ff25a9bcd0711a52c1ae23c56c43b0daee9595bf5b6d5", "searchTerms": [], + "slug": "level-35-all-marathons", "title": "Level 35 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 35 AND (chart.data->>'length')::numeric >= 960" }, @@ -23433,6 +25840,7 @@ "inactive": false, "legacyFolderID": "Fa122111aa39437d7cf9ee6612f1feae2c28521078ac406e77179ddaff690ddda", "searchTerms": [], + "slug": "level-35-ranked-marathons", "title": "Level 35 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 35" }, @@ -23442,6 +25850,7 @@ "inactive": false, "legacyFolderID": "F99203afec3a786f09afcd24347487503eb0724b36680d258018756329cb77571", "searchTerms": [], + "slug": "level-35-w-unranked", "title": "Level 35 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 35" }, @@ -23451,6 +25860,7 @@ "inactive": false, "legacyFolderID": "F2f70105964c212da135a89aaa4b48ae1e1f0cf9cea92628542bdf5b2c3fe8d69", "searchTerms": [], + "slug": "level-35-ranked", "title": "Level 35 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 35" }, @@ -23460,6 +25870,7 @@ "inactive": false, "legacyFolderID": "F3011ee48d3a18011351fe4601015323baa20688e9fb7bbd0ffc2686cb8545123", "searchTerms": [], + "slug": "level-36", "title": "Level 36", "where": "chart.level = '36'" }, @@ -23469,6 +25880,7 @@ "inactive": false, "legacyFolderID": "F1b73ca0c0284f044c5cddc7fb7f9b7d7a69a0def21b8c5e7a98f648715cf7aac", "searchTerms": [], + "slug": "level-36-all-marathons", "title": "Level 36 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 36 AND (chart.data->>'length')::numeric >= 960" }, @@ -23478,6 +25890,7 @@ "inactive": false, "legacyFolderID": "Ffacb311132b7553ade3bc7f486e8b230ad16b80db1ef062bb4c7f4d3d8a11b44", "searchTerms": [], + "slug": "level-36-ranked-marathons", "title": "Level 36 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 36" }, @@ -23487,6 +25900,7 @@ "inactive": false, "legacyFolderID": "F46847271d141d775a355bbb6decb26d33b9caabefb14e4ca039770c08fa5d417", "searchTerms": [], + "slug": "level-36-w-unranked", "title": "Level 36 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 36" }, @@ -23496,6 +25910,7 @@ "inactive": false, "legacyFolderID": "Fef670256df259ada230ca7078f779326f8cc342a60746ae9099f21d6ce0e4573", "searchTerms": [], + "slug": "level-36-ranked", "title": "Level 36 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 36" }, @@ -23505,6 +25920,7 @@ "inactive": false, "legacyFolderID": "Fa5d6369b41214b7546ff1700dae7a6c2b9da05d1177f928ff04203133414c168", "searchTerms": [], + "slug": "level-37", "title": "Level 37", "where": "chart.level = '37'" }, @@ -23514,6 +25930,7 @@ "inactive": false, "legacyFolderID": "Fecaa33dc584b086dc24ca1d1362f167bacfeacf0d78cde88829d50421e0270ec", "searchTerms": [], + "slug": "level-37-all-marathons", "title": "Level 37 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 37 AND (chart.data->>'length')::numeric >= 960" }, @@ -23523,6 +25940,7 @@ "inactive": false, "legacyFolderID": "F85ad4119af2a756b0a94aaa3f114ef5e8445cfea5ea817493e59891c4d81e351", "searchTerms": [], + "slug": "level-37-ranked-marathons", "title": "Level 37 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 37" }, @@ -23532,6 +25950,7 @@ "inactive": false, "legacyFolderID": "F82f4f7673d2bdc3a19bbad68ab7a1b6c06871f4e34b5956797a95b09bb422e88", "searchTerms": [], + "slug": "level-37-w-unranked", "title": "Level 37 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 37" }, @@ -23541,6 +25960,7 @@ "inactive": false, "legacyFolderID": "Fafd73d12e88df7aeb269e5b51caa6e6760f0fc49eacb2165dd8f1b167575aa89", "searchTerms": [], + "slug": "level-37-ranked", "title": "Level 37 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 37" }, @@ -23550,6 +25970,7 @@ "inactive": false, "legacyFolderID": "F7598158525d26deb2c26b8727991f91992ea301778d0faa9e5ed37fa40daf5df", "searchTerms": [], + "slug": "level-38", "title": "Level 38", "where": "chart.level = '38'" }, @@ -23559,6 +25980,7 @@ "inactive": false, "legacyFolderID": "Fd0ec4758888b7c1fa7824f89fc6a341afbd3bd52d1abaf9b5e43da555e47aacb", "searchTerms": [], + "slug": "level-38-all-marathons", "title": "Level 38 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 38 AND (chart.data->>'length')::numeric >= 960" }, @@ -23568,6 +25990,7 @@ "inactive": false, "legacyFolderID": "Ffeb4915fe4f7600a9627b036f8a21ce2e9562a4444f11285522d5b7e66492945", "searchTerms": [], + "slug": "level-38-ranked-marathons", "title": "Level 38 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 38" }, @@ -23577,6 +26000,7 @@ "inactive": false, "legacyFolderID": "F4f08a888a9415b5513ebcc4cf8a559d5ce01feea057c676d52d3de6e31d0b3d4", "searchTerms": [], + "slug": "level-38-w-unranked", "title": "Level 38 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 38" }, @@ -23586,6 +26010,7 @@ "inactive": false, "legacyFolderID": "Fcf02d15b36d4e93860fc36d5220325742cefd54f5a024e8fecbfb1db1f72a1f7", "searchTerms": [], + "slug": "level-38-ranked", "title": "Level 38 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 38" }, @@ -23595,6 +26020,7 @@ "inactive": false, "legacyFolderID": "F6229db8f31ee57d9b1afac71b1c7ebd5ea311f9c0b28bfef205a5fa81482fdd7", "searchTerms": [], + "slug": "level-39", "title": "Level 39", "where": "chart.level = '39'" }, @@ -23604,6 +26030,7 @@ "inactive": false, "legacyFolderID": "Fce3434fed654038a2521aac6b17cc7377f3bde92c74b1b48fad9c1d526e74a7f", "searchTerms": [], + "slug": "level-39-all-marathons", "title": "Level 39 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 39 AND (chart.data->>'length')::numeric >= 960" }, @@ -23613,6 +26040,7 @@ "inactive": false, "legacyFolderID": "Fbc111eea562636a1d8d5a004bb5f021ee88d2373209e7ddc5b897159a98d65cc", "searchTerms": [], + "slug": "level-39-ranked-marathons", "title": "Level 39 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 39" }, @@ -23622,6 +26050,7 @@ "inactive": false, "legacyFolderID": "F7c837a40bfe717f155f1da9efe713824196b7963d2b6fb3c24922441b05a0a81", "searchTerms": [], + "slug": "level-39-w-unranked", "title": "Level 39 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 39" }, @@ -23631,6 +26060,7 @@ "inactive": false, "legacyFolderID": "Fe0458b8a2b024f7a5a8b7c114f3fc4645535dec8746083770dc1841d1832c527", "searchTerms": [], + "slug": "level-39-ranked", "title": "Level 39 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 39" }, @@ -23640,6 +26070,7 @@ "inactive": false, "legacyFolderID": "F60737d537fa7799432103bbbbcec1c8c0d51e77269a901ded7ae96a5fb990b0a", "searchTerms": [], + "slug": "level-4", "title": "Level 4", "where": "chart.level = '4'" }, @@ -23649,6 +26080,7 @@ "inactive": false, "legacyFolderID": "F56dca6c010fa2102cbdf7b0fad49f0ce06bfb78f6fb479fb1bb22d35cf50cf86", "searchTerms": [], + "slug": "level-4-all-marathons", "title": "Level 4 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 4 AND (chart.data->>'length')::numeric >= 960" }, @@ -23658,6 +26090,7 @@ "inactive": false, "legacyFolderID": "F21e3d5cbde06d08f3cb9a0501f97e77680e66fc3738ceb748bd6de78aea18e60", "searchTerms": [], + "slug": "level-4-ranked-marathons", "title": "Level 4 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 4" }, @@ -23667,6 +26100,7 @@ "inactive": false, "legacyFolderID": "F5fb820185a46ab04009fbf08849cd1111ba3cb2e1afa22997f4118548e9daaa8", "searchTerms": [], + "slug": "level-4-w-unranked", "title": "Level 4 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 4" }, @@ -23676,6 +26110,7 @@ "inactive": false, "legacyFolderID": "F3a8dc216a823fcc6edd94b34d9ea3b8fca98007967381e1128bb4c7f2b03cd02", "searchTerms": [], + "slug": "level-4-ranked", "title": "Level 4 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 4" }, @@ -23685,6 +26120,7 @@ "inactive": false, "legacyFolderID": "Fabdff2fd35ba6553fee25e1c64c1af45bfca5c10c1320d7d798b7feefdd2d3eb", "searchTerms": [], + "slug": "level-5", "title": "Level 5", "where": "chart.level = '5'" }, @@ -23694,6 +26130,7 @@ "inactive": false, "legacyFolderID": "F6237e4dbe170f6e88ba14413cb40021960ddee8a9435fc8974bb03d336e9cb55", "searchTerms": [], + "slug": "level-5-all-marathons", "title": "Level 5 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 5 AND (chart.data->>'length')::numeric >= 960" }, @@ -23703,6 +26140,7 @@ "inactive": false, "legacyFolderID": "F0094d6b9cad3c9d19e21516d2296671a95b9d8e78b8fbc9b5dd64b48ad8298b2", "searchTerms": [], + "slug": "level-5-ranked-marathons", "title": "Level 5 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 5" }, @@ -23712,6 +26150,7 @@ "inactive": false, "legacyFolderID": "F8af2473e21682473f0377b57b2929ef57bfcf22bdc6381c7260b3e3c20d7c555", "searchTerms": [], + "slug": "level-5-w-unranked", "title": "Level 5 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 5" }, @@ -23721,6 +26160,7 @@ "inactive": false, "legacyFolderID": "Fdaf60b117a3dc64b67f40b845eaa148f75246ad1107213548f66126acfc31e0f", "searchTerms": [], + "slug": "level-5-ranked", "title": "Level 5 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 5" }, @@ -23730,6 +26170,7 @@ "inactive": false, "legacyFolderID": "F70a1b21e00e80403c584d1b74f420dc44d471ce3cc141e019ac2960ba757d044", "searchTerms": [], + "slug": "level-6", "title": "Level 6", "where": "chart.level = '6'" }, @@ -23739,6 +26180,7 @@ "inactive": false, "legacyFolderID": "F9ca0bd16caf536c5f803623c130f64af2aa17ae73bbfd8d19296cde5a03142a1", "searchTerms": [], + "slug": "level-6-all-marathons", "title": "Level 6 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 6 AND (chart.data->>'length')::numeric >= 960" }, @@ -23748,6 +26190,7 @@ "inactive": false, "legacyFolderID": "F9dee6910078b7a82e5db6c883aeb104ffce1c35e79698473dd01daa4cb36c13e", "searchTerms": [], + "slug": "level-6-ranked-marathons", "title": "Level 6 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 6" }, @@ -23757,6 +26200,7 @@ "inactive": false, "legacyFolderID": "F25511e8a643e1221acd9277cf096e804f3c485bb72ab9a91807c6d3197d540dd", "searchTerms": [], + "slug": "level-6-w-unranked", "title": "Level 6 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 6" }, @@ -23766,6 +26210,7 @@ "inactive": false, "legacyFolderID": "F6b6b7ad5bcb56326101d0c7dade2b8dbd9c9b2869b8d50cfd9e1b0424b18eb12", "searchTerms": [], + "slug": "level-6-ranked", "title": "Level 6 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 6" }, @@ -23775,6 +26220,7 @@ "inactive": false, "legacyFolderID": "F9c55536987325957693cacad362b326d316a211a7ef7dbf5c383efc7d3c977b8", "searchTerms": [], + "slug": "level-7", "title": "Level 7", "where": "chart.level = '7'" }, @@ -23784,6 +26230,7 @@ "inactive": false, "legacyFolderID": "Fd8ac9666244b3411e15ddeec287d8b35d679382c19343f8b27266b0738f061b8", "searchTerms": [], + "slug": "level-7-all-marathons", "title": "Level 7 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 7 AND (chart.data->>'length')::numeric >= 960" }, @@ -23793,6 +26240,7 @@ "inactive": false, "legacyFolderID": "F5da914f3f8ef6c5d76a32762807e413e16e752eb654fa92293c8cad13993d585", "searchTerms": [], + "slug": "level-7-ranked-marathons", "title": "Level 7 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 7" }, @@ -23802,6 +26250,7 @@ "inactive": false, "legacyFolderID": "Fd04f6d26007ddbf309c8d09d42f747e0efcc619afd3b58a7c46808719dc25839", "searchTerms": [], + "slug": "level-7-w-unranked", "title": "Level 7 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 7" }, @@ -23811,6 +26260,7 @@ "inactive": false, "legacyFolderID": "F4a3c0abd12085b52fd76fc98c004d64d4daebaa0d9828a87441e67723fb610e0", "searchTerms": [], + "slug": "level-7-ranked", "title": "Level 7 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 7" }, @@ -23820,6 +26270,7 @@ "inactive": false, "legacyFolderID": "F40a52df9c8aeb941ba2d0092d224503bced6ba02df410a6454e63e38207f0d28", "searchTerms": [], + "slug": "level-8", "title": "Level 8", "where": "chart.level = '8'" }, @@ -23829,6 +26280,7 @@ "inactive": false, "legacyFolderID": "Fd9ef9de19ef410606fead5520100caea590dd3623914c395f5469752cceb49ee", "searchTerms": [], + "slug": "level-8-all-marathons", "title": "Level 8 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 8 AND (chart.data->>'length')::numeric >= 960" }, @@ -23838,6 +26290,7 @@ "inactive": false, "legacyFolderID": "Ff92e96dca83c4c2375c2ae0a208236a05fbd1c3325f18014abb2301144c3f199", "searchTerms": [], + "slug": "level-8-ranked-marathons", "title": "Level 8 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 8" }, @@ -23847,6 +26300,7 @@ "inactive": false, "legacyFolderID": "Fbecaf61c7d16330f05d9e15f3983dd1ea869f017b6e6f65f35a0eb230ced8fea", "searchTerms": [], + "slug": "level-8-w-unranked", "title": "Level 8 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 8" }, @@ -23856,6 +26310,7 @@ "inactive": false, "legacyFolderID": "Fa323c8560d249b54209acec17a5a8d5a2fbc92c85841f5cd02c03eb086a7adb7", "searchTerms": [], + "slug": "level-8-ranked", "title": "Level 8 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 8" }, @@ -23865,6 +26320,7 @@ "inactive": false, "legacyFolderID": "F00b57c9af97f0b1b2bb6f8140612a05b913649b907cef973dc21cb6aa88524ee", "searchTerms": [], + "slug": "level-9", "title": "Level 9", "where": "chart.level = '9'" }, @@ -23874,6 +26330,7 @@ "inactive": false, "legacyFolderID": "Fdf70af41a86770b7b758fa39f4332f6f10a27c4e2a45045e12dc292854536b5d", "searchTerms": [], + "slug": "level-9-all-marathons", "title": "Level 9 (All Marathons)", "where": "(chart.data->>'chartLevel')::numeric = 9 AND (chart.data->>'length')::numeric >= 960" }, @@ -23883,6 +26340,7 @@ "inactive": false, "legacyFolderID": "Fcb8c0fefdaae8563e4181443d6a06a48273f0a33ad4825a426fbaa2b649e4adf", "searchTerms": [], + "slug": "level-9-ranked-marathons", "title": "Level 9 (Ranked Marathons)", "where": "(chart.data->>'length')::numeric >= 960 AND (chart.data->>'rankedLevel')::numeric = 9" }, @@ -23892,6 +26350,7 @@ "inactive": false, "legacyFolderID": "F9ddd066f29658f5139335d8bdc15a413c87688963088093460ed3831223e8dda", "searchTerms": [], + "slug": "level-9-w-unranked", "title": "Level 9 (w/ Unranked)", "where": "(chart.data->>'chartLevel')::numeric = 9" }, @@ -23901,6 +26360,7 @@ "inactive": false, "legacyFolderID": "Fa38a3bb9a8fdbc2b891cf35abc8b7df65d443f528af200d9dde4866d56167bec", "searchTerms": [], + "slug": "level-9-ranked", "title": "Level 9 Ranked", "where": "(chart.data->>'rankedLevel')::numeric = 9" }, @@ -23910,9 +26370,10 @@ "inactive": false, "legacyFolderID": "F4b9110f2e0428df39a2b3e4fec6a3ac2b658b3dc7d11b892ca41da657f820b6b", "searchTerms": [], + "slug": "1-normal-ave", "title": "Level 1 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -23920,9 +26381,10 @@ "inactive": true, "legacyFolderID": "Fc0784a113cc997075ea210d7ce090a1dfab3f4b2b2a372a5f2450047a696b351", "searchTerms": [], + "slug": "1-normal-copious", "title": "Level 1 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -23930,9 +26392,10 @@ "inactive": false, "legacyFolderID": "Fd42120f5899f2ee2f2101a5719dd8181d46b3ad0fe6a582bb5dee56c8f9b0114", "searchTerms": [], + "slug": "1-normal-festo-omni", "title": "Level 1 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -23940,9 +26403,10 @@ "inactive": false, "legacyFolderID": "Fbe17de215244962c083398520ddb98f29478944c57712b9e5f9ed0dbc9a45541", "searchTerms": [], + "slug": "1-normal-festo", "title": "Level 1 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -23950,9 +26414,10 @@ "inactive": false, "legacyFolderID": "F154fea3c2a743e07e41e3e677731c5d166fae7fad318b223705c276bffcd643a", "searchTerms": [], + "slug": "1-hard-ave", "title": "Level 1 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -23960,9 +26425,10 @@ "inactive": true, "legacyFolderID": "Fac03f7b11915bf10f2a4289bbb5fda70140283061c9364e5a5a9ce5ebca86ebd", "searchTerms": [], + "slug": "1-hard-copious", "title": "Level 1 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -23970,9 +26436,10 @@ "inactive": false, "legacyFolderID": "Fb7a796f263ddb980032fcb7aa8e3910501498b14b12a8314993bee1fb3cb3a45", "searchTerms": [], + "slug": "1-hard-festo-omni", "title": "Level 1 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -23980,9 +26447,10 @@ "inactive": false, "legacyFolderID": "F2cbdc30e472064793d0193ef22960f0c5f239b9e9efa240d742a42c245da4d35", "searchTerms": [], + "slug": "1-hard-festo", "title": "Level 1 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -23990,9 +26458,10 @@ "inactive": true, "legacyFolderID": "Fc46391cd2177a8dd8f318f79ef5b95178412b4606f9ceaa8140858f1417fb089", "searchTerms": [], + "slug": "1-hard-jubeat", "title": "Level 1 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24000,9 +26469,10 @@ "inactive": true, "legacyFolderID": "Fc8fcd5cfa79968038bff0dc40d6ca191520ee66711379a4a3ae0c47f4ee329ad", "searchTerms": [], + "slug": "1-hard-knit", "title": "Level 1 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24010,9 +26480,10 @@ "inactive": true, "legacyFolderID": "Fd3303d4bbc7972ec3d3849e8d3d8dfe9fdbd70791ceab19a3e71b5bfa0ec5203", "searchTerms": [], + "slug": "1-hard-prop", "title": "Level 1 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24020,9 +26491,10 @@ "inactive": true, "legacyFolderID": "Fec94ea50ef82303a8a85166026f410f7de64632ddf4adaf806851c2a794c1163", "searchTerms": [], + "slug": "1-hard-qubell", "title": "Level 1 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24030,9 +26502,10 @@ "inactive": true, "legacyFolderID": "F95b208a86876daf8f1d1da63f98ebc7379796aa89bbf8f190baaa59c1082af75", "searchTerms": [], + "slug": "1-hard-ripples", "title": "Level 1 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24040,9 +26513,10 @@ "inactive": true, "legacyFolderID": "F369457c314ab8bae31ebf1f1b61cdbd5ccf690cd0ebc541b72efbb1f1ac8d803", "searchTerms": [], + "slug": "1-hard-saucer", "title": "Level 1 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24050,9 +26524,10 @@ "inactive": true, "legacyFolderID": "F4f78764233f3fac9585dc76f2aece8ce51d5b6728b8bec58b83adddfd0ec362b", "searchTerms": [], + "slug": "1-normal-jubeat", "title": "Level 1 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24060,9 +26535,10 @@ "inactive": true, "legacyFolderID": "F8ba292905e2b3cefac3542da62c193c7e264b022e40a6abbe22c57cc0806d86a", "searchTerms": [], + "slug": "1-normal-knit", "title": "Level 1 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24070,9 +26546,10 @@ "inactive": true, "legacyFolderID": "Fc74533fecaa88b797e197efa4d4fb9c6998acea07326fa272ad0f9871a1e611c", "searchTerms": [], + "slug": "1-normal-prop", "title": "Level 1 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24080,9 +26557,10 @@ "inactive": true, "legacyFolderID": "F2de8189a350f94793379cf2007d4098bbff3572ca8f2ad9957efe47ecbce8c38", "searchTerms": [], + "slug": "1-normal-qubell", "title": "Level 1 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24090,9 +26568,10 @@ "inactive": true, "legacyFolderID": "F6f815bd32f7e4ce62c7100bd49f1637a813a5de4458cb64630aa107e33ff1562", "searchTerms": [], + "slug": "1-normal-ripples", "title": "Level 1 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24100,9 +26579,10 @@ "inactive": true, "legacyFolderID": "Fb6e07361e854689fdd207ba44b35cc5d8a7b738f07e285202d82ee0ee7ad971a", "searchTerms": [], + "slug": "1-normal-saucer", "title": "Level 1 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '1'" }, { "game": "jubeat", @@ -24110,9 +26590,10 @@ "inactive": false, "legacyFolderID": "F0f5c219ac17133a199181def798cd1d676f0ed9b2d1641c00f26cab390521d5b", "searchTerms": [], + "slug": "10p0-normal-ave", "title": "Level 10.0 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.0'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.0'" }, { "game": "jubeat", @@ -24120,9 +26601,10 @@ "inactive": false, "legacyFolderID": "Ff1b3d62d8857cebdca0425f71f6e0dc7dd3ad16a461085c37c283e91e93d5e21", "searchTerms": [], + "slug": "10p0-normal-festo-omni", "title": "Level 10.0 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.0'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.0'" }, { "game": "jubeat", @@ -24130,9 +26612,10 @@ "inactive": false, "legacyFolderID": "Fb5c73b49dbd62343dde9f3b1850e798c1e4c117521788a0a4c4e3baa8ef87170", "searchTerms": [], + "slug": "10p0-normal-festo", "title": "Level 10.0 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.0'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.0'" }, { "game": "jubeat", @@ -24140,9 +26623,10 @@ "inactive": false, "legacyFolderID": "F667c83689ef39e66000a1c418093df8049e6d4497b0f58670f25749a98a36971", "searchTerms": [], + "slug": "10p0-hard-ave", "title": "Level 10.0 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.0'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.0'" }, { "game": "jubeat", @@ -24150,9 +26634,10 @@ "inactive": false, "legacyFolderID": "F0559c414517e9662aa05ce4af788acde0dd5ed8d32b6dc3c636352c0bd975d26", "searchTerms": [], + "slug": "10p0-hard-festo-omni", "title": "Level 10.0 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.0'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.0'" }, { "game": "jubeat", @@ -24160,9 +26645,10 @@ "inactive": false, "legacyFolderID": "F51a465409b26d1c700a99f06b10f6d1ac1a116ff733ec6aeaf11cb42f0519097", "searchTerms": [], + "slug": "10p0-hard-festo", "title": "Level 10.0 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.0'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.0'" }, { "game": "jubeat", @@ -24170,9 +26656,10 @@ "inactive": true, "legacyFolderID": "F1f091b003bf7bf48a6a136a1ff6766979ac5976bd4b533cf06630d6a265e513f", "searchTerms": [], + "slug": "10p0-hard-qubell", "title": "Level 10.0 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.0'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.0'" }, { "game": "jubeat", @@ -24180,9 +26667,10 @@ "inactive": true, "legacyFolderID": "Fc1d8754193899290272e0a3fcecab6426ef952e647f20db403a10a1ceda890b6", "searchTerms": [], + "slug": "10p0-normal-qubell", "title": "Level 10.0 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.0'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.0'" }, { "game": "jubeat", @@ -24190,9 +26678,10 @@ "inactive": false, "legacyFolderID": "Fa402abf77d196f97c3dc2bca5df67e4926edc7626819eb0ab77330640c08ada1", "searchTerms": [], + "slug": "ge-10-lt-11-normal-ave", "title": "Level 10.0-10.9 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24200,9 +26689,10 @@ "inactive": true, "legacyFolderID": "Fa586873ded00c8ccd79df99c6f257cfb42e349712ff51d7a829ea48e3dadc8d8", "searchTerms": [], + "slug": "ge-10-lt-11-normal-copious", "title": "Level 10.0-10.9 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24210,9 +26700,10 @@ "inactive": false, "legacyFolderID": "Fe7d3d0d211ecba31ddbd7a929cc8baf261a402d6817cf4ce71e97ee2089fddb1", "searchTerms": [], + "slug": "ge-10-lt-11-normal-festo-omni", "title": "Level 10.0-10.9 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24220,9 +26711,10 @@ "inactive": false, "legacyFolderID": "Fe06b58eef982542a2caff985de0d07a0fe25ca3f883de0031e83a03f32311e9c", "searchTerms": [], + "slug": "ge-10-lt-11-normal-festo", "title": "Level 10.0-10.9 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24230,9 +26722,10 @@ "inactive": false, "legacyFolderID": "F1303c1914ecacdef338781ffe219b7282a63b4db638cdc3ec1fe7827a8d4b9b3", "searchTerms": [], + "slug": "ge-10-lt-11-hard-ave", "title": "Level 10.0-10.9 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24240,9 +26733,10 @@ "inactive": true, "legacyFolderID": "F3a684c28310008bc15e7dfe3dd39d08b3911934a40a3c16073dc05eaeca251e4", "searchTerms": [], + "slug": "ge-10-lt-11-hard-copious", "title": "Level 10.0-10.9 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24250,9 +26744,10 @@ "inactive": false, "legacyFolderID": "Ff584621ef31a7a6bc7a97e345d30e77656ab0b5a4a66a03995f0132dcc1f72f7", "searchTerms": [], + "slug": "ge-10-lt-11-hard-festo-omni", "title": "Level 10.0-10.9 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24260,9 +26755,10 @@ "inactive": false, "legacyFolderID": "Fe178d04b7187e34d784be4c3bb03d5bad04d3934d3deceeaaaab2113934aad23", "searchTerms": [], + "slug": "ge-10-lt-11-hard-festo", "title": "Level 10.0-10.9 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24270,9 +26766,10 @@ "inactive": true, "legacyFolderID": "Fb16f4a643a42b78a02f18956a58d2dab240a6cf22971d9164587fb6c68a70a40", "searchTerms": [], + "slug": "ge-10-lt-11-hard-jubeat", "title": "Level 10.0-10.9 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24280,9 +26777,10 @@ "inactive": true, "legacyFolderID": "Fcc556576cd6ab15025e1dde2072e770c8e02eac76264ec78c307423a4a32a445", "searchTerms": [], + "slug": "ge-10-lt-11-hard-knit", "title": "Level 10.0-10.9 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24290,9 +26788,10 @@ "inactive": true, "legacyFolderID": "F913d7347cec5e11b9b6e7de30ce128e7cf842d7b8dfc28a13d8f194e1113c8b5", "searchTerms": [], + "slug": "ge-10-lt-11-hard-prop", "title": "Level 10.0-10.9 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24300,9 +26799,10 @@ "inactive": true, "legacyFolderID": "F50388d22e6f043c2222d45141997545092b1db693f49d129e8ffa236774cd786", "searchTerms": [], + "slug": "ge-10-lt-11-hard-qubell", "title": "Level 10.0-10.9 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24310,9 +26810,10 @@ "inactive": true, "legacyFolderID": "F8365f59ed99a4a0520efae4ab4c23e2979a2207703fb89a758d400e0e761d47e", "searchTerms": [], + "slug": "ge-10-lt-11-hard-ripples", "title": "Level 10.0-10.9 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24320,9 +26821,10 @@ "inactive": true, "legacyFolderID": "Fb285b4bedb613860a3607500257a7fa82be1d8a24450eae1dac0d34ab4057ad8", "searchTerms": [], + "slug": "ge-10-lt-11-hard-saucer", "title": "Level 10.0-10.9 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24330,9 +26832,10 @@ "inactive": true, "legacyFolderID": "Fbd351b82a2dfdc1dc6595c200f1a2688e87129810f1ac5e05ffd244324ae408c", "searchTerms": [], + "slug": "ge-10-lt-11-normal-jubeat", "title": "Level 10.0-10.9 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24340,9 +26843,10 @@ "inactive": true, "legacyFolderID": "F98842550fd9304fa9e8d914bd52e33c7c1e7d7a1be40f0ecd90d2884a18a98b1", "searchTerms": [], + "slug": "ge-10-lt-11-normal-knit", "title": "Level 10.0-10.9 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24350,9 +26854,10 @@ "inactive": true, "legacyFolderID": "F5a13d82b9fbafaa2f52f8500e39bf15f7fd3ecf3480a4defdee9319a60ff0ad9", "searchTerms": [], + "slug": "ge-10-lt-11-normal-prop", "title": "Level 10.0-10.9 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24360,9 +26865,10 @@ "inactive": true, "legacyFolderID": "F66a95b3d1701c59ae48f2e9b15914897acefb2c82520d05b74185fe6b4cc2af5", "searchTerms": [], + "slug": "ge-10-lt-11-normal-qubell", "title": "Level 10.0-10.9 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24370,9 +26876,10 @@ "inactive": true, "legacyFolderID": "Fea382bb10d81b78a7d47e133a0aa6088eaa5e7d6be49fa292118cf5fea242b68", "searchTerms": [], + "slug": "ge-10-lt-11-normal-ripples", "title": "Level 10.0-10.9 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24380,9 +26887,10 @@ "inactive": true, "legacyFolderID": "Fd12cb05f5adb353f4565a39f74b78a229fbcfd6d8165a6ff378421097a5f2027", "searchTerms": [], + "slug": "ge-10-lt-11-normal-saucer", "title": "Level 10.0-10.9 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 10 AND chart.level_num < 11" }, { "game": "jubeat", @@ -24390,9 +26898,10 @@ "inactive": false, "legacyFolderID": "F1fc8f2c606debab8bd3c131fc73649b73749858c565aac40dae9e756f9703728", "searchTerms": [], + "slug": "10p1-normal-ave", "title": "Level 10.1 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.1'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.1'" }, { "game": "jubeat", @@ -24400,9 +26909,10 @@ "inactive": false, "legacyFolderID": "F7a695c59902ac85ce13eda734fd56c24e3fa037f94a6a1dcdc052d941f4094bf", "searchTerms": [], + "slug": "10p1-normal-festo-omni", "title": "Level 10.1 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.1'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.1'" }, { "game": "jubeat", @@ -24410,9 +26920,10 @@ "inactive": false, "legacyFolderID": "F06856442a2b6d156115822df63dd187d341b6dcd2eef93b7011f895615472eb8", "searchTerms": [], + "slug": "10p1-normal-festo", "title": "Level 10.1 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.1'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.1'" }, { "game": "jubeat", @@ -24420,9 +26931,10 @@ "inactive": false, "legacyFolderID": "F79328026cfc908a635c3ef84f27923c4cb79e6a63166cc8b79127b6578c131c9", "searchTerms": [], + "slug": "10p1-hard-ave", "title": "Level 10.1 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.1'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.1'" }, { "game": "jubeat", @@ -24430,9 +26942,10 @@ "inactive": false, "legacyFolderID": "F6deab2c5db1bce13cfb5c75323dd8acc3abcad199dec9531e37dc5622d7ba2b6", "searchTerms": [], + "slug": "10p1-hard-festo-omni", "title": "Level 10.1 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.1'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.1'" }, { "game": "jubeat", @@ -24440,9 +26953,10 @@ "inactive": false, "legacyFolderID": "F3bd6380f4806ee03b8189d0f4d68758194079c188c6e2a140585f7290c3d8777", "searchTerms": [], + "slug": "10p1-hard-festo", "title": "Level 10.1 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.1'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.1'" }, { "game": "jubeat", @@ -24450,9 +26964,10 @@ "inactive": true, "legacyFolderID": "F83589b2c0d5bf385093111933614a27b38e2bb995f58a9d6b50405c5a17d0acb", "searchTerms": [], + "slug": "10p1-hard-qubell", "title": "Level 10.1 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.1'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.1'" }, { "game": "jubeat", @@ -24460,9 +26975,10 @@ "inactive": true, "legacyFolderID": "Fc44f82f0a43fb32689de38e73ba84d4304817a7cc27669803e7e06a0e543870d", "searchTerms": [], + "slug": "10p1-normal-qubell", "title": "Level 10.1 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.1'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.1'" }, { "game": "jubeat", @@ -24470,9 +26986,10 @@ "inactive": false, "legacyFolderID": "Fbdd5e0afb58326019eb9a9b7a368d2edd81d3a60a0e6e4597ef61f7e7b3c19ba", "searchTerms": [], + "slug": "10p2-normal-ave", "title": "Level 10.2 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.2'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.2'" }, { "game": "jubeat", @@ -24480,9 +26997,10 @@ "inactive": false, "legacyFolderID": "Fb5a46b2bad5f313f269858bd970a98f71320b5ffe6e505fd1202bce81336b90c", "searchTerms": [], + "slug": "10p2-normal-festo-omni", "title": "Level 10.2 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.2'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.2'" }, { "game": "jubeat", @@ -24490,9 +27008,10 @@ "inactive": false, "legacyFolderID": "F105bd71727ce7620e09d142b857ecb3e062d9b166e0c75190bfe2676062256e5", "searchTerms": [], + "slug": "10p2-normal-festo", "title": "Level 10.2 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.2'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.2'" }, { "game": "jubeat", @@ -24500,9 +27019,10 @@ "inactive": false, "legacyFolderID": "F97597f9efc9b391db950c9e27c6b111096c0b5dd9be57ee32d8bcc92f720e9df", "searchTerms": [], + "slug": "10p2-hard-ave", "title": "Level 10.2 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.2'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.2'" }, { "game": "jubeat", @@ -24510,9 +27030,10 @@ "inactive": false, "legacyFolderID": "F918a29c650a0d15dff4ae46942c419ca7223e4842ee1aeda2c7e87831dce749e", "searchTerms": [], + "slug": "10p2-hard-festo-omni", "title": "Level 10.2 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.2'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.2'" }, { "game": "jubeat", @@ -24520,9 +27041,10 @@ "inactive": false, "legacyFolderID": "Fa50adf1fb64febf1cd095642192fc2bc6d4a80cd75043e4f66161ff13809eb82", "searchTerms": [], + "slug": "10p2-hard-festo", "title": "Level 10.2 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.2'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.2'" }, { "game": "jubeat", @@ -24530,9 +27052,10 @@ "inactive": true, "legacyFolderID": "Fb2b87ee897ab643ed793bef338cd9a3de3b701524adbb5dfb4584f0c67ed7d54", "searchTerms": [], + "slug": "10p2-hard-qubell", "title": "Level 10.2 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.2'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.2'" }, { "game": "jubeat", @@ -24540,9 +27063,10 @@ "inactive": true, "legacyFolderID": "F86500ffb456d1c905c5ba53083431645d041b1b71db1a61e04d3a9c1cb33ba5b", "searchTerms": [], + "slug": "10p2-normal-qubell", "title": "Level 10.2 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.2'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.2'" }, { "game": "jubeat", @@ -24550,9 +27074,10 @@ "inactive": false, "legacyFolderID": "F16679a04836a63401ae62b8b5976c69c3d91e0c32e3235657a6b49290a64ad26", "searchTerms": [], + "slug": "10p3-normal-ave", "title": "Level 10.3 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.3'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.3'" }, { "game": "jubeat", @@ -24560,9 +27085,10 @@ "inactive": false, "legacyFolderID": "F0be80ccd99a5cfdc8418b99432ba3cb6f07cae3ecc6011c448841db34948f3c9", "searchTerms": [], + "slug": "10p3-normal-festo-omni", "title": "Level 10.3 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.3'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.3'" }, { "game": "jubeat", @@ -24570,9 +27096,10 @@ "inactive": false, "legacyFolderID": "F2510c3b8477cdd84b59fd577a95f1728efb27cfe5f6842e724c550c24b16c66f", "searchTerms": [], + "slug": "10p3-normal-festo", "title": "Level 10.3 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.3'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.3'" }, { "game": "jubeat", @@ -24580,9 +27107,10 @@ "inactive": false, "legacyFolderID": "Fb7755a0e963eacc5b6ff0a52268880ab5c72a6155cb2cbfeca7946db18245481", "searchTerms": [], + "slug": "10p3-hard-ave", "title": "Level 10.3 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.3'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.3'" }, { "game": "jubeat", @@ -24590,9 +27118,10 @@ "inactive": false, "legacyFolderID": "F4a80bc1d985c621e573af1aa15617ee97eb75177f2208c2e5ecdcfeb3fa013bb", "searchTerms": [], + "slug": "10p3-hard-festo-omni", "title": "Level 10.3 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.3'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.3'" }, { "game": "jubeat", @@ -24600,9 +27129,10 @@ "inactive": false, "legacyFolderID": "Ff8abcabebfaa8e9052c4118329f18584525037246a3f860eb67cf2bfef4a1c5a", "searchTerms": [], + "slug": "10p3-hard-festo", "title": "Level 10.3 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.3'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.3'" }, { "game": "jubeat", @@ -24610,9 +27140,10 @@ "inactive": true, "legacyFolderID": "F70510548a5d7e349f9cac277daf7d5ec1f503e4480893319165768cee55c6337", "searchTerms": [], + "slug": "10p3-hard-qubell", "title": "Level 10.3 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.3'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.3'" }, { "game": "jubeat", @@ -24620,9 +27151,10 @@ "inactive": true, "legacyFolderID": "Ff998355d774bb232769704233d5eafe9e11d6794ebca7741378cde304bb900bb", "searchTerms": [], + "slug": "10p3-normal-qubell", "title": "Level 10.3 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.3'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.3'" }, { "game": "jubeat", @@ -24630,9 +27162,10 @@ "inactive": false, "legacyFolderID": "Fb9065a0763b7e59f1167a88c0636f5f60b5d5984022ce37fa5b757f73407c2eb", "searchTerms": [], + "slug": "10p4-normal-ave", "title": "Level 10.4 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.4'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.4'" }, { "game": "jubeat", @@ -24640,9 +27173,10 @@ "inactive": false, "legacyFolderID": "Ffd5f22143b82953f3f915127cb6dee37bd87c2c805b20cedde5988582b774867", "searchTerms": [], + "slug": "10p4-normal-festo-omni", "title": "Level 10.4 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.4'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.4'" }, { "game": "jubeat", @@ -24650,9 +27184,10 @@ "inactive": false, "legacyFolderID": "F2b877e83e8bdc0aec887c5dce44395f62cc726e2706efc737c6dd2b9066a29a2", "searchTerms": [], + "slug": "10p4-normal-festo", "title": "Level 10.4 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.4'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.4'" }, { "game": "jubeat", @@ -24660,9 +27195,10 @@ "inactive": false, "legacyFolderID": "F4c2f4751732b432808067d5ae35a865560c670906a8b881c24166115589b820c", "searchTerms": [], + "slug": "10p4-hard-ave", "title": "Level 10.4 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.4'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.4'" }, { "game": "jubeat", @@ -24670,9 +27206,10 @@ "inactive": false, "legacyFolderID": "Fdac647437c3d9709ba55ed961e1641ceb60488b67558969e4633b891d6779ff1", "searchTerms": [], + "slug": "10p4-hard-festo-omni", "title": "Level 10.4 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.4'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.4'" }, { "game": "jubeat", @@ -24680,9 +27217,10 @@ "inactive": false, "legacyFolderID": "F248a05df982af3e6b3b0c66a84cf87cf610c8c261f3dd6d9f64d46e6cd2dff31", "searchTerms": [], + "slug": "10p4-hard-festo", "title": "Level 10.4 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.4'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.4'" }, { "game": "jubeat", @@ -24690,9 +27228,10 @@ "inactive": true, "legacyFolderID": "Fe0ff8f6b28553590c8fb50ef452924c68baa360d214fe216f75a7f8e3e939caa", "searchTerms": [], + "slug": "10p4-hard-qubell", "title": "Level 10.4 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.4'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.4'" }, { "game": "jubeat", @@ -24700,9 +27239,10 @@ "inactive": true, "legacyFolderID": "F803ff7986ce2a21deec689277c2c54846c629aa1bd3dab183121b9664060ff9a", "searchTerms": [], + "slug": "10p4-normal-qubell", "title": "Level 10.4 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.4'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.4'" }, { "game": "jubeat", @@ -24710,9 +27250,10 @@ "inactive": false, "legacyFolderID": "F35d61bd1fad337b7b314ab0a38babffe990ffdedeb4de88f8d464695cf9a2d24", "searchTerms": [], + "slug": "10p5-normal-ave", "title": "Level 10.5 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.5'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.5'" }, { "game": "jubeat", @@ -24720,9 +27261,10 @@ "inactive": false, "legacyFolderID": "Fca4aa6df9c482137d4c799ddca23a0cc922e9824d73d83c06533eec91866a14c", "searchTerms": [], + "slug": "10p5-normal-festo-omni", "title": "Level 10.5 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.5'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.5'" }, { "game": "jubeat", @@ -24730,9 +27272,10 @@ "inactive": false, "legacyFolderID": "Fc5785b3007b81fe8205e0cb2a74007aac2f320dee356500231d44867042ec4bd", "searchTerms": [], + "slug": "10p5-normal-festo", "title": "Level 10.5 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.5'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.5'" }, { "game": "jubeat", @@ -24740,9 +27283,10 @@ "inactive": false, "legacyFolderID": "Fbc728347fe6f4bb6993ce96b7208136131602d5a8ada25922cfc4f6645f5ff12", "searchTerms": [], + "slug": "10p5-hard-ave", "title": "Level 10.5 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.5'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.5'" }, { "game": "jubeat", @@ -24750,9 +27294,10 @@ "inactive": false, "legacyFolderID": "F6643c8b71344605867b92894a486f97d12b0f3c16f2795198eb2be76f8555908", "searchTerms": [], + "slug": "10p5-hard-festo-omni", "title": "Level 10.5 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.5'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.5'" }, { "game": "jubeat", @@ -24760,9 +27305,10 @@ "inactive": false, "legacyFolderID": "F1d426a443f6a9aa155d2ba1ca6b037f4db5b8f61660aeafb4777ea0041094f17", "searchTerms": [], + "slug": "10p5-hard-festo", "title": "Level 10.5 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.5'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.5'" }, { "game": "jubeat", @@ -24770,9 +27316,10 @@ "inactive": true, "legacyFolderID": "Ff0932f242b1a4f9b8fe1ea9005144a366cbfad087e64bbee96c05a5cb8ebd2dc", "searchTerms": [], + "slug": "10p5-hard-qubell", "title": "Level 10.5 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.5'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.5'" }, { "game": "jubeat", @@ -24780,9 +27327,10 @@ "inactive": true, "legacyFolderID": "Fe337c2ec3dd41770dc0c5867ae47dfe88349824512bd5dd86403a5fdc971e4e2", "searchTerms": [], + "slug": "10p5-normal-qubell", "title": "Level 10.5 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.5'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.5'" }, { "game": "jubeat", @@ -24790,9 +27338,10 @@ "inactive": false, "legacyFolderID": "Fc7c411dd589b46b06223e3c9e2f587372b61f14a780934d7b453b066e8a72e23", "searchTerms": [], + "slug": "10p6-normal-ave", "title": "Level 10.6 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.6'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.6'" }, { "game": "jubeat", @@ -24800,9 +27349,10 @@ "inactive": false, "legacyFolderID": "Fb6a942be115ae75a1780093a02f48776e87a4ceede66343d8dca4973bf1a273e", "searchTerms": [], + "slug": "10p6-normal-festo-omni", "title": "Level 10.6 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.6'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.6'" }, { "game": "jubeat", @@ -24810,9 +27360,10 @@ "inactive": false, "legacyFolderID": "Fe8b84334ca5c1571db854414cda0e83c43f0c1035c7aec6bf4e8ef4e6d8c51a7", "searchTerms": [], + "slug": "10p6-normal-festo", "title": "Level 10.6 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.6'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.6'" }, { "game": "jubeat", @@ -24820,9 +27371,10 @@ "inactive": false, "legacyFolderID": "Fb76d7dc99cf6f7513fa5c9b9c3cb186e711f453b23adc70bde1d8d2cf62d320e", "searchTerms": [], + "slug": "10p6-hard-ave", "title": "Level 10.6 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.6'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.6'" }, { "game": "jubeat", @@ -24830,9 +27382,10 @@ "inactive": false, "legacyFolderID": "Fe0433a490478bcff71abf4b64577007d90372347bdafdd1813f53a37f074e0b5", "searchTerms": [], + "slug": "10p6-hard-festo-omni", "title": "Level 10.6 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.6'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.6'" }, { "game": "jubeat", @@ -24840,9 +27393,10 @@ "inactive": false, "legacyFolderID": "F14e48a63e3b2bfa5dd46bc3313e88c560a1c368092d5eee35c70ebcae99a4134", "searchTerms": [], + "slug": "10p6-hard-festo", "title": "Level 10.6 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.6'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.6'" }, { "game": "jubeat", @@ -24850,9 +27404,10 @@ "inactive": true, "legacyFolderID": "Fdb67d1a26d40de642c9ae6e3932cd63e622ec84959b7d3030d15b1f44382260f", "searchTerms": [], + "slug": "10p6-hard-qubell", "title": "Level 10.6 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.6'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.6'" }, { "game": "jubeat", @@ -24860,9 +27415,10 @@ "inactive": true, "legacyFolderID": "F0661930dc54000714a3bf88b72b43c35995eda988c23d48a44b3915200bb6d24", "searchTerms": [], + "slug": "10p6-normal-qubell", "title": "Level 10.6 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.6'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.6'" }, { "game": "jubeat", @@ -24870,9 +27426,10 @@ "inactive": false, "legacyFolderID": "F7865a5a0166d001637ec520d42efa7d6a34acf217bed1c864dec90fdc3a9d812", "searchTerms": [], + "slug": "10p7-normal-ave", "title": "Level 10.7 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.7'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.7'" }, { "game": "jubeat", @@ -24880,9 +27437,10 @@ "inactive": false, "legacyFolderID": "F49b89289b8459c957ddf148ccece4339886236308120b9eb6378acc997eacd79", "searchTerms": [], + "slug": "10p7-normal-festo-omni", "title": "Level 10.7 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.7'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.7'" }, { "game": "jubeat", @@ -24890,9 +27448,10 @@ "inactive": false, "legacyFolderID": "F61f947e86ae5b899831f4741f8aefb98973b4c0883f63cd0d3c87759719ac2ab", "searchTerms": [], + "slug": "10p7-normal-festo", "title": "Level 10.7 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.7'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.7'" }, { "game": "jubeat", @@ -24900,9 +27459,10 @@ "inactive": false, "legacyFolderID": "F5c6f703237a660d6f9da112c77cdad3613bb2c8a8068a777f4a865db13d3e88a", "searchTerms": [], + "slug": "10p7-hard-ave", "title": "Level 10.7 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.7'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.7'" }, { "game": "jubeat", @@ -24910,9 +27470,10 @@ "inactive": false, "legacyFolderID": "F11d494d4123bc2da44737ed3ad85f45d2c6c2ce4387d2ed38a734414d16d9b1e", "searchTerms": [], + "slug": "10p7-hard-festo-omni", "title": "Level 10.7 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.7'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.7'" }, { "game": "jubeat", @@ -24920,9 +27481,10 @@ "inactive": false, "legacyFolderID": "F18eae2ea1257ba01d39a95a08758b4d4b0b62ebdd172bd511db99883efb4d41a", "searchTerms": [], + "slug": "10p7-hard-festo", "title": "Level 10.7 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.7'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.7'" }, { "game": "jubeat", @@ -24930,9 +27492,10 @@ "inactive": true, "legacyFolderID": "F7aef43d0356344175cacfdd11d258dbcf1693709e2ca6e674d07b651863a4970", "searchTerms": [], + "slug": "10p7-hard-qubell", "title": "Level 10.7 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.7'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.7'" }, { "game": "jubeat", @@ -24940,9 +27503,10 @@ "inactive": true, "legacyFolderID": "Fb946ff2505fa7f7535f5896d7dc6fb2c70f86d0ff495210b79407f243b795ebd", "searchTerms": [], + "slug": "10p7-normal-qubell", "title": "Level 10.7 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.7'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.7'" }, { "game": "jubeat", @@ -24950,9 +27514,10 @@ "inactive": false, "legacyFolderID": "F34d1e793073285fc732cab930890b90bd7c0371b751d66121c1e02a74ada5a08", "searchTerms": [], + "slug": "10p8-normal-ave", "title": "Level 10.8 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.8'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.8'" }, { "game": "jubeat", @@ -24960,9 +27525,10 @@ "inactive": false, "legacyFolderID": "F39b77f2b560c7555e3458157654e94ea65e6156adee5fba4a928a53b836e5b42", "searchTerms": [], + "slug": "10p8-normal-festo-omni", "title": "Level 10.8 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.8'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.8'" }, { "game": "jubeat", @@ -24970,9 +27536,10 @@ "inactive": false, "legacyFolderID": "Fd8fdbb1c69ac16183378d991c5b79a544d421da2f3a2e165fb0496e3ef775639", "searchTerms": [], + "slug": "10p8-normal-festo", "title": "Level 10.8 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.8'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.8'" }, { "game": "jubeat", @@ -24980,9 +27547,10 @@ "inactive": false, "legacyFolderID": "Fc4d864cd89a589851ae7d4969e481ec7c4c591a5e2096cefff7859a78f9a9461", "searchTerms": [], + "slug": "10p8-hard-ave", "title": "Level 10.8 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.8'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.8'" }, { "game": "jubeat", @@ -24990,9 +27558,10 @@ "inactive": false, "legacyFolderID": "F86b8c0468855a4bf09da7928d933f41c82f8183e6b548503452e2af50dd835be", "searchTerms": [], + "slug": "10p8-hard-festo-omni", "title": "Level 10.8 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.8'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.8'" }, { "game": "jubeat", @@ -25000,9 +27569,10 @@ "inactive": false, "legacyFolderID": "F22f85aa28dc3d8c3e24f9025cef86d43d778f7d61462181ca3d02f6020dda66a", "searchTerms": [], + "slug": "10p8-hard-festo", "title": "Level 10.8 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.8'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.8'" }, { "game": "jubeat", @@ -25010,9 +27580,10 @@ "inactive": true, "legacyFolderID": "F78662782644778846e50fe0d7da1c75c3b9124640c251c8d3e89ae480ea987e4", "searchTerms": [], + "slug": "10p8-hard-qubell", "title": "Level 10.8 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.8'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.8'" }, { "game": "jubeat", @@ -25020,9 +27591,10 @@ "inactive": true, "legacyFolderID": "Fc6e6d7ec7a0f9a61e92b8d2a15600611e69e0ad86be1db28c1b5379c326d7b2c", "searchTerms": [], + "slug": "10p8-normal-qubell", "title": "Level 10.8 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.8'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.8'" }, { "game": "jubeat", @@ -25030,9 +27602,10 @@ "inactive": false, "legacyFolderID": "Fdded36d67ef8148973896fe1f448324bec8f5d77556b6dca251ceb2507512070", "searchTerms": [], + "slug": "10p9-normal-ave", "title": "Level 10.9 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.9'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.9'" }, { "game": "jubeat", @@ -25040,9 +27613,10 @@ "inactive": false, "legacyFolderID": "F3eb9acf9a41c52ccb096b4125034d5d9fa9977714eacfccdbd7fb43f5c6a7331", "searchTerms": [], + "slug": "10p9-normal-festo-omni", "title": "Level 10.9 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.9'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.9'" }, { "game": "jubeat", @@ -25050,9 +27624,10 @@ "inactive": false, "legacyFolderID": "F7e496dea76af2532827dc237cdc07d504b11eddafa1b5f2676fcba2f92d6e938", "searchTerms": [], + "slug": "10p9-normal-festo", "title": "Level 10.9 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.9'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.9'" }, { "game": "jubeat", @@ -25060,9 +27635,10 @@ "inactive": false, "legacyFolderID": "F86cd55d7168a736a5faf7e5e3bc5c10248597e41a26f581561341031b8f9e05e", "searchTerms": [], + "slug": "10p9-hard-ave", "title": "Level 10.9 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.9'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.9'" }, { "game": "jubeat", @@ -25070,9 +27646,10 @@ "inactive": false, "legacyFolderID": "Ff22668e33143f023903771c7470e6a335e98e88976dfafd7de4e621fb5b1b66d", "searchTerms": [], + "slug": "10p9-hard-festo-omni", "title": "Level 10.9 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.9'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.9'" }, { "game": "jubeat", @@ -25080,9 +27657,10 @@ "inactive": false, "legacyFolderID": "F6fcf3bc4063181f5b8f1ca976d826b05bab22cd42f4423b660ec08edb08acf0e", "searchTerms": [], + "slug": "10p9-hard-festo", "title": "Level 10.9 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.9'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.9'" }, { "game": "jubeat", @@ -25090,9 +27668,10 @@ "inactive": true, "legacyFolderID": "F7056e65a960e0453b91f51f734d69ea0974a96dd0416665a658dfce13522e6b2", "searchTerms": [], + "slug": "10p9-hard-qubell", "title": "Level 10.9 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.9'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '10.9'" }, { "game": "jubeat", @@ -25100,9 +27679,10 @@ "inactive": true, "legacyFolderID": "Fe1578fdaa1229d2de374a54e5166f45d788e50e375f46369d837cda851e3058f", "searchTerms": [], + "slug": "10p9-normal-qubell", "title": "Level 10.9 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.9'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '10.9'" }, { "game": "jubeat", @@ -25110,9 +27690,10 @@ "inactive": false, "legacyFolderID": "Ff05242271734c3e72d72ec55602882d4aca14efc977f6b33ce758e382e311637", "searchTerms": [], + "slug": "2-normal-ave", "title": "Level 2 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25120,9 +27701,10 @@ "inactive": true, "legacyFolderID": "F77dff203be337b9d0103b1f895f135e8f68b44fd5541159daddbb1edcb2bc860", "searchTerms": [], + "slug": "2-normal-copious", "title": "Level 2 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25130,9 +27712,10 @@ "inactive": false, "legacyFolderID": "F9a2d3f2af16a110625886820dbb09e30702e4774bd150ae5256332ba4bcfd5ba", "searchTerms": [], + "slug": "2-normal-festo-omni", "title": "Level 2 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25140,9 +27723,10 @@ "inactive": false, "legacyFolderID": "Fdcadcbe68a661e0baa3dc9c3fe6b4125ff791c720c30be55a0517bd2f263b0fd", "searchTerms": [], + "slug": "2-normal-festo", "title": "Level 2 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25150,9 +27734,10 @@ "inactive": false, "legacyFolderID": "F743cee90cf440fe21cab460904c1fc759e749c750eafc16d8e2c914838a71841", "searchTerms": [], + "slug": "2-hard-ave", "title": "Level 2 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25160,9 +27745,10 @@ "inactive": true, "legacyFolderID": "F5fc7872fb3ca751416b18273b3c15e1fe64ed3acfb0a5b2a54bbf3f540857504", "searchTerms": [], + "slug": "2-hard-copious", "title": "Level 2 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25170,9 +27756,10 @@ "inactive": false, "legacyFolderID": "Ff948af5557c25a1a0033c9e782db543a35a6e6c6660e7b8f6c227deac7fd2a4b", "searchTerms": [], + "slug": "2-hard-festo-omni", "title": "Level 2 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25180,9 +27767,10 @@ "inactive": false, "legacyFolderID": "F9b725f57bd6fe35ca8c207617eca0cd29c226bd7cb654227999895729f1452c9", "searchTerms": [], + "slug": "2-hard-festo", "title": "Level 2 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25190,9 +27778,10 @@ "inactive": true, "legacyFolderID": "F916716be766990b6904d77f82ec2ddcf04ed5abdd8be7b671755f3be7d957c32", "searchTerms": [], + "slug": "2-hard-jubeat", "title": "Level 2 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25200,9 +27789,10 @@ "inactive": true, "legacyFolderID": "Fb8300876df0994d0672a3d5ab706dd2a1d946ad79940ddc6efa185a78c2b51ae", "searchTerms": [], + "slug": "2-hard-knit", "title": "Level 2 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25210,9 +27800,10 @@ "inactive": true, "legacyFolderID": "Ff6150f5997ae1e2fce1645aa12ecc8d06246db9cf95d26018f04a776273b13c2", "searchTerms": [], + "slug": "2-hard-prop", "title": "Level 2 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25220,9 +27811,10 @@ "inactive": true, "legacyFolderID": "F6b52bac6a06082f9a6ba0b91df916d86718182ff3887f09caadabe54eb3f23c8", "searchTerms": [], + "slug": "2-hard-qubell", "title": "Level 2 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25230,9 +27822,10 @@ "inactive": true, "legacyFolderID": "Fae28f736d1fefa5ef1ec88d852e9e2f5e1affd38f515345b90f19f710dcb65dc", "searchTerms": [], + "slug": "2-hard-ripples", "title": "Level 2 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25240,9 +27833,10 @@ "inactive": true, "legacyFolderID": "Faeb1aa02d5b8c847885225fe6ae162ce7bf31bfb3c263f554f1d11a1e633d4ea", "searchTerms": [], + "slug": "2-hard-saucer", "title": "Level 2 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25250,9 +27844,10 @@ "inactive": true, "legacyFolderID": "F5d51d0cc25f5a535aefa741ec23f7f239956712b65192e6f5bbbed2e7ffeb54a", "searchTerms": [], + "slug": "2-normal-jubeat", "title": "Level 2 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25260,9 +27855,10 @@ "inactive": true, "legacyFolderID": "Fa032097e603538b6f265f3bba32e81523bef421de9b0bc80d7e65c56b24276b8", "searchTerms": [], + "slug": "2-normal-knit", "title": "Level 2 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25270,9 +27866,10 @@ "inactive": true, "legacyFolderID": "Ff7df95a9dd3c06ff899681f46e2421b1dfda0f2d51719c1791bc7b3a7a50c2c1", "searchTerms": [], + "slug": "2-normal-prop", "title": "Level 2 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25280,9 +27877,10 @@ "inactive": true, "legacyFolderID": "F174fb6bca80266fcbe0e3d47263cc8fabd44a9bd9199b084d90a59a471be4eec", "searchTerms": [], + "slug": "2-normal-qubell", "title": "Level 2 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25290,9 +27888,10 @@ "inactive": true, "legacyFolderID": "F68778c59e8274366d9543637f1fdd9215423d653a46eefa31ac263fdd0d43fdb", "searchTerms": [], + "slug": "2-normal-ripples", "title": "Level 2 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25300,9 +27899,10 @@ "inactive": true, "legacyFolderID": "Ff1a9eccf91fe82e9112dbd2a3f91b225befa7d2b3850d274285fc3730ce85a9a", "searchTerms": [], + "slug": "2-normal-saucer", "title": "Level 2 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '2'" }, { "game": "jubeat", @@ -25310,9 +27910,10 @@ "inactive": false, "legacyFolderID": "Ff98e66969040a6c2ece0074ca6ee51752020574b242ad1135f5da40963e8f80d", "searchTerms": [], + "slug": "3-normal-ave", "title": "Level 3 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25320,9 +27921,10 @@ "inactive": true, "legacyFolderID": "F8871234eb8e1739af3ee0b241fb0a2f409589f8407944dac86222a3c6a2664fa", "searchTerms": [], + "slug": "3-normal-copious", "title": "Level 3 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25330,9 +27932,10 @@ "inactive": false, "legacyFolderID": "F52797ce23a3a43ee639daa71741414c6e1cfc00ba689fbb8e8e9bb30eabea16e", "searchTerms": [], + "slug": "3-normal-festo-omni", "title": "Level 3 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25340,9 +27943,10 @@ "inactive": false, "legacyFolderID": "Fb4524def4255ea1967813607009da271ceb6f9b988ff7c7578f3d731719e22e0", "searchTerms": [], + "slug": "3-normal-festo", "title": "Level 3 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25350,9 +27954,10 @@ "inactive": false, "legacyFolderID": "F6842849112baa2fd007a759208336c3bbffa618f13be41014d9ac584925cbd49", "searchTerms": [], + "slug": "3-hard-ave", "title": "Level 3 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25360,9 +27965,10 @@ "inactive": true, "legacyFolderID": "Fbda36dd8a23af39df77b9105c0f6dab731c693a2e54e2d97d0f18aee9397fbe6", "searchTerms": [], + "slug": "3-hard-copious", "title": "Level 3 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25370,9 +27976,10 @@ "inactive": false, "legacyFolderID": "F7375cd20502779c6c3060f3b1afca3e70d7eb9a75b47f5a14bf5d22fc3dedcac", "searchTerms": [], + "slug": "3-hard-festo-omni", "title": "Level 3 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25380,9 +27987,10 @@ "inactive": false, "legacyFolderID": "Fb1d6959b2bb836658bcbb1adff42e436e5b2c8d48d5893c4b7b543dc5e57f08f", "searchTerms": [], + "slug": "3-hard-festo", "title": "Level 3 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25390,9 +27998,10 @@ "inactive": true, "legacyFolderID": "F838f07e44467f927985bd5d8a33d66db0f202723401becd8b61992ff16e1654d", "searchTerms": [], + "slug": "3-hard-jubeat", "title": "Level 3 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25400,9 +28009,10 @@ "inactive": true, "legacyFolderID": "Ff50335529557bfe1cc64f858793eeed2d84283b798d264db46b3ce0aa59ef641", "searchTerms": [], + "slug": "3-hard-knit", "title": "Level 3 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25410,9 +28020,10 @@ "inactive": true, "legacyFolderID": "F654bd1772287b6b365c7dd1702b6e449a89c33ace57cfd72cdf093720878b998", "searchTerms": [], + "slug": "3-hard-prop", "title": "Level 3 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25420,9 +28031,10 @@ "inactive": true, "legacyFolderID": "Fa798cc59df45a1209f289ed72071d50f9def56d67a7d26739e854cf56b1c01b7", "searchTerms": [], + "slug": "3-hard-qubell", "title": "Level 3 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25430,9 +28042,10 @@ "inactive": true, "legacyFolderID": "F01575e7391f9f33d9d462c6225dfd5c56f3261651044f920ca42ee0598121cbb", "searchTerms": [], + "slug": "3-hard-ripples", "title": "Level 3 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25440,9 +28053,10 @@ "inactive": true, "legacyFolderID": "F84812075a554fc86ebe3647a09fbf0b0c8eb0320a0c3aef255cbee84717e3734", "searchTerms": [], + "slug": "3-hard-saucer", "title": "Level 3 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25450,9 +28064,10 @@ "inactive": true, "legacyFolderID": "F0fe03ac792bc9ef03402b8fc45cd8748050628484d6a282f2c6660eb63aca686", "searchTerms": [], + "slug": "3-normal-jubeat", "title": "Level 3 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25460,9 +28075,10 @@ "inactive": true, "legacyFolderID": "F43e519238d8bc47397c6aa671c4fc93da37520861b22b822ab4913803f131708", "searchTerms": [], + "slug": "3-normal-knit", "title": "Level 3 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25470,9 +28086,10 @@ "inactive": true, "legacyFolderID": "F20d62811ef61b07c098798e667cccc5935bd4f99e0fe8096defeae5cd2140dde", "searchTerms": [], + "slug": "3-normal-prop", "title": "Level 3 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25480,9 +28097,10 @@ "inactive": true, "legacyFolderID": "F1dbfa2f3873b4a5df4d931890147950fd4798fb3910d70538d6487a404c9624d", "searchTerms": [], + "slug": "3-normal-qubell", "title": "Level 3 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25490,9 +28108,10 @@ "inactive": true, "legacyFolderID": "F916544bdf3805ca408649d25c0f7a34575337e471efea396e0da47091bc44399", "searchTerms": [], + "slug": "3-normal-ripples", "title": "Level 3 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25500,9 +28119,10 @@ "inactive": true, "legacyFolderID": "F92feb7905eb9552ab402922ef3bb1d21a31aec7d6e0a720d483012d325743721", "searchTerms": [], + "slug": "3-normal-saucer", "title": "Level 3 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '3'" }, { "game": "jubeat", @@ -25510,9 +28130,10 @@ "inactive": false, "legacyFolderID": "F6316427346c8fc003b444f48cfa5f46d026b8dacdedd2fec076d5a2bbc227ac1", "searchTerms": [], + "slug": "4-normal-ave", "title": "Level 4 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25520,9 +28141,10 @@ "inactive": true, "legacyFolderID": "F9c927d54289dea04203760544b75698c4270bbc89ee516eebccfbae22680a79b", "searchTerms": [], + "slug": "4-normal-copious", "title": "Level 4 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25530,9 +28152,10 @@ "inactive": false, "legacyFolderID": "F5ae9d17370334cf0fac30e9546de852af0d901963989c89e011a653d14422716", "searchTerms": [], + "slug": "4-normal-festo-omni", "title": "Level 4 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25540,9 +28163,10 @@ "inactive": false, "legacyFolderID": "F265e59f1c399de9c036e107f708a02c902d432ada57fe238b256e171d189b418", "searchTerms": [], + "slug": "4-normal-festo", "title": "Level 4 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25550,9 +28174,10 @@ "inactive": false, "legacyFolderID": "F27de38b5ff3660d3bd4c471e4aa1e9f88391ea24038e08d30bf809e350b0057f", "searchTerms": [], + "slug": "4-hard-ave", "title": "Level 4 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25560,9 +28185,10 @@ "inactive": true, "legacyFolderID": "F7a858cae10845574752a706a2668ff8add00aaa96f7fcaa67d22d058d270a177", "searchTerms": [], + "slug": "4-hard-copious", "title": "Level 4 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25570,9 +28196,10 @@ "inactive": false, "legacyFolderID": "F064b103b2da5d7d9344bf08cb2c15ec08213142e02a324145b043646f6c3b591", "searchTerms": [], + "slug": "4-hard-festo-omni", "title": "Level 4 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25580,9 +28207,10 @@ "inactive": false, "legacyFolderID": "F69c27e9eda3ff0052a1aa0b03b20cde40a3edd0d062de3227dc5c8080c10f813", "searchTerms": [], + "slug": "4-hard-festo", "title": "Level 4 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25590,9 +28218,10 @@ "inactive": true, "legacyFolderID": "Fe8ea90c10f7c74378366bac27cc6f74aa9b683cf5a7f4b7eda7de89420909c1e", "searchTerms": [], + "slug": "4-hard-jubeat", "title": "Level 4 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25600,9 +28229,10 @@ "inactive": true, "legacyFolderID": "F4a1f4c1083d69500bcb8273473ee48e838ef36860ff14986d0c2206312fa356e", "searchTerms": [], + "slug": "4-hard-knit", "title": "Level 4 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25610,9 +28240,10 @@ "inactive": true, "legacyFolderID": "Fe37bf864737739ea74a6f24605a74738f0506092711c25626d6bdea288e0688b", "searchTerms": [], + "slug": "4-hard-prop", "title": "Level 4 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25620,9 +28251,10 @@ "inactive": true, "legacyFolderID": "Fc501a661462e49aa04de8b7e1b01c45585adde525a9bc3d56759748e653e37d2", "searchTerms": [], + "slug": "4-hard-qubell", "title": "Level 4 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25630,9 +28262,10 @@ "inactive": true, "legacyFolderID": "F6034069ac1a6fcbe624986a58f2bd8e694d4caa2663d9e89ea42c826c11e1f6e", "searchTerms": [], + "slug": "4-hard-ripples", "title": "Level 4 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25640,9 +28273,10 @@ "inactive": true, "legacyFolderID": "F0b815b73b1fb2fbe7fa3b18968157aa9ca61dd2c1c0d6cfbf2c7ad5cebfe38f3", "searchTerms": [], + "slug": "4-hard-saucer", "title": "Level 4 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25650,9 +28284,10 @@ "inactive": true, "legacyFolderID": "Ff8d2e28bb48628ca176e0b880a8014ca3f72280d6652284135ff58652ba22e94", "searchTerms": [], + "slug": "4-normal-jubeat", "title": "Level 4 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25660,9 +28295,10 @@ "inactive": true, "legacyFolderID": "Ff877280589529f3932635d22dde79417618043d22f164f5ac71dd4e84a417921", "searchTerms": [], + "slug": "4-normal-knit", "title": "Level 4 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25670,9 +28306,10 @@ "inactive": true, "legacyFolderID": "F6ddf597e6bd4b0b7ab6bced1d4ab7038e16b318d7d10fa8a76184b7b31006b90", "searchTerms": [], + "slug": "4-normal-prop", "title": "Level 4 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25680,9 +28317,10 @@ "inactive": true, "legacyFolderID": "F388ce4621b1bcdae9a747ada2615454e2bc5cd64686d2cf489aabf217915324d", "searchTerms": [], + "slug": "4-normal-qubell", "title": "Level 4 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25690,9 +28328,10 @@ "inactive": true, "legacyFolderID": "Fc3e61089b9ca8b59ccdf8baee0e7a826c3b9745b07c58527478cd8047f507dd3", "searchTerms": [], + "slug": "4-normal-ripples", "title": "Level 4 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25700,9 +28339,10 @@ "inactive": true, "legacyFolderID": "F5a504dcc6c6d84eed0bb78194df96679069bab75663da4ce435f7b9e5b19b35e", "searchTerms": [], + "slug": "4-normal-saucer", "title": "Level 4 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '4'" }, { "game": "jubeat", @@ -25710,9 +28350,10 @@ "inactive": false, "legacyFolderID": "Ff6e5d18a181d75e920117430e100f8d4c22fb20eacdbc359660399d3b4459951", "searchTerms": [], + "slug": "5-normal-ave", "title": "Level 5 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25720,9 +28361,10 @@ "inactive": true, "legacyFolderID": "Fc83b91978840957b65d377b2053fd277a8bec14a8f23fd7c4fdc992b2cd81cbd", "searchTerms": [], + "slug": "5-normal-copious", "title": "Level 5 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25730,9 +28372,10 @@ "inactive": false, "legacyFolderID": "F722bc2f52f64be6d7912e6a493e95cb2002d981dcfb8c72545a5df5b13a8a915", "searchTerms": [], + "slug": "5-normal-festo-omni", "title": "Level 5 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25740,9 +28383,10 @@ "inactive": false, "legacyFolderID": "F76f2889fa51aa0cb7bf3d828be3611880d6e031d1f3387b72271a4ee6a6f0664", "searchTerms": [], + "slug": "5-normal-festo", "title": "Level 5 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25750,9 +28394,10 @@ "inactive": false, "legacyFolderID": "F2c916dfe832c4cbf67a12cd4d6d5f3a49d37a6b6688994937e9e72bbd3ba493d", "searchTerms": [], + "slug": "5-hard-ave", "title": "Level 5 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25760,9 +28405,10 @@ "inactive": true, "legacyFolderID": "F8097c65c57b54cceeaf6bc4140e6a66609bdbc5ae7298e2152e2edd26a7dc498", "searchTerms": [], + "slug": "5-hard-copious", "title": "Level 5 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25770,9 +28416,10 @@ "inactive": false, "legacyFolderID": "Fce1b7e48fc83eb921ff637d7e1e64af01fa51272975a1289bff93389628da707", "searchTerms": [], + "slug": "5-hard-festo-omni", "title": "Level 5 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25780,9 +28427,10 @@ "inactive": false, "legacyFolderID": "F2455e0cb65feaa00dc98585ad334e09072bb8638630c6db704732a9c3973f8a4", "searchTerms": [], + "slug": "5-hard-festo", "title": "Level 5 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25790,9 +28438,10 @@ "inactive": true, "legacyFolderID": "F0421ae84deb8f16a6db5c995a66a12cfefd7845fa5cdcada9bb58a1a11a70ca9", "searchTerms": [], + "slug": "5-hard-jubeat", "title": "Level 5 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25800,9 +28449,10 @@ "inactive": true, "legacyFolderID": "F342d1335cb2e959d7fb37980adf862278d143a132c8785cb399ccb6ad5a7bb84", "searchTerms": [], + "slug": "5-hard-knit", "title": "Level 5 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25810,9 +28460,10 @@ "inactive": true, "legacyFolderID": "F54b2f243f9366def718a42e1287ba5ee7d359e77e4adfb1379eb5afeb29fd283", "searchTerms": [], + "slug": "5-hard-prop", "title": "Level 5 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25820,9 +28471,10 @@ "inactive": true, "legacyFolderID": "F630286dc4b4719793c1205273793fc740e1c1c2eca0279e00fb88b5bc728aacc", "searchTerms": [], + "slug": "5-hard-qubell", "title": "Level 5 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25830,9 +28482,10 @@ "inactive": true, "legacyFolderID": "Fd4a1986fe9fa4f9cb589eed45046c6bdcd8f0b4219af16b7475c5bd9af8ef197", "searchTerms": [], + "slug": "5-hard-ripples", "title": "Level 5 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25840,9 +28493,10 @@ "inactive": true, "legacyFolderID": "F789dd8d932571cdc472213e8120ebf52c16791ef96b39bb0bb951bcee07c8c05", "searchTerms": [], + "slug": "5-hard-saucer", "title": "Level 5 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25850,9 +28504,10 @@ "inactive": true, "legacyFolderID": "Fb40e2c581b1d907b8d2b13fe80da929478d727fd48cdb32dbd50c836971bcd97", "searchTerms": [], + "slug": "5-normal-jubeat", "title": "Level 5 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25860,9 +28515,10 @@ "inactive": true, "legacyFolderID": "F016dd746b0f09bd3b3e58f745fd4a8610297ced157ee229ed7dc92dda16cc907", "searchTerms": [], + "slug": "5-normal-knit", "title": "Level 5 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25870,9 +28526,10 @@ "inactive": true, "legacyFolderID": "F52f10e675a14a586c7b28b2ebfeea668670e6061e2314140dce094e80eebdc83", "searchTerms": [], + "slug": "5-normal-prop", "title": "Level 5 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25880,9 +28537,10 @@ "inactive": true, "legacyFolderID": "F840ab3d8cdcc2ee2e6eecb927c233eaaa137c78e3c2517f56bdcae4c0a66d309", "searchTerms": [], + "slug": "5-normal-qubell", "title": "Level 5 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25890,9 +28548,10 @@ "inactive": true, "legacyFolderID": "F75cf504c5d41ecfa2981d45a00c8dc0bcdad0800b2b2a0316fe9fae6c6d3779e", "searchTerms": [], + "slug": "5-normal-ripples", "title": "Level 5 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25900,9 +28559,10 @@ "inactive": true, "legacyFolderID": "F7b76b49fb4839cb3156b65e19fe49768e4f7ebe0ff69010d20257068ab4a86d6", "searchTerms": [], + "slug": "5-normal-saucer", "title": "Level 5 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '5'" }, { "game": "jubeat", @@ -25910,9 +28570,10 @@ "inactive": false, "legacyFolderID": "F19d0429ceb0753246557b359f9423ff29e7419f2bc2a4cdbbd1c69e13668a735", "searchTerms": [], + "slug": "6-normal-ave", "title": "Level 6 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -25920,9 +28581,10 @@ "inactive": true, "legacyFolderID": "Fe203c8bbdc9549043ae68805f9d112695cdf8e9231e4f7a21b16bd53cce9d63f", "searchTerms": [], + "slug": "6-normal-copious", "title": "Level 6 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -25930,9 +28592,10 @@ "inactive": false, "legacyFolderID": "F71a311f27235bf8fc88b6a1150ed50b47e1e972bc26c0a91192015fbb0f51a6e", "searchTerms": [], + "slug": "6-normal-festo-omni", "title": "Level 6 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -25940,9 +28603,10 @@ "inactive": false, "legacyFolderID": "F987f6b35253d4e5c3b7edb917935b1ac9f62e7201a1e66d6c3d30de55a22fb16", "searchTerms": [], + "slug": "6-normal-festo", "title": "Level 6 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -25950,9 +28614,10 @@ "inactive": false, "legacyFolderID": "F65b124540257cb2f20f3acfbf2ca953f46f2e2e97bfc666bf8d1323ae0975e59", "searchTerms": [], + "slug": "6-hard-ave", "title": "Level 6 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -25960,9 +28625,10 @@ "inactive": true, "legacyFolderID": "F0716290def3d5212f69b5fdf328b2dd1247548265fb74e517ed35878bf217d7e", "searchTerms": [], + "slug": "6-hard-copious", "title": "Level 6 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -25970,9 +28636,10 @@ "inactive": false, "legacyFolderID": "F414ee43a3d5ad9afc5c4b66fa722367383ad87052682de0d6994c5d2c6eea6fa", "searchTerms": [], + "slug": "6-hard-festo-omni", "title": "Level 6 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -25980,9 +28647,10 @@ "inactive": false, "legacyFolderID": "F95aae7f291a169a36209427a9fb71c282000aa4b9fe3875764e22920c11e5827", "searchTerms": [], + "slug": "6-hard-festo", "title": "Level 6 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -25990,9 +28658,10 @@ "inactive": true, "legacyFolderID": "Fd3bbe4fd81949b854018067735d3fde9983f11671b67ecd93838f581ef1e9260", "searchTerms": [], + "slug": "6-hard-jubeat", "title": "Level 6 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26000,9 +28669,10 @@ "inactive": true, "legacyFolderID": "Fb805b4cf7aaaa0b428cac79b49b40c0be64fc2725aa92c5d99ddc9b2bdbc351c", "searchTerms": [], + "slug": "6-hard-knit", "title": "Level 6 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26010,9 +28680,10 @@ "inactive": true, "legacyFolderID": "F7bde40cb4f3bd214f5fdb7d0688072fe5a716b835b87004dba6256db46e4fb73", "searchTerms": [], + "slug": "6-hard-prop", "title": "Level 6 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26020,9 +28691,10 @@ "inactive": true, "legacyFolderID": "F0ad85c7198964cab1921bce9e5a3c4580e5ddfe812c8c2e1ec004c694280fe31", "searchTerms": [], + "slug": "6-hard-qubell", "title": "Level 6 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26030,9 +28702,10 @@ "inactive": true, "legacyFolderID": "Fd9db3a70a3046981bee1bf5a027f3afddb2fd67f02fd6e02525d772dc14446a2", "searchTerms": [], + "slug": "6-hard-ripples", "title": "Level 6 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26040,9 +28713,10 @@ "inactive": true, "legacyFolderID": "F827b995692a94b5ff4c422cbedd7434d308d24676a4b3c84f8d41617650ce100", "searchTerms": [], + "slug": "6-hard-saucer", "title": "Level 6 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26050,9 +28724,10 @@ "inactive": true, "legacyFolderID": "F5fc2f771a8562002efe0ee1ddfc2e8a428cd75ce02cc180d2d31638dd86dd4d7", "searchTerms": [], + "slug": "6-normal-jubeat", "title": "Level 6 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26060,9 +28735,10 @@ "inactive": true, "legacyFolderID": "F5af44040b9eec9ebaeea99280f3ba6f64d6a28e7392c9510ae51b57139e85e4a", "searchTerms": [], + "slug": "6-normal-knit", "title": "Level 6 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26070,9 +28746,10 @@ "inactive": true, "legacyFolderID": "Fa8ba22b740f25ff00a05e812c5471bcb35f7924f0499164517d65780d0d8d0e4", "searchTerms": [], + "slug": "6-normal-prop", "title": "Level 6 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26080,9 +28757,10 @@ "inactive": true, "legacyFolderID": "Ff935595f72671dc178b0dd6bc639facef1802543d09238a9579056cc3740a0b2", "searchTerms": [], + "slug": "6-normal-qubell", "title": "Level 6 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26090,9 +28768,10 @@ "inactive": true, "legacyFolderID": "Fc9e8fbf4457d9b95150d94b2b89fa90b347de9bb5b25cbf97d626403b79bf488", "searchTerms": [], + "slug": "6-normal-ripples", "title": "Level 6 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26100,9 +28779,10 @@ "inactive": true, "legacyFolderID": "Fffd715182529bbf5a7a84e2f40ccc4209575a3dd3830e5eeb3e7375f23cce3c5", "searchTerms": [], + "slug": "6-normal-saucer", "title": "Level 6 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '6'" }, { "game": "jubeat", @@ -26110,9 +28790,10 @@ "inactive": false, "legacyFolderID": "F69884f796e3dec8dff7332a00e3e7d4b81dfcaa3951713a55001dbb7b9ae390f", "searchTerms": [], + "slug": "7-normal-ave", "title": "Level 7 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26120,9 +28801,10 @@ "inactive": true, "legacyFolderID": "Ff50b97390b0121e9ba355873caf43b45a82a03d635371e921f6a51ae19f8fe1e", "searchTerms": [], + "slug": "7-normal-copious", "title": "Level 7 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26130,9 +28812,10 @@ "inactive": false, "legacyFolderID": "F3de81f6edf73b2712a229cd0c6cc1101b695f9e40e5829ed66fae180c5c1686c", "searchTerms": [], + "slug": "7-normal-festo-omni", "title": "Level 7 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26140,9 +28823,10 @@ "inactive": false, "legacyFolderID": "F993fb421bcb8deef5afdfa03b76da6acd9521b24e188b7b29ba71a5241f849b4", "searchTerms": [], + "slug": "7-normal-festo", "title": "Level 7 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26150,9 +28834,10 @@ "inactive": false, "legacyFolderID": "Ff624fb527c0b57a2149aaf0e89e22d45e754683f342fbc7d13a33da77260cb0f", "searchTerms": [], + "slug": "7-hard-ave", "title": "Level 7 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26160,9 +28845,10 @@ "inactive": true, "legacyFolderID": "F953f48cfd61df73ceed3c6b8876cf1f0d40b2bcc93007aa3db050b5cbf305001", "searchTerms": [], + "slug": "7-hard-copious", "title": "Level 7 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26170,9 +28856,10 @@ "inactive": false, "legacyFolderID": "F747ad5d5eb6b15d61b03d8520f40077f144126e6ba6e481329b61faa4ee210df", "searchTerms": [], + "slug": "7-hard-festo-omni", "title": "Level 7 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26180,9 +28867,10 @@ "inactive": false, "legacyFolderID": "F7b6eac25b7ed014f94663a49fa647c0e42c5e1a6cb47618da7b03458d6df66c6", "searchTerms": [], + "slug": "7-hard-festo", "title": "Level 7 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26190,9 +28878,10 @@ "inactive": true, "legacyFolderID": "Fc75d5a32df48e47d8caf5659739de2a97845719ff38b4ae4aa8803f2f4b0de6f", "searchTerms": [], + "slug": "7-hard-jubeat", "title": "Level 7 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26200,9 +28889,10 @@ "inactive": true, "legacyFolderID": "F09c8326afb0d042d830664019ebe29a46cd029e1dda06d4a99d693179d0a5294", "searchTerms": [], + "slug": "7-hard-knit", "title": "Level 7 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26210,9 +28900,10 @@ "inactive": true, "legacyFolderID": "Fdfc47babd98cb9760d740068d2fa7bdd86474aeabe7648a8246cb8296e5ee079", "searchTerms": [], + "slug": "7-hard-prop", "title": "Level 7 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26220,9 +28911,10 @@ "inactive": true, "legacyFolderID": "F782718d7007dc73f4d0f6b5ff52bb6d48d9483f8d9c778ab1fe829e9bfa47a45", "searchTerms": [], + "slug": "7-hard-qubell", "title": "Level 7 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26230,9 +28922,10 @@ "inactive": true, "legacyFolderID": "F262fb401dc89d1ef20648ab227caa63a4a9c0a9a38345efc46236df743fca0db", "searchTerms": [], + "slug": "7-hard-ripples", "title": "Level 7 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26240,9 +28933,10 @@ "inactive": true, "legacyFolderID": "Fd73c60b99f68ab860d1b776a07630e2c7a956c0ba189cbdd155b58f8c5b591fe", "searchTerms": [], + "slug": "7-hard-saucer", "title": "Level 7 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26250,9 +28944,10 @@ "inactive": true, "legacyFolderID": "F55dbee2c2a2424f84c51e14635aba59f53be3304fb2242ecec768f2793d99a23", "searchTerms": [], + "slug": "7-normal-jubeat", "title": "Level 7 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26260,9 +28955,10 @@ "inactive": true, "legacyFolderID": "Fed06249c325741c7a3db4825fc6b28b77c9f6d81ac2cc53be5bcbce67a35529e", "searchTerms": [], + "slug": "7-normal-knit", "title": "Level 7 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26270,9 +28966,10 @@ "inactive": true, "legacyFolderID": "Fb55eda6f0f573de5b0adceedc88a6cab8d3ce4effd73f00cb727bd25b60372d4", "searchTerms": [], + "slug": "7-normal-prop", "title": "Level 7 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26280,9 +28977,10 @@ "inactive": true, "legacyFolderID": "Fb99f7d9f262e158d27de712d01bec669089606e7d1cc041119aa6ac500fa8833", "searchTerms": [], + "slug": "7-normal-qubell", "title": "Level 7 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26290,9 +28988,10 @@ "inactive": true, "legacyFolderID": "F711925ce028498ee5a2f6b1346f8579a0bfe47b9eb24902d1efe80bfbd5f66b9", "searchTerms": [], + "slug": "7-normal-ripples", "title": "Level 7 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26300,9 +28999,10 @@ "inactive": true, "legacyFolderID": "Fe59159702783da498d98857ac24920d2f7438b828d583a3b109575191ad25a98", "searchTerms": [], + "slug": "7-normal-saucer", "title": "Level 7 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '7'" }, { "game": "jubeat", @@ -26310,9 +29010,10 @@ "inactive": false, "legacyFolderID": "Fc40aa3efbcbb9db673ce785a2ecb6daec83678cceabe7631ad07cf7561a3425f", "searchTerms": [], + "slug": "8-normal-ave", "title": "Level 8 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26320,9 +29021,10 @@ "inactive": true, "legacyFolderID": "Fa2b74d5fada19308eb33450c20f696a44957505041fbd4aef4b06b481b8c751f", "searchTerms": [], + "slug": "8-normal-copious", "title": "Level 8 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26330,9 +29032,10 @@ "inactive": false, "legacyFolderID": "F6e1caa5245cb292b8ecbe45b3da8e6cb0669c1f253660f3c59e28539e73d3dc6", "searchTerms": [], + "slug": "8-normal-festo-omni", "title": "Level 8 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26340,9 +29043,10 @@ "inactive": false, "legacyFolderID": "F76a7f68a4267e7a7943b03106df0599499aaab03b1b8871947be37d16a5dc458", "searchTerms": [], + "slug": "8-normal-festo", "title": "Level 8 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26350,9 +29054,10 @@ "inactive": false, "legacyFolderID": "Fd3e2031f23c8459e85aa7cfbd95db9963b344ff33faf3f06565e06ab0a3c7a16", "searchTerms": [], + "slug": "8-hard-ave", "title": "Level 8 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26360,9 +29065,10 @@ "inactive": true, "legacyFolderID": "F3fba40955b527a18f21b6e9ed1216ebd487716e4549e2ea50112eed74ab5cf63", "searchTerms": [], + "slug": "8-hard-copious", "title": "Level 8 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26370,9 +29076,10 @@ "inactive": false, "legacyFolderID": "Fd18eb6d0ee1f169786210fd1c9846de0ec048dee0f47d90994e2b33419882cae", "searchTerms": [], + "slug": "8-hard-festo-omni", "title": "Level 8 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26380,9 +29087,10 @@ "inactive": false, "legacyFolderID": "F5f30adfeabab660ee6f3df9809405d8e893e5bb0a847d61036f89ce8325ea909", "searchTerms": [], + "slug": "8-hard-festo", "title": "Level 8 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26390,9 +29098,10 @@ "inactive": true, "legacyFolderID": "Fb7f5d391ef6adb73782ee4a583b9159ec6eadfe8d9572590e3735c5388719cc9", "searchTerms": [], + "slug": "8-hard-jubeat", "title": "Level 8 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26400,9 +29109,10 @@ "inactive": true, "legacyFolderID": "Fc3a7b5152c97b283523c147944763d62c4a804ec0317be42287bb2ab0bb2d263", "searchTerms": [], + "slug": "8-hard-knit", "title": "Level 8 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26410,9 +29120,10 @@ "inactive": true, "legacyFolderID": "F29598ae4c21c30db8eb0181858b1d294eca9dde172148958b4793c2b3ad30e61", "searchTerms": [], + "slug": "8-hard-prop", "title": "Level 8 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26420,9 +29131,10 @@ "inactive": true, "legacyFolderID": "Fc2de370a404c5e5d514852fce0a2f6762575f0c021b4f02007d38a88d5812078", "searchTerms": [], + "slug": "8-hard-qubell", "title": "Level 8 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26430,9 +29142,10 @@ "inactive": true, "legacyFolderID": "F62215376e1a59534f2c1902ee7bfd94c527f01d10a1d02e8d98531760a73c22f", "searchTerms": [], + "slug": "8-hard-ripples", "title": "Level 8 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26440,9 +29153,10 @@ "inactive": true, "legacyFolderID": "F435403fe60fccd96f937cac9cc0f6d3e024e9b1a2715fe541717e2c13e0170ce", "searchTerms": [], + "slug": "8-hard-saucer", "title": "Level 8 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26450,9 +29164,10 @@ "inactive": true, "legacyFolderID": "Fb76ecd1f79173d55bde6a33e0f3c3f0292bf0d16017efaf2af4efb3f460d394e", "searchTerms": [], + "slug": "8-normal-jubeat", "title": "Level 8 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26460,9 +29175,10 @@ "inactive": true, "legacyFolderID": "F80783963061b36a526fd83484b7af50787edca02c2fd759cafc6b02caf645cec", "searchTerms": [], + "slug": "8-normal-knit", "title": "Level 8 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26470,9 +29186,10 @@ "inactive": true, "legacyFolderID": "Fb4720f67371f95eb8ba76edc45319ee2dfb4e18a39a21b15bceaf7ad370a45b6", "searchTerms": [], + "slug": "8-normal-prop", "title": "Level 8 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26480,9 +29197,10 @@ "inactive": true, "legacyFolderID": "Fedd2b0546fd624cd3feeb2c70581a15081960dc86e4fdf177588a4dfbc5bfcd7", "searchTerms": [], + "slug": "8-normal-qubell", "title": "Level 8 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26490,9 +29208,10 @@ "inactive": true, "legacyFolderID": "F3ed4f718aab2adb0f8b2808821d73438b762bb6fdd02bcfc2a429870af349d73", "searchTerms": [], + "slug": "8-normal-ripples", "title": "Level 8 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26500,9 +29219,10 @@ "inactive": true, "legacyFolderID": "Fc737ae012958a50ee46f6126abcf6b12c5b6194484853c8c310a07187cc1669d", "searchTerms": [], + "slug": "8-normal-saucer", "title": "Level 8 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '8'" }, { "game": "jubeat", @@ -26510,9 +29230,10 @@ "inactive": false, "legacyFolderID": "Fc3686f647d38acd150c719cc7aca8e013bd50486ae82247087063afc1caaf742", "searchTerms": [], + "slug": "9p0-normal-ave", "title": "Level 9.0 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.0'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.0'" }, { "game": "jubeat", @@ -26520,9 +29241,10 @@ "inactive": false, "legacyFolderID": "Fd5bb624ce939d93dbefda85a5d49e702b6a905744755122ff2a7626cd4bd5e23", "searchTerms": [], + "slug": "9p0-normal-festo-omni", "title": "Level 9.0 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.0'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.0'" }, { "game": "jubeat", @@ -26530,9 +29252,10 @@ "inactive": false, "legacyFolderID": "F51493e5c749a20340893249cc21806c386b20dd4327dbeb4fcc557ca2bfe578a", "searchTerms": [], + "slug": "9p0-normal-festo", "title": "Level 9.0 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.0'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.0'" }, { "game": "jubeat", @@ -26540,9 +29263,10 @@ "inactive": false, "legacyFolderID": "F2811a466ee6657e2a2fb2b77423ba489e99207b5115e8656d0a91ef12b4883aa", "searchTerms": [], + "slug": "9p0-hard-ave", "title": "Level 9.0 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.0'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.0'" }, { "game": "jubeat", @@ -26550,9 +29274,10 @@ "inactive": false, "legacyFolderID": "F2365569405dcba0ba32c05b6c858f0a870047a5655fd0df3f7d1dc65f61b1f26", "searchTerms": [], + "slug": "9p0-hard-festo-omni", "title": "Level 9.0 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.0'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.0'" }, { "game": "jubeat", @@ -26560,9 +29285,10 @@ "inactive": false, "legacyFolderID": "F58fe76aabadd6aec7d2bfb29d51c981c5c760a278e16e56e0187b8446303d6e4", "searchTerms": [], + "slug": "9p0-hard-festo", "title": "Level 9.0 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.0'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.0'" }, { "game": "jubeat", @@ -26570,9 +29296,10 @@ "inactive": true, "legacyFolderID": "F74e817a9ea5669aebd8fd7a1e409ff8f4d5c29208bea07dcd79fe9efad5bf3aa", "searchTerms": [], + "slug": "9p0-hard-qubell", "title": "Level 9.0 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.0'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.0'" }, { "game": "jubeat", @@ -26580,9 +29307,10 @@ "inactive": true, "legacyFolderID": "F46aa6d8c253c546352aa966883a6899f89c2de14ca775154de79ce6bb927453c", "searchTerms": [], + "slug": "9p0-normal-qubell", "title": "Level 9.0 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.0'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.0'" }, { "game": "jubeat", @@ -26590,9 +29318,10 @@ "inactive": false, "legacyFolderID": "F19730e31bb2f7b380e4445529a7d2b7011e26eb9fa87f354ad1dfc354ce833aa", "searchTerms": [], + "slug": "ge-9-lt-10-normal-ave", "title": "Level 9.0-9.9 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26600,9 +29329,10 @@ "inactive": true, "legacyFolderID": "Fdbb7928cf96fcb982133edcdc0d2903a36f36684ff6a6e5baa0a6af683409598", "searchTerms": [], + "slug": "ge-9-lt-10-normal-copious", "title": "Level 9.0-9.9 (copious)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26610,9 +29340,10 @@ "inactive": false, "legacyFolderID": "F9f57463257b4d27a9240de4ad0ef4d5da2985c6e0b54ce4a47512b43bb34b512", "searchTerms": [], + "slug": "ge-9-lt-10-normal-festo-omni", "title": "Level 9.0-9.9 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26620,9 +29351,10 @@ "inactive": false, "legacyFolderID": "F59e6555060b31213a1bf6b971b999fcc866fa1b18b6446de3cff2ecafcd9bdee", "searchTerms": [], + "slug": "ge-9-lt-10-normal-festo", "title": "Level 9.0-9.9 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26630,9 +29362,10 @@ "inactive": false, "legacyFolderID": "Fb7866cf52657e9d63d4fef365b27a568ae4d082e1e9a7dc4746c73892e16e758", "searchTerms": [], + "slug": "ge-9-lt-10-hard-ave", "title": "Level 9.0-9.9 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26640,9 +29373,10 @@ "inactive": true, "legacyFolderID": "Fd15d2888e764b7f62d9afcced2871da5223f08e0d4e2e46f06e57642901b5252", "searchTerms": [], + "slug": "ge-9-lt-10-hard-copious", "title": "Level 9.0-9.9 (Hard Mode) (copious)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["copious"] + "versionFilter": ["copious"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26650,9 +29384,10 @@ "inactive": false, "legacyFolderID": "F6680860cc5eaa8e6d44401cbb33840eeca42a2bfeaf7a5a3b6a5e8e4c74bca52", "searchTerms": [], + "slug": "ge-9-lt-10-hard-festo-omni", "title": "Level 9.0-9.9 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26660,9 +29395,10 @@ "inactive": false, "legacyFolderID": "F6ebbc454d6ddac566c6dc82f06f3ce943a392597417bfbb1d4a8d610e3caba21", "searchTerms": [], + "slug": "ge-9-lt-10-hard-festo", "title": "Level 9.0-9.9 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26670,9 +29406,10 @@ "inactive": true, "legacyFolderID": "F0657b5dc2a06da9ba3d2acc815a13432bc9bba47a2d6f431e0e71a5834afe47f", "searchTerms": [], + "slug": "ge-9-lt-10-hard-jubeat", "title": "Level 9.0-9.9 (Hard Mode) (jubeat)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26680,9 +29417,10 @@ "inactive": true, "legacyFolderID": "F9eaca19d3b1f61554fb3ca6b759ca8543d0ee4d510760315e258d03b3289539a", "searchTerms": [], + "slug": "ge-9-lt-10-hard-knit", "title": "Level 9.0-9.9 (Hard Mode) (knit)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26690,9 +29428,10 @@ "inactive": true, "legacyFolderID": "Fad4d547d85ea1aa30bf087ec3fd3bfe66793d58bd6b9337d71e53f42fa5a15b6", "searchTerms": [], + "slug": "ge-9-lt-10-hard-prop", "title": "Level 9.0-9.9 (Hard Mode) (prop)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26700,9 +29439,10 @@ "inactive": true, "legacyFolderID": "Fb3b5b2dfd5e4f8261d9b81f5751761691e7ddaa6c070273496c3861738338e38", "searchTerms": [], + "slug": "ge-9-lt-10-hard-qubell", "title": "Level 9.0-9.9 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26710,9 +29450,10 @@ "inactive": true, "legacyFolderID": "F8f92ec10fb0a83bdab5418d60df23b8d08e4a65c7433da888b4c0c9faeca11e5", "searchTerms": [], + "slug": "ge-9-lt-10-hard-ripples", "title": "Level 9.0-9.9 (Hard Mode) (ripples)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26720,9 +29461,10 @@ "inactive": true, "legacyFolderID": "Fbf150a066ed3d049fd1f77933d7a0af21467460fd326b307ef6208d19d51d022", "searchTerms": [], + "slug": "ge-9-lt-10-hard-saucer", "title": "Level 9.0-9.9 (Hard Mode) (saucer)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26730,9 +29472,10 @@ "inactive": true, "legacyFolderID": "Fddbb178cd6cdfe235608b9afa80ad102518186558722503c0d6d025ab92aa585", "searchTerms": [], + "slug": "ge-9-lt-10-normal-jubeat", "title": "Level 9.0-9.9 (jubeat)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["jubeat"] + "versionFilter": ["jubeat"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26740,9 +29483,10 @@ "inactive": true, "legacyFolderID": "Fac39d5ce1b5f5bdd0455d14a1cc676cf31a25e8f5561a8f7c5e646e96d7177b3", "searchTerms": [], + "slug": "ge-9-lt-10-normal-knit", "title": "Level 9.0-9.9 (knit)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["knit"] + "versionFilter": ["knit"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26750,9 +29494,10 @@ "inactive": true, "legacyFolderID": "F8d24ad67612aeff1ea83d007e8b1f525d63272676e6544e338f720a0c44e52d1", "searchTerms": [], + "slug": "ge-9-lt-10-normal-prop", "title": "Level 9.0-9.9 (prop)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["prop"] + "versionFilter": ["prop"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26760,9 +29505,10 @@ "inactive": true, "legacyFolderID": "F218bcc110ad4163ad2a30269b505940c09c109f38a7dfa329280d633e5f8609b", "searchTerms": [], + "slug": "ge-9-lt-10-normal-qubell", "title": "Level 9.0-9.9 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26770,9 +29516,10 @@ "inactive": true, "legacyFolderID": "Fce5dd56503cb3354c2f7874ff0f9b50e422de0d4efc747e8e5f04c7c1fef3cf8", "searchTerms": [], + "slug": "ge-9-lt-10-normal-ripples", "title": "Level 9.0-9.9 (ripples)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["ripples"] + "versionFilter": ["ripples"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26780,9 +29527,10 @@ "inactive": true, "legacyFolderID": "F2d04b07887bcb93a2a85aa4db1b3b00755e4eb690d2b54ce5fb5a8c36bc786d8", "searchTerms": [], + "slug": "ge-9-lt-10-normal-saucer", "title": "Level 9.0-9.9 (saucer)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10", - "versionFilter": ["saucer"] + "versionFilter": ["saucer"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level_num >= 9 AND chart.level_num < 10" }, { "game": "jubeat", @@ -26790,9 +29538,10 @@ "inactive": false, "legacyFolderID": "Ffee060997fe1d7f9a2a04adc5125d73f440ef18726d02c9f1997956ccc2ef5f8", "searchTerms": [], + "slug": "9p1-normal-ave", "title": "Level 9.1 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.1'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.1'" }, { "game": "jubeat", @@ -26800,9 +29549,10 @@ "inactive": false, "legacyFolderID": "F1d92d7f0b7495f853ecc6a59477f554ed5c1071384f8dc4751153c5cc6d7451f", "searchTerms": [], + "slug": "9p1-normal-festo-omni", "title": "Level 9.1 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.1'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.1'" }, { "game": "jubeat", @@ -26810,9 +29560,10 @@ "inactive": false, "legacyFolderID": "F891e0c398de94128202bf4c05d1bfb81167f541bfd9640a519afc269d098a3e7", "searchTerms": [], + "slug": "9p1-normal-festo", "title": "Level 9.1 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.1'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.1'" }, { "game": "jubeat", @@ -26820,9 +29571,10 @@ "inactive": false, "legacyFolderID": "F22d6f27ed7134da3d2f6918dd4028455d9f86d08184c2eeeb319a071ca18f8e2", "searchTerms": [], + "slug": "9p1-hard-ave", "title": "Level 9.1 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.1'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.1'" }, { "game": "jubeat", @@ -26830,9 +29582,10 @@ "inactive": false, "legacyFolderID": "F86c16ca4fd2fdf908137538b01b75b53b595658f1c11de1d6ee19609f57a71b9", "searchTerms": [], + "slug": "9p1-hard-festo-omni", "title": "Level 9.1 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.1'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.1'" }, { "game": "jubeat", @@ -26840,9 +29593,10 @@ "inactive": false, "legacyFolderID": "F7dda621093eec21d2838df3433d2d41d6a1bcfc3cad05f0e65097790625fb53a", "searchTerms": [], + "slug": "9p1-hard-festo", "title": "Level 9.1 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.1'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.1'" }, { "game": "jubeat", @@ -26850,9 +29604,10 @@ "inactive": true, "legacyFolderID": "F6b276562e0f4ae1f409bc351b0ae483b4ea249ff6c3bb8464278e6b7a0a16391", "searchTerms": [], + "slug": "9p1-hard-qubell", "title": "Level 9.1 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.1'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.1'" }, { "game": "jubeat", @@ -26860,9 +29615,10 @@ "inactive": true, "legacyFolderID": "F4201a4fcb1f77153f78b7630c00ea16aa38f2ff8c9530639619d29cb6ac5bc83", "searchTerms": [], + "slug": "9p1-normal-qubell", "title": "Level 9.1 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.1'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.1'" }, { "game": "jubeat", @@ -26870,9 +29626,10 @@ "inactive": false, "legacyFolderID": "F2b50a4fbd7b239951daa3e233cb26ee18b40d6950f0f0c1875344be3baaf4724", "searchTerms": [], + "slug": "9p2-normal-ave", "title": "Level 9.2 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.2'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.2'" }, { "game": "jubeat", @@ -26880,9 +29637,10 @@ "inactive": false, "legacyFolderID": "F8a1b1132e5f14ea1c08f89aebb65c00713265f87b1cc8f3d4a73b836360b4886", "searchTerms": [], + "slug": "9p2-normal-festo-omni", "title": "Level 9.2 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.2'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.2'" }, { "game": "jubeat", @@ -26890,9 +29648,10 @@ "inactive": false, "legacyFolderID": "F77dac654747515a2fd6739c70b986d92c311f339461e9d0e83baf476537523cd", "searchTerms": [], + "slug": "9p2-normal-festo", "title": "Level 9.2 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.2'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.2'" }, { "game": "jubeat", @@ -26900,9 +29659,10 @@ "inactive": false, "legacyFolderID": "F9be09493fc662017d5a329f2d00c27b7cb052f75b0fd2926bd0631a56b17e5fc", "searchTerms": [], + "slug": "9p2-hard-ave", "title": "Level 9.2 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.2'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.2'" }, { "game": "jubeat", @@ -26910,9 +29670,10 @@ "inactive": false, "legacyFolderID": "Fc5ae59bc39e4411c7474416e621e00d41418ebfc934e62e0292005fd33aebf27", "searchTerms": [], + "slug": "9p2-hard-festo-omni", "title": "Level 9.2 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.2'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.2'" }, { "game": "jubeat", @@ -26920,9 +29681,10 @@ "inactive": false, "legacyFolderID": "F2c9719d397174a8544ae71d6897f19b38d04f79aeb043978b069b4f86525b266", "searchTerms": [], + "slug": "9p2-hard-festo", "title": "Level 9.2 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.2'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.2'" }, { "game": "jubeat", @@ -26930,9 +29692,10 @@ "inactive": true, "legacyFolderID": "Fb63da160a5ea5313311e4225b22cd9eda26e71281d3815720b7f6a151f7cce8f", "searchTerms": [], + "slug": "9p2-hard-qubell", "title": "Level 9.2 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.2'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.2'" }, { "game": "jubeat", @@ -26940,9 +29703,10 @@ "inactive": true, "legacyFolderID": "Fde077861a27b54d8a106abb90558f3b3029f520e116fdf2efcfc1712527b05b2", "searchTerms": [], + "slug": "9p2-normal-qubell", "title": "Level 9.2 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.2'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.2'" }, { "game": "jubeat", @@ -26950,9 +29714,10 @@ "inactive": false, "legacyFolderID": "F7bf09f03d4dad80b5f8b9f3b34b01fe71410d65a61316efce467a51c4a747da9", "searchTerms": [], + "slug": "9p3-normal-ave", "title": "Level 9.3 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.3'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.3'" }, { "game": "jubeat", @@ -26960,9 +29725,10 @@ "inactive": false, "legacyFolderID": "F9acea0b278b8448062157f994fde89438ae22b0f01b3cde87d3611de0f043132", "searchTerms": [], + "slug": "9p3-normal-festo-omni", "title": "Level 9.3 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.3'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.3'" }, { "game": "jubeat", @@ -26970,9 +29736,10 @@ "inactive": false, "legacyFolderID": "Febeed9e1c76d7d7a15767565709920b0fc598e4db86bb01efbf3e83d0e9654df", "searchTerms": [], + "slug": "9p3-normal-festo", "title": "Level 9.3 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.3'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.3'" }, { "game": "jubeat", @@ -26980,9 +29747,10 @@ "inactive": false, "legacyFolderID": "F33fd107808bbf502f8f2584d69ac29c7b187f622baa1476e0bbab70272c360a9", "searchTerms": [], + "slug": "9p3-hard-ave", "title": "Level 9.3 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.3'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.3'" }, { "game": "jubeat", @@ -26990,9 +29758,10 @@ "inactive": false, "legacyFolderID": "F8aedff32beaf689ca0733338d83c7b89737b3843fc21e11dad882adf5c848f4a", "searchTerms": [], + "slug": "9p3-hard-festo-omni", "title": "Level 9.3 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.3'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.3'" }, { "game": "jubeat", @@ -27000,9 +29769,10 @@ "inactive": false, "legacyFolderID": "Fd327bfe57f83f98fb6a44affac4494902220d8a2b01a7136a06b8a64b45fc76c", "searchTerms": [], + "slug": "9p3-hard-festo", "title": "Level 9.3 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.3'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.3'" }, { "game": "jubeat", @@ -27010,9 +29780,10 @@ "inactive": true, "legacyFolderID": "F6cc70718c31faccc7043c2c39a8d5b2838262282b518021458f963c1708c22a1", "searchTerms": [], + "slug": "9p3-hard-qubell", "title": "Level 9.3 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.3'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.3'" }, { "game": "jubeat", @@ -27020,9 +29791,10 @@ "inactive": true, "legacyFolderID": "F353c15d53211abd20769b3537588e52c84a5378a39bf100d037b7c38b63c3af2", "searchTerms": [], + "slug": "9p3-normal-qubell", "title": "Level 9.3 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.3'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.3'" }, { "game": "jubeat", @@ -27030,9 +29802,10 @@ "inactive": false, "legacyFolderID": "Fffa89bcc26d4f004bb1c59eed0c61c7609633f82447559f2788a7b9024e21b91", "searchTerms": [], + "slug": "9p4-normal-ave", "title": "Level 9.4 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.4'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.4'" }, { "game": "jubeat", @@ -27040,9 +29813,10 @@ "inactive": false, "legacyFolderID": "Fd20dc5655f5a368096614f7093d1d9a2a44dcec03a6a519dc51bbda1326e28f3", "searchTerms": [], + "slug": "9p4-normal-festo-omni", "title": "Level 9.4 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.4'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.4'" }, { "game": "jubeat", @@ -27050,9 +29824,10 @@ "inactive": false, "legacyFolderID": "F80a7a25f0647ffb9a756ecec5c865f1dea8f9d027d8ef3c26fc69b637b2f2a4f", "searchTerms": [], + "slug": "9p4-normal-festo", "title": "Level 9.4 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.4'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.4'" }, { "game": "jubeat", @@ -27060,9 +29835,10 @@ "inactive": false, "legacyFolderID": "F7a69419fd3c20c324d2817250c2abb14b9d0cd16ad06a7bb7dd505def74f9039", "searchTerms": [], + "slug": "9p4-hard-ave", "title": "Level 9.4 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.4'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.4'" }, { "game": "jubeat", @@ -27070,9 +29846,10 @@ "inactive": false, "legacyFolderID": "Fc4027a2823bcc5c9d88f44d57d13374d1f8b76bfd8d79bb4f957913a0291e93c", "searchTerms": [], + "slug": "9p4-hard-festo-omni", "title": "Level 9.4 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.4'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.4'" }, { "game": "jubeat", @@ -27080,9 +29857,10 @@ "inactive": false, "legacyFolderID": "Ff0e90f0796359d4c032330e1b41b3025f94901c9eac7d6cf363006b3a7af5ddd", "searchTerms": [], + "slug": "9p4-hard-festo", "title": "Level 9.4 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.4'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.4'" }, { "game": "jubeat", @@ -27090,9 +29868,10 @@ "inactive": true, "legacyFolderID": "F3d7c9d5853e4c9ba66a5e20f3a0abf18d212046d08944e3288fa906167a05bc2", "searchTerms": [], + "slug": "9p4-hard-qubell", "title": "Level 9.4 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.4'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.4'" }, { "game": "jubeat", @@ -27100,9 +29879,10 @@ "inactive": true, "legacyFolderID": "F6b56efcfff73f05f5e50c09a721f27427e532424ba40c54979526b4133e16483", "searchTerms": [], + "slug": "9p4-normal-qubell", "title": "Level 9.4 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.4'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.4'" }, { "game": "jubeat", @@ -27110,9 +29890,10 @@ "inactive": false, "legacyFolderID": "Fc80f1fc48ccca8e5e44307dcc95ebe56aeadceece5d032905ebd9b72b80513e7", "searchTerms": [], + "slug": "9p5-normal-ave", "title": "Level 9.5 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.5'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.5'" }, { "game": "jubeat", @@ -27120,9 +29901,10 @@ "inactive": false, "legacyFolderID": "Fcbabc5acc06a36077396ae670c02d5477a367438777188c0c780222a656af438", "searchTerms": [], + "slug": "9p5-normal-festo-omni", "title": "Level 9.5 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.5'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.5'" }, { "game": "jubeat", @@ -27130,9 +29912,10 @@ "inactive": false, "legacyFolderID": "F50ae7e80ad36716d117f4b0a1154d9558bf0ac9d40bdfa8ca6f821687bab5893", "searchTerms": [], + "slug": "9p5-normal-festo", "title": "Level 9.5 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.5'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.5'" }, { "game": "jubeat", @@ -27140,9 +29923,10 @@ "inactive": false, "legacyFolderID": "F3f8020a83ff5b717ce48f10f6f4acb44dde391e12c01726995e1d6ea777e1a0c", "searchTerms": [], + "slug": "9p5-hard-ave", "title": "Level 9.5 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.5'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.5'" }, { "game": "jubeat", @@ -27150,9 +29934,10 @@ "inactive": false, "legacyFolderID": "F8a6675a3bd385df4b17dd1599c2147bbe01918783d2f8e8b3f628d637450815a", "searchTerms": [], + "slug": "9p5-hard-festo-omni", "title": "Level 9.5 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.5'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.5'" }, { "game": "jubeat", @@ -27160,9 +29945,10 @@ "inactive": false, "legacyFolderID": "Fad3b49693ee5b431b45637f831d2c119162b33075d71bf4b0b5265887c3014aa", "searchTerms": [], + "slug": "9p5-hard-festo", "title": "Level 9.5 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.5'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.5'" }, { "game": "jubeat", @@ -27170,9 +29956,10 @@ "inactive": true, "legacyFolderID": "F028126e50838bc453fc9113e93c21349929ee537479dccf69cedbe66cef2709a", "searchTerms": [], + "slug": "9p5-hard-qubell", "title": "Level 9.5 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.5'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.5'" }, { "game": "jubeat", @@ -27180,9 +29967,10 @@ "inactive": true, "legacyFolderID": "Fe957b832a6e1cbeefdc465b8b46e44420354958828ff5934349c137900483911", "searchTerms": [], + "slug": "9p5-normal-qubell", "title": "Level 9.5 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.5'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.5'" }, { "game": "jubeat", @@ -27190,9 +29978,10 @@ "inactive": false, "legacyFolderID": "Fc64de863cbf48a31a0aa3139f4e43f9ebe2677fa2e0a39b677b3a7c526c1a0db", "searchTerms": [], + "slug": "9p6-normal-ave", "title": "Level 9.6 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.6'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.6'" }, { "game": "jubeat", @@ -27200,9 +29989,10 @@ "inactive": false, "legacyFolderID": "F2fc642977bad848ff05d9cc97af2efd48e7493eea0dd5d707361184f397b6a68", "searchTerms": [], + "slug": "9p6-normal-festo-omni", "title": "Level 9.6 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.6'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.6'" }, { "game": "jubeat", @@ -27210,9 +30000,10 @@ "inactive": false, "legacyFolderID": "Fa013f36b81be6d217196cc1df0ed400bd872dae9e544dfb860a624a8be2a2e68", "searchTerms": [], + "slug": "9p6-normal-festo", "title": "Level 9.6 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.6'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.6'" }, { "game": "jubeat", @@ -27220,9 +30011,10 @@ "inactive": false, "legacyFolderID": "F34aae5f0b86bef40e0ed2c6d78c2c6107be09632abfffbd4f43df79a53c2d4dd", "searchTerms": [], + "slug": "9p6-hard-ave", "title": "Level 9.6 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.6'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.6'" }, { "game": "jubeat", @@ -27230,9 +30022,10 @@ "inactive": false, "legacyFolderID": "Fd1e0bd4703eac0e41bd3d59a18cda9d422823fd78e1099f1e15e1fbff605cc8a", "searchTerms": [], + "slug": "9p6-hard-festo-omni", "title": "Level 9.6 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.6'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.6'" }, { "game": "jubeat", @@ -27240,9 +30033,10 @@ "inactive": false, "legacyFolderID": "Fe2b324ded8a55683114b47f8e819c47dd764092be4145db612969954c249dfb5", "searchTerms": [], + "slug": "9p6-hard-festo", "title": "Level 9.6 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.6'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.6'" }, { "game": "jubeat", @@ -27250,9 +30044,10 @@ "inactive": true, "legacyFolderID": "Fb6ffc80d91e4b7b5216ebfd33b81639894fdbd841532a525527caf5a8e5e5591", "searchTerms": [], + "slug": "9p6-hard-qubell", "title": "Level 9.6 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.6'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.6'" }, { "game": "jubeat", @@ -27260,9 +30055,10 @@ "inactive": true, "legacyFolderID": "Fb13be85c838e175aeab968376c07c639ae59c23ebc9b06e8e5dc886ded4d4d87", "searchTerms": [], + "slug": "9p6-normal-qubell", "title": "Level 9.6 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.6'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.6'" }, { "game": "jubeat", @@ -27270,9 +30066,10 @@ "inactive": false, "legacyFolderID": "F10a2ff0390e2d00e152f927501b520afbca7cece87668a26abe87538331b7d7a", "searchTerms": [], + "slug": "9p7-normal-ave", "title": "Level 9.7 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.7'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.7'" }, { "game": "jubeat", @@ -27280,9 +30077,10 @@ "inactive": false, "legacyFolderID": "F965c297f454c3e2cdc04e66f366af65543bd320358284a71c321f69d9fdfdb1b", "searchTerms": [], + "slug": "9p7-normal-festo-omni", "title": "Level 9.7 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.7'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.7'" }, { "game": "jubeat", @@ -27290,9 +30088,10 @@ "inactive": false, "legacyFolderID": "F4ed014686d77a9a8b57aba784317dca45c579d258531a2317877a2dfebf29eb0", "searchTerms": [], + "slug": "9p7-normal-festo", "title": "Level 9.7 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.7'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.7'" }, { "game": "jubeat", @@ -27300,9 +30099,10 @@ "inactive": false, "legacyFolderID": "Fab5781695a5aba18b93d0fdd743dec108375646e876542a376f89da3f29670c4", "searchTerms": [], + "slug": "9p7-hard-ave", "title": "Level 9.7 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.7'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.7'" }, { "game": "jubeat", @@ -27310,9 +30110,10 @@ "inactive": false, "legacyFolderID": "F9c15f5b959a58a7a0cc88436c5b8b568ed655f7815b8608f0df6dcae31b35236", "searchTerms": [], + "slug": "9p7-hard-festo-omni", "title": "Level 9.7 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.7'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.7'" }, { "game": "jubeat", @@ -27320,9 +30121,10 @@ "inactive": false, "legacyFolderID": "F63b2a138e1a18bdc68ddb3edda7023b26f03850746560a036ba9d15fcec0d521", "searchTerms": [], + "slug": "9p7-hard-festo", "title": "Level 9.7 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.7'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.7'" }, { "game": "jubeat", @@ -27330,9 +30132,10 @@ "inactive": true, "legacyFolderID": "F78d73e35eda2ce5ed6b13a3dc76b02ece0a19bda9fd54044f763910cb2840cd4", "searchTerms": [], + "slug": "9p7-hard-qubell", "title": "Level 9.7 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.7'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.7'" }, { "game": "jubeat", @@ -27340,9 +30143,10 @@ "inactive": true, "legacyFolderID": "F589ac717011e1082c72181c9c2888f26789a6ab712f530965af335ae8410b850", "searchTerms": [], + "slug": "9p7-normal-qubell", "title": "Level 9.7 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.7'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.7'" }, { "game": "jubeat", @@ -27350,9 +30154,10 @@ "inactive": false, "legacyFolderID": "F7e549e00562f728a186ef657bf757bd5dd3b54e5beeae9e641629c6d6e7f9f7a", "searchTerms": [], + "slug": "9p8-normal-ave", "title": "Level 9.8 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.8'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.8'" }, { "game": "jubeat", @@ -27360,9 +30165,10 @@ "inactive": false, "legacyFolderID": "F87be14fff303494eb598aa99d6b3a54daee9a17f8527d1fded709fdffb74a788", "searchTerms": [], + "slug": "9p8-normal-festo-omni", "title": "Level 9.8 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.8'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.8'" }, { "game": "jubeat", @@ -27370,9 +30176,10 @@ "inactive": false, "legacyFolderID": "Fb21572a62ad2b6a573d7f33f58afd513583df8bedb10fb42c5f1d975b2d48d90", "searchTerms": [], + "slug": "9p8-normal-festo", "title": "Level 9.8 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.8'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.8'" }, { "game": "jubeat", @@ -27380,9 +30187,10 @@ "inactive": false, "legacyFolderID": "Fec7a8781afb1cbaf19981c057ae0ad7440334987f7af7a39f725da5684cd6ef9", "searchTerms": [], + "slug": "9p8-hard-ave", "title": "Level 9.8 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.8'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.8'" }, { "game": "jubeat", @@ -27390,9 +30198,10 @@ "inactive": false, "legacyFolderID": "Fd6eb1c63061e2ec02c779a579150e7341d12a97fa4ae38eebaa3748a9b04bbba", "searchTerms": [], + "slug": "9p8-hard-festo-omni", "title": "Level 9.8 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.8'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.8'" }, { "game": "jubeat", @@ -27400,9 +30209,10 @@ "inactive": false, "legacyFolderID": "F02472c722227e370d679692efe7b0b3ac1d5130ca9d7e0da12c463cd11b1bb3f", "searchTerms": [], + "slug": "9p8-hard-festo", "title": "Level 9.8 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.8'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.8'" }, { "game": "jubeat", @@ -27410,9 +30220,10 @@ "inactive": true, "legacyFolderID": "F2546b5538a7c5c2b98ee6443a1fbbff2d2bf759897c82610aa609950fba9922b", "searchTerms": [], + "slug": "9p8-hard-qubell", "title": "Level 9.8 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.8'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.8'" }, { "game": "jubeat", @@ -27420,9 +30231,10 @@ "inactive": true, "legacyFolderID": "F6e370e5d7c738f3aec4c1fc299621579738233bfec9902db8ba4ce06d9633395", "searchTerms": [], + "slug": "9p8-normal-qubell", "title": "Level 9.8 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.8'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.8'" }, { "game": "jubeat", @@ -27430,9 +30242,10 @@ "inactive": false, "legacyFolderID": "F38b28556d70793b5137c3ace3d4b11d6a4a827e284a30f6c268eb08824c5e490", "searchTerms": [], + "slug": "9p9-normal-ave", "title": "Level 9.9 (Ave.)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.9'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.9'" }, { "game": "jubeat", @@ -27440,9 +30253,10 @@ "inactive": false, "legacyFolderID": "Fc14a2034372aa3d90253ef8bf09afd01873c23f9f58e4874d833ff1b15a2ae3f", "searchTerms": [], + "slug": "9p9-normal-festo-omni", "title": "Level 9.9 (festo Omnimix)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.9'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.9'" }, { "game": "jubeat", @@ -27450,9 +30264,10 @@ "inactive": false, "legacyFolderID": "Ff40c8135b5c0107ef3445159aaf2fcb0cd374670c518f994b4afa4475752814c", "searchTerms": [], + "slug": "9p9-normal-festo", "title": "Level 9.9 (festo)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.9'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.9'" }, { "game": "jubeat", @@ -27460,9 +30275,10 @@ "inactive": false, "legacyFolderID": "Fdc4ae8fe84ab78fb5a05a2040a368fccd2c19b7a3081e8916a94dfce74989c2a", "searchTerms": [], + "slug": "9p9-hard-ave", "title": "Level 9.9 (Hard Mode) (Ave.)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.9'", - "versionFilter": ["ave"] + "versionFilter": ["ave"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.9'" }, { "game": "jubeat", @@ -27470,9 +30286,10 @@ "inactive": false, "legacyFolderID": "Fa5b55b4a5b5f07694ec6874fbdd807c0e9dc78424e011d3d542a99a4f6bd679a", "searchTerms": [], + "slug": "9p9-hard-festo-omni", "title": "Level 9.9 (Hard Mode) (festo Omnimix)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.9'", - "versionFilter": ["festo-omni"] + "versionFilter": ["festo-omni"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.9'" }, { "game": "jubeat", @@ -27480,9 +30297,10 @@ "inactive": false, "legacyFolderID": "F28b7d4bd96e8ef80341983dd054379587c68b1b6e75b1fab2bea47d848d2eb27", "searchTerms": [], + "slug": "9p9-hard-festo", "title": "Level 9.9 (Hard Mode) (festo)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.9'", - "versionFilter": ["festo"] + "versionFilter": ["festo"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.9'" }, { "game": "jubeat", @@ -27490,9 +30308,10 @@ "inactive": true, "legacyFolderID": "F530a322b785619dacbc14d8c803b629c0fdffe111a9e2842976eae9ad94aa172", "searchTerms": [], + "slug": "9p9-hard-qubell", "title": "Level 9.9 (Hard Mode) (Qubell)", - "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.9'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('HARD BSC', 'HARD ADV', 'HARD EXT') AND chart.level = '9.9'" }, { "game": "jubeat", @@ -27500,9 +30319,10 @@ "inactive": true, "legacyFolderID": "Fb9760c6fe5c0fb50817626822bae110569c4457e475ea7f89193d43f9d79c7ac", "searchTerms": [], + "slug": "9p9-normal-qubell", "title": "Level 9.9 (Qubell)", - "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.9'", - "versionFilter": ["qubell"] + "versionFilter": ["qubell"], + "where": "chart.difficulty IN ('BSC', 'ADV', 'EXT') AND chart.level = '9.9'" }, { "game": "maimai", @@ -27510,9 +30330,10 @@ "inactive": false, "legacyFolderID": "F1a0c0e32b477738519b1a69e167b4c6f87fa5ffd704b2a47759b414a7da4b8c4", "searchTerms": [], + "slug": "1-finale", "title": "Level 1 (FiNALE)", - "where": "chart.level = '1'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '1'" }, { "game": "maimai", @@ -27520,9 +30341,10 @@ "inactive": false, "legacyFolderID": "F15a62cbb7b4af62198f1048d315d4f784d0f9943c967ebee2e8207b376513668", "searchTerms": [], + "slug": "10-finale", "title": "Level 10 (FiNALE)", - "where": "chart.level = '10'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '10'" }, { "game": "maimai", @@ -27530,9 +30352,10 @@ "inactive": false, "legacyFolderID": "Fb26affb9911425f64b06ee78ae717f52ca2b19d06ffd26a7c1cbe90de8cea7e0", "searchTerms": [], + "slug": "10p-finale", "title": "Level 10+ (FiNALE)", - "where": "chart.level = '10+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '10+'" }, { "game": "maimai", @@ -27540,9 +30363,10 @@ "inactive": false, "legacyFolderID": "F5b7589434609a8a5117dbc71c421e9c6b8588926cf0ca01eec1cbb8f2792aa49", "searchTerms": [], + "slug": "11-finale", "title": "Level 11 (FiNALE)", - "where": "chart.level = '11'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '11'" }, { "game": "maimai", @@ -27550,9 +30374,10 @@ "inactive": false, "legacyFolderID": "F77b0b508a335d4ad32c351690364f6ec75615499660c033ce33103b489802cf8", "searchTerms": [], + "slug": "11p-finale", "title": "Level 11+ (FiNALE)", - "where": "chart.level = '11+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '11+'" }, { "game": "maimai", @@ -27560,9 +30385,10 @@ "inactive": false, "legacyFolderID": "Fae9a5d9ce589b0286a41cebf212aafba4d209e22ff8d6874717c10b743f45a36", "searchTerms": [], + "slug": "12-finale", "title": "Level 12 (FiNALE)", - "where": "chart.level = '12'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '12'" }, { "game": "maimai", @@ -27570,9 +30396,10 @@ "inactive": false, "legacyFolderID": "F6467544964f67cadfbb688470493baece6a97cdda1d0108949a807436ffe9f72", "searchTerms": [], + "slug": "12p-finale", "title": "Level 12+ (FiNALE)", - "where": "chart.level = '12+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '12+'" }, { "game": "maimai", @@ -27580,9 +30407,10 @@ "inactive": false, "legacyFolderID": "F5fd3fe0b33f6d9473c16f970a4a4a7b775c1ef833ad346d230cc76ec105cb6bd", "searchTerms": [], + "slug": "13-finale", "title": "Level 13 (FiNALE)", - "where": "chart.level = '13'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '13'" }, { "game": "maimai", @@ -27590,9 +30418,10 @@ "inactive": false, "legacyFolderID": "F6b5f9be654a31e6eb13f4f5d4fbb252392d02b0dbdd163e736f37dcd3a37ba77", "searchTerms": [], + "slug": "13p-finale", "title": "Level 13+ (FiNALE)", - "where": "chart.level = '13+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '13+'" }, { "game": "maimai", @@ -27600,9 +30429,10 @@ "inactive": false, "legacyFolderID": "F506c71630d7208d67522261012fdc3ab253ddaa3b5c9ba4e31a5c6fe88a7ffb9", "searchTerms": [], + "slug": "14-finale", "title": "Level 14 (FiNALE)", - "where": "chart.level = '14'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '14'" }, { "game": "maimai", @@ -27610,9 +30440,10 @@ "inactive": false, "legacyFolderID": "Fae54b2e40ea8e8a3cab7f007a516f6c4dbbb0eda8a6d211d02e1b8cce5cf1ff9", "searchTerms": [], + "slug": "2-finale", "title": "Level 2 (FiNALE)", - "where": "chart.level = '2'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '2'" }, { "game": "maimai", @@ -27620,9 +30451,10 @@ "inactive": false, "legacyFolderID": "Fc0ffd85c2959bd4f39703ca5df640bc55ef375de5c5633c2724ee9b3b22cd86d", "searchTerms": [], + "slug": "3-finale", "title": "Level 3 (FiNALE)", - "where": "chart.level = '3'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '3'" }, { "game": "maimai", @@ -27630,9 +30462,10 @@ "inactive": false, "legacyFolderID": "F4e7ac5d666f05d39cbb4d1a6ea8061241a904685f2e888b3e97388de6bc70e9a", "searchTerms": [], + "slug": "4-finale", "title": "Level 4 (FiNALE)", - "where": "chart.level = '4'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '4'" }, { "game": "maimai", @@ -27640,9 +30473,10 @@ "inactive": false, "legacyFolderID": "F6f79ae4e23953dbff6bfd3709360805b2652b863dedf29c7a7880157e2bc36d1", "searchTerms": [], + "slug": "5-finale", "title": "Level 5 (FiNALE)", - "where": "chart.level = '5'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '5'" }, { "game": "maimai", @@ -27650,9 +30484,10 @@ "inactive": false, "legacyFolderID": "Fe789cf5db5b4153582c557a99c1b3af74dbf8aaa90afc5cb2d9faa05ec60cf03", "searchTerms": [], + "slug": "5p-finale", "title": "Level 5+ (FiNALE)", - "where": "chart.level = '5+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '5+'" }, { "game": "maimai", @@ -27660,9 +30495,10 @@ "inactive": false, "legacyFolderID": "F1c076b827e34f5f3c03c8ee2626773c1bed614687e605acf866d3f2acd2a8bb2", "searchTerms": [], + "slug": "6-finale", "title": "Level 6 (FiNALE)", - "where": "chart.level = '6'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '6'" }, { "game": "maimai", @@ -27670,9 +30506,10 @@ "inactive": false, "legacyFolderID": "F017c78011efb1784e53b2c659fcaf089daede5ec2e39a26d55a3ddc53c4c5b58", "searchTerms": [], + "slug": "6p-finale", "title": "Level 6+ (FiNALE)", - "where": "chart.level = '6+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '6+'" }, { "game": "maimai", @@ -27680,9 +30517,10 @@ "inactive": false, "legacyFolderID": "Fee6ff8ec22ed89de6c619a5e0584a19f2e80eefc8ceddb15ab99a99ed6b17dab", "searchTerms": [], + "slug": "7-finale", "title": "Level 7 (FiNALE)", - "where": "chart.level = '7'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '7'" }, { "game": "maimai", @@ -27690,9 +30528,10 @@ "inactive": false, "legacyFolderID": "Fbec2ce2959e8a23ae39cbba3396136617c289836e50b9a9f635ded49fe546736", "searchTerms": [], + "slug": "7p-finale", "title": "Level 7+ (FiNALE)", - "where": "chart.level = '7+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '7+'" }, { "game": "maimai", @@ -27700,9 +30539,10 @@ "inactive": false, "legacyFolderID": "Fb2baf0a296fac30b8b2270f5ca3e7c759c403e2d8c110ec424e05727c6c33d18", "searchTerms": [], + "slug": "8-finale", "title": "Level 8 (FiNALE)", - "where": "chart.level = '8'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '8'" }, { "game": "maimai", @@ -27710,9 +30550,10 @@ "inactive": false, "legacyFolderID": "F85df71c7b200899ec6759b2dfee5446c7da0a0af49524b3b261c4aac34f18e09", "searchTerms": [], + "slug": "8p-finale", "title": "Level 8+ (FiNALE)", - "where": "chart.level = '8+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '8+'" }, { "game": "maimai", @@ -27720,9 +30561,10 @@ "inactive": false, "legacyFolderID": "F5534b37f2e158f7ad946f7d38b61c085b3fc6b2ddaf2cc57ae0a2693e12b803e", "searchTerms": [], + "slug": "9-finale", "title": "Level 9 (FiNALE)", - "where": "chart.level = '9'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '9'" }, { "game": "maimai", @@ -27730,9 +30572,10 @@ "inactive": false, "legacyFolderID": "F8fd9946d8a24ba19f98736bded20ad6c173063a5b4a1322c004a8775851cd5cb", "searchTerms": [], + "slug": "9p-finale", "title": "Level 9+ (FiNALE)", - "where": "chart.level = '9+'", - "versionFilter": ["finale"] + "versionFilter": ["finale"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -27740,9 +30583,10 @@ "inactive": true, "legacyFolderID": "F0e3e7919c74c9c82aa8a7baa99825f2a86efd7970a58c88c604adf0ca5bb4407", "searchTerms": [], + "slug": "1-buddies-omni", "title": "Level 1 (BUDDiES Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27750,9 +30594,10 @@ "inactive": true, "legacyFolderID": "F86b7a235538e760c8036c3a66ac282bb969d60ef533a124e101c43c0299ec542", "searchTerms": [], + "slug": "1-buddiesplus-omni", "title": "Level 1 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27760,9 +30605,10 @@ "inactive": true, "legacyFolderID": "Fef2e84ea3c606f9276e059649901ba85b6b142a974e7de2f9d6ba59282843630", "searchTerms": [], + "slug": "1-buddiesplus", "title": "Level 1 (BUDDiES PLUS)", - "where": "chart.level = '1'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27770,9 +30616,10 @@ "inactive": true, "legacyFolderID": "F6368ee35170f27874d3869158e738981a5dc8e2a0c0ca74d783d8006269bd1dd", "searchTerms": [], + "slug": "1-buddies", "title": "Level 1 (BUDDiES)", - "where": "chart.level = '1'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27780,9 +30627,10 @@ "inactive": false, "legacyFolderID": "F0a80f6806ffb5e84c076feb92e3030040437d95b1e375826a93033543f993508", "searchTerms": [], + "slug": "1-circle", "title": "Level 1 (CiRCLE)", - "where": "chart.level = '1'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27790,9 +30638,10 @@ "inactive": true, "legacyFolderID": "F3cb8c46dfb31ca7fb8d4ec18b226232b8a05303f88f329a83455099317f1759b", "searchTerms": [], + "slug": "1-festivalplus", "title": "Level 1 (FESTiVAL PLUS)", - "where": "chart.level = '1'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27800,9 +30649,10 @@ "inactive": true, "legacyFolderID": "F29303d01c9e76686779858ec865846b70d148c8de825c152aabd174616972892", "searchTerms": [], + "slug": "1-festival", "title": "Level 1 (FESTiVAL)", - "where": "chart.level = '1'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27810,9 +30660,10 @@ "inactive": true, "legacyFolderID": "F699f13160b40954c61ac7ef12ea870716e5c77c57b48bf0308d8e6bda13f2afd", "searchTerms": [], + "slug": "1-prism-omni", "title": "Level 1 (PRiSM Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27820,9 +30671,10 @@ "inactive": false, "legacyFolderID": "F9aa0e11529f8a29ce1c0996221421cdcd80759d164b55efdb71d870619396310", "searchTerms": [], + "slug": "1-prismplus-omni", "title": "Level 1 (PRiSM PLUS Omnimix)", - "where": "chart.level = '1'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27830,9 +30682,10 @@ "inactive": false, "legacyFolderID": "Fb109a12b19d82d0705ea8c34b68cde17fbbde9b3d8f4d44397db8fddae04bd99", "searchTerms": [], + "slug": "1-prismplus", "title": "Level 1 (PRiSM PLUS)", - "where": "chart.level = '1'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27840,9 +30693,10 @@ "inactive": true, "legacyFolderID": "F1a87377aebbd3d97f370b63899d439d5d2af657c16f8ab146b29b48fa9b03cca", "searchTerms": [], + "slug": "1-prism", "title": "Level 1 (PRiSM)", - "where": "chart.level = '1'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27850,9 +30704,10 @@ "inactive": true, "legacyFolderID": "F0fbdcae40c32aa7cd9a3cbb94ca1682e9e64f2c92c637be1c650e2572492f675", "searchTerms": [], + "slug": "1-universeplus", "title": "Level 1 (UNiVERSE PLUS)", - "where": "chart.level = '1'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '1'" }, { "game": "maimaidx", @@ -27860,9 +30715,10 @@ "inactive": true, "legacyFolderID": "F1461c5f4c949b9d3116bac8e692a2675d9c01806ac7216be04cae31cd08716c2", "searchTerms": [], + "slug": "10-buddies-omni", "title": "Level 10 (BUDDiES Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27870,9 +30726,10 @@ "inactive": true, "legacyFolderID": "Fef35ad5aeb72a6fa5a5397f2962ee41f5ec9ae0c2290b3b5909e5d4769efee7e", "searchTerms": [], + "slug": "10-buddiesplus-omni", "title": "Level 10 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27880,9 +30737,10 @@ "inactive": true, "legacyFolderID": "Fa117b16d37438d927d47801e1bf1ddd215d28016d14c0d133d67546e0809a3cd", "searchTerms": [], + "slug": "10-buddiesplus", "title": "Level 10 (BUDDiES PLUS)", - "where": "chart.level = '10'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27890,9 +30748,10 @@ "inactive": true, "legacyFolderID": "F6aecc2187c5cd1dfe30b908442648d1b7869bb67289433332a6c70175d370ec0", "searchTerms": [], + "slug": "10-buddies", "title": "Level 10 (BUDDiES)", - "where": "chart.level = '10'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27900,9 +30759,10 @@ "inactive": false, "legacyFolderID": "F27880c2cb47716e18fcb45c9ff2cc27c34dc695b822f4a12b79e0e75a7c98120", "searchTerms": [], + "slug": "10-circle", "title": "Level 10 (CiRCLE)", - "where": "chart.level = '10'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27910,9 +30770,10 @@ "inactive": true, "legacyFolderID": "F39f2ff24749ee84eb2ef65272c5ce9748603234cea2e867be517ec615031a0cd", "searchTerms": [], + "slug": "10-festivalplus", "title": "Level 10 (FESTiVAL PLUS)", - "where": "chart.level = '10'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27920,9 +30781,10 @@ "inactive": true, "legacyFolderID": "Ff95a21372fb9619391de8b5ea0cacf9dc61614d148fcb1147f74eeeb33c892d4", "searchTerms": [], + "slug": "10-festival", "title": "Level 10 (FESTiVAL)", - "where": "chart.level = '10'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27930,9 +30792,10 @@ "inactive": true, "legacyFolderID": "Fe4980c2ffc76aeeed9af903a694c6fa23c9fa51779b52836280160fce0b00c68", "searchTerms": [], + "slug": "10-prism-omni", "title": "Level 10 (PRiSM Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27940,9 +30803,10 @@ "inactive": false, "legacyFolderID": "Ffb6c75f70e2ab7fd0e987fefcd544ecd8e0c77c9bcbc4a394bdd733bf68aac02", "searchTerms": [], + "slug": "10-prismplus-omni", "title": "Level 10 (PRiSM PLUS Omnimix)", - "where": "chart.level = '10'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27950,9 +30814,10 @@ "inactive": false, "legacyFolderID": "F317b85fe04e754ec29ce4cb4408e33293a9beb3d213e128d73d25580f8f8eada", "searchTerms": [], + "slug": "10-prismplus", "title": "Level 10 (PRiSM PLUS)", - "where": "chart.level = '10'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27960,9 +30825,10 @@ "inactive": true, "legacyFolderID": "Ffa6e86155f045af8303565324a276907ed11cf8bc0a75dcd9660ec1a19458b7d", "searchTerms": [], + "slug": "10-prism", "title": "Level 10 (PRiSM)", - "where": "chart.level = '10'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27970,9 +30836,10 @@ "inactive": true, "legacyFolderID": "F7a4d2b540e5d572644a558738ac11f8520d3b70fee2479443b7c92b72c61cc46", "searchTerms": [], + "slug": "10-universeplus", "title": "Level 10 (UNiVERSE PLUS)", - "where": "chart.level = '10'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '10'" }, { "game": "maimaidx", @@ -27980,9 +30847,10 @@ "inactive": true, "legacyFolderID": "F4b5a6c7625460b1253ece7e182d3dad22682d388c6f1226d9fe83039b9281896", "searchTerms": [], + "slug": "10p-buddies-omni", "title": "Level 10+ (BUDDiES Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -27990,9 +30858,10 @@ "inactive": true, "legacyFolderID": "Fe1d8c34990305749f18482491d68d89283862877d55da6fb5878527ee43927ae", "searchTerms": [], + "slug": "10p-buddiesplus-omni", "title": "Level 10+ (BUDDiES PLUS Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28000,9 +30869,10 @@ "inactive": true, "legacyFolderID": "F9925170992bdec8794b7d13a843c52630a442ee63293e8d23b2f3bf37bdef652", "searchTerms": [], + "slug": "10p-buddiesplus", "title": "Level 10+ (BUDDiES PLUS)", - "where": "chart.level = '10+'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28010,9 +30880,10 @@ "inactive": true, "legacyFolderID": "F642ad377715ff93d029411bc63370283d6a2f36d0f5633df1c6e3b20c6e3cab8", "searchTerms": [], + "slug": "10p-buddies", "title": "Level 10+ (BUDDiES)", - "where": "chart.level = '10+'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28020,9 +30891,10 @@ "inactive": false, "legacyFolderID": "F10bcd0a5d8714b321cadd2c392d4496a9609c483540c8f1ad84acfbbc6e6a128", "searchTerms": [], + "slug": "10p-circle", "title": "Level 10+ (CiRCLE)", - "where": "chart.level = '10+'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28030,9 +30902,10 @@ "inactive": true, "legacyFolderID": "F428f846939f3d4c7867793342635c084f1daa60f877510e55e41911491c22dd8", "searchTerms": [], + "slug": "10p-festivalplus", "title": "Level 10+ (FESTiVAL PLUS)", - "where": "chart.level = '10+'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28040,9 +30913,10 @@ "inactive": true, "legacyFolderID": "Fc4a255d2b764466bc38d5c2ebfd360e5abaacea70a3f07d7de150b2ad8be646e", "searchTerms": [], + "slug": "10p-festival", "title": "Level 10+ (FESTiVAL)", - "where": "chart.level = '10+'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28050,9 +30924,10 @@ "inactive": true, "legacyFolderID": "F6390104a5351514c7aa5ca17822788a152498f3f5d73f34f3901b4cb0be0f0d7", "searchTerms": [], + "slug": "10p-prism-omni", "title": "Level 10+ (PRiSM Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28060,9 +30935,10 @@ "inactive": false, "legacyFolderID": "F4e488b77e7db3bef4e59053374bf3871139f6ed1847710b4ef8c04d663a6f158", "searchTerms": [], + "slug": "10p-prismplus-omni", "title": "Level 10+ (PRiSM PLUS Omnimix)", - "where": "chart.level = '10+'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28070,9 +30946,10 @@ "inactive": false, "legacyFolderID": "F73d4a5c9e83de8df2a0d47b22412b22b9243f41040233bda71f0bb5df5f4f189", "searchTerms": [], + "slug": "10p-prismplus", "title": "Level 10+ (PRiSM PLUS)", - "where": "chart.level = '10+'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28080,9 +30957,10 @@ "inactive": true, "legacyFolderID": "F2d02b5860d0296fdf63a7ea7a45fabdc2648fee72a7c6c1fc5bdadbd2f6e6c93", "searchTerms": [], + "slug": "10p-prism", "title": "Level 10+ (PRiSM)", - "where": "chart.level = '10+'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28090,9 +30968,10 @@ "inactive": true, "legacyFolderID": "F25f7c9177235d7b06c8f250900b21119091163ae5babeef6162a87cc8bc417fe", "searchTerms": [], + "slug": "10p-universeplus", "title": "Level 10+ (UNiVERSE PLUS)", - "where": "chart.level = '10+'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '10+'" }, { "game": "maimaidx", @@ -28100,9 +30979,10 @@ "inactive": true, "legacyFolderID": "F161d1f128b1120d97307a9b700ee59b9695e035a1f3781426a007fcd0ca44ae1", "searchTerms": [], + "slug": "11-buddies-omni", "title": "Level 11 (BUDDiES Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28110,9 +30990,10 @@ "inactive": true, "legacyFolderID": "F8fe844b5c9a0c862588376a67a77fd04491407e21cb5860e50d52382db66b160", "searchTerms": [], + "slug": "11-buddiesplus-omni", "title": "Level 11 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28120,9 +31001,10 @@ "inactive": true, "legacyFolderID": "F09843bf05a7113bb2fc7c758659269d8a9ccbb6e7adde1ce1be0809f8afb2511", "searchTerms": [], + "slug": "11-buddiesplus", "title": "Level 11 (BUDDiES PLUS)", - "where": "chart.level = '11'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28130,9 +31012,10 @@ "inactive": true, "legacyFolderID": "F081c0cc40019fd901337698f6505f5467b370c72eaac53e06bce77b5c0e03e11", "searchTerms": [], + "slug": "11-buddies", "title": "Level 11 (BUDDiES)", - "where": "chart.level = '11'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28140,9 +31023,10 @@ "inactive": false, "legacyFolderID": "F54988e616f86114a99d3dc411b46e3d9ba317b5c1f1df373c56d7870d69def43", "searchTerms": [], + "slug": "11-circle", "title": "Level 11 (CiRCLE)", - "where": "chart.level = '11'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28150,9 +31034,10 @@ "inactive": true, "legacyFolderID": "Fc3c6420d856640721403d67a0936b0a413124dc2a1ab8ab6275371132b1577eb", "searchTerms": [], + "slug": "11-festivalplus", "title": "Level 11 (FESTiVAL PLUS)", - "where": "chart.level = '11'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28160,9 +31045,10 @@ "inactive": true, "legacyFolderID": "F100e0316c2baff00f723ec17d0805e3ceb511f67867ea19347e478df625271e5", "searchTerms": [], + "slug": "11-festival", "title": "Level 11 (FESTiVAL)", - "where": "chart.level = '11'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28170,9 +31056,10 @@ "inactive": true, "legacyFolderID": "F3e5a19833775017fe9f95d9499edd049685ddb7916350e54ea630f62ea0154c0", "searchTerms": [], + "slug": "11-prism-omni", "title": "Level 11 (PRiSM Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28180,9 +31067,10 @@ "inactive": false, "legacyFolderID": "F6adb0c36526c2649848f6d83af026f485751f94ee22e42324b63178eed70b002", "searchTerms": [], + "slug": "11-prismplus-omni", "title": "Level 11 (PRiSM PLUS Omnimix)", - "where": "chart.level = '11'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28190,9 +31078,10 @@ "inactive": false, "legacyFolderID": "Ffdc14d6d08080f8fe3060ba46d3182ac6e2e105c7e77d427ff2c083dfbd050d8", "searchTerms": [], + "slug": "11-prismplus", "title": "Level 11 (PRiSM PLUS)", - "where": "chart.level = '11'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28200,9 +31089,10 @@ "inactive": true, "legacyFolderID": "F64a9175cc6650b27a86e39e3d633acdc37d817790e9f55e180b2c87d9f7c9b39", "searchTerms": [], + "slug": "11-prism", "title": "Level 11 (PRiSM)", - "where": "chart.level = '11'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28210,9 +31100,10 @@ "inactive": true, "legacyFolderID": "F6219546c53513f346a871326397951b48fd9153fed40d64d9c02cdc1dc437357", "searchTerms": [], + "slug": "11-universeplus", "title": "Level 11 (UNiVERSE PLUS)", - "where": "chart.level = '11'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '11'" }, { "game": "maimaidx", @@ -28220,9 +31111,10 @@ "inactive": true, "legacyFolderID": "Fd4211efa9de8287b84536132976cd9f3c1e225141811ee90a13e90d47cf8b803", "searchTerms": [], + "slug": "11p-buddies-omni", "title": "Level 11+ (BUDDiES Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28230,9 +31122,10 @@ "inactive": true, "legacyFolderID": "F4320695c59ecf8da99bdfe800d970e1cdd738da274a82433ab0f01ddce4d8dc6", "searchTerms": [], + "slug": "11p-buddiesplus-omni", "title": "Level 11+ (BUDDiES PLUS Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28240,9 +31133,10 @@ "inactive": true, "legacyFolderID": "F51e05a887207012ebde7865a85ec6f2481628fea23e8734f4692f983a041e100", "searchTerms": [], + "slug": "11p-buddiesplus", "title": "Level 11+ (BUDDiES PLUS)", - "where": "chart.level = '11+'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28250,9 +31144,10 @@ "inactive": true, "legacyFolderID": "Fc49208811a0ca9564d5cb155c1b28304aae0be18c21063ea23de8a73114c2c2d", "searchTerms": [], + "slug": "11p-buddies", "title": "Level 11+ (BUDDiES)", - "where": "chart.level = '11+'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28260,9 +31155,10 @@ "inactive": false, "legacyFolderID": "Fd92e4e693de99d258ff1826ed9022cc1c7718222b5a9b25ead7a4614304cecc0", "searchTerms": [], + "slug": "11p-circle", "title": "Level 11+ (CiRCLE)", - "where": "chart.level = '11+'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28270,9 +31166,10 @@ "inactive": true, "legacyFolderID": "F7f065feb073c6e323b805de43b192778352c56043f0467cfa3f61b166b4a957e", "searchTerms": [], + "slug": "11p-festivalplus", "title": "Level 11+ (FESTiVAL PLUS)", - "where": "chart.level = '11+'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28280,9 +31177,10 @@ "inactive": true, "legacyFolderID": "F95e1a2c8d3ec1f5419c1b62de3bc0c8a977820855176bc3b1e7595a72e200e97", "searchTerms": [], + "slug": "11p-festival", "title": "Level 11+ (FESTiVAL)", - "where": "chart.level = '11+'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28290,9 +31188,10 @@ "inactive": true, "legacyFolderID": "F0ca5948108478b8003134fde6eece4dfcf1132db07f647e8b815d81d170f3b96", "searchTerms": [], + "slug": "11p-prism-omni", "title": "Level 11+ (PRiSM Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28300,9 +31199,10 @@ "inactive": false, "legacyFolderID": "F38b1fbf5edc3e75382f5adfc36d7a29072eadffd012adeb3237f0d6ecf50d978", "searchTerms": [], + "slug": "11p-prismplus-omni", "title": "Level 11+ (PRiSM PLUS Omnimix)", - "where": "chart.level = '11+'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28310,9 +31210,10 @@ "inactive": false, "legacyFolderID": "Ff537bee0faffc0c7d00e9af0208165ef4e62f6bac62640bc8152b3c2cb631494", "searchTerms": [], + "slug": "11p-prismplus", "title": "Level 11+ (PRiSM PLUS)", - "where": "chart.level = '11+'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28320,9 +31221,10 @@ "inactive": true, "legacyFolderID": "F55ff85eb87f42536687d2aac9acc1294fe05ad24de67560d95c0646e2b512a75", "searchTerms": [], + "slug": "11p-prism", "title": "Level 11+ (PRiSM)", - "where": "chart.level = '11+'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28330,9 +31232,10 @@ "inactive": true, "legacyFolderID": "F27e9c507738e00794a33f624159b83594022ab7cb1bfb8054a3eb6a85b4f6139", "searchTerms": [], + "slug": "11p-universeplus", "title": "Level 11+ (UNiVERSE PLUS)", - "where": "chart.level = '11+'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '11+'" }, { "game": "maimaidx", @@ -28340,9 +31243,10 @@ "inactive": true, "legacyFolderID": "F42ce75cb660804636f87fc97c33ff038e7449404658b12172190eda17c62ec76", "searchTerms": [], + "slug": "12-buddies-omni", "title": "Level 12 (BUDDiES Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28350,9 +31254,10 @@ "inactive": true, "legacyFolderID": "F0152204632e4e317ff0e695ea47f8223df3fd707b491a55e1e872ead5fb41d1a", "searchTerms": [], + "slug": "12-buddiesplus-omni", "title": "Level 12 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28360,9 +31265,10 @@ "inactive": true, "legacyFolderID": "Fdc9bc25d76b268e48b24964c4db5193dc405874ea593ba6ce71ca35ad9d143be", "searchTerms": [], + "slug": "12-buddiesplus", "title": "Level 12 (BUDDiES PLUS)", - "where": "chart.level = '12'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28370,9 +31276,10 @@ "inactive": true, "legacyFolderID": "Fdfb1de2d66b33089de5d61d115c7736848d309feda17d5358e026ea7b3bdeb28", "searchTerms": [], + "slug": "12-buddies", "title": "Level 12 (BUDDiES)", - "where": "chart.level = '12'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28380,9 +31287,10 @@ "inactive": false, "legacyFolderID": "F7b2aa694e3871434b48e1c7cbfd36ef4275488ecd88a0cec16a5b99a2b7b5ec5", "searchTerms": [], + "slug": "12-circle", "title": "Level 12 (CiRCLE)", - "where": "chart.level = '12'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28390,9 +31298,10 @@ "inactive": true, "legacyFolderID": "F2d28eac9a3dbf79ecf2d30c795190ce005d4849c4d6a07c7b53ca8c77db5d19b", "searchTerms": [], + "slug": "12-festivalplus", "title": "Level 12 (FESTiVAL PLUS)", - "where": "chart.level = '12'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28400,9 +31309,10 @@ "inactive": true, "legacyFolderID": "Fc311db6405e1dc4a581e6185d05c89fef79f884e6e5d8ef90bc4e09e102eb6d9", "searchTerms": [], + "slug": "12-festival", "title": "Level 12 (FESTiVAL)", - "where": "chart.level = '12'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28410,9 +31320,10 @@ "inactive": true, "legacyFolderID": "F3385b0a70b56dea479e46916f1b26d10a3d1b3ca3231936aa009e5aa2df8d6ef", "searchTerms": [], + "slug": "12-prism-omni", "title": "Level 12 (PRiSM Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28420,9 +31331,10 @@ "inactive": false, "legacyFolderID": "F5e67896944e52e4a96798b49993bf4d232d6a03d4f9e123329c20e5e687f436b", "searchTerms": [], + "slug": "12-prismplus-omni", "title": "Level 12 (PRiSM PLUS Omnimix)", - "where": "chart.level = '12'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28430,9 +31342,10 @@ "inactive": false, "legacyFolderID": "Fb5b8c8d968d87273b397bab441e3527c9a98d408148f4459eaf7df336ac1ebc0", "searchTerms": [], + "slug": "12-prismplus", "title": "Level 12 (PRiSM PLUS)", - "where": "chart.level = '12'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28440,9 +31353,10 @@ "inactive": true, "legacyFolderID": "F33f4880412015931f358239818b1263c3b6688f026e166806ceaba1cd861e824", "searchTerms": [], + "slug": "12-prism", "title": "Level 12 (PRiSM)", - "where": "chart.level = '12'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28450,9 +31364,10 @@ "inactive": true, "legacyFolderID": "F85014701324320462539482506091a04b72652869bc1b2d92c451c109813d9c8", "searchTerms": [], + "slug": "12-universeplus", "title": "Level 12 (UNiVERSE PLUS)", - "where": "chart.level = '12'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '12'" }, { "game": "maimaidx", @@ -28460,9 +31375,10 @@ "inactive": true, "legacyFolderID": "F02b90e070d3d1a5558ae00d05a16662cb2d247f4bac9dab9ab76938fd457c082", "searchTerms": [], + "slug": "12p-buddies-omni", "title": "Level 12+ (BUDDiES Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28470,9 +31386,10 @@ "inactive": true, "legacyFolderID": "Fada6298db80d5cf0a3e2d0f1c88918249c99076b490b34ef93d6a4a78a467b94", "searchTerms": [], + "slug": "12p-buddiesplus-omni", "title": "Level 12+ (BUDDiES PLUS Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28480,9 +31397,10 @@ "inactive": true, "legacyFolderID": "F1fa7dd4ca7ec86afb9459c170c1a80a4c456624fa8a906ba86bf347e966adf8b", "searchTerms": [], + "slug": "12p-buddiesplus", "title": "Level 12+ (BUDDiES PLUS)", - "where": "chart.level = '12+'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28490,9 +31408,10 @@ "inactive": true, "legacyFolderID": "F0465da9514b6b0c2d68ceca7175bbc3dd1b06daa8ef311c44d15f56b05bf9baa", "searchTerms": [], + "slug": "12p-buddies", "title": "Level 12+ (BUDDiES)", - "where": "chart.level = '12+'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28500,9 +31419,10 @@ "inactive": false, "legacyFolderID": "F22fe4d7aa48026a1aa5f719c05b3e9d8ca639af907ce7b96df44090a13e4031f", "searchTerms": [], + "slug": "12p-circle", "title": "Level 12+ (CiRCLE)", - "where": "chart.level = '12+'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28510,9 +31430,10 @@ "inactive": true, "legacyFolderID": "Fa4546475e53fd9e4b0ed219dc291ea2954d8bdd68a51ee46b6f6241be0b7756e", "searchTerms": [], + "slug": "12p-festivalplus", "title": "Level 12+ (FESTiVAL PLUS)", - "where": "chart.level = '12+'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28520,9 +31441,10 @@ "inactive": true, "legacyFolderID": "F5e174e402efcf6b7101ab1455a63f233d74fc838257ced0db5c59075640c346c", "searchTerms": [], + "slug": "12p-festival", "title": "Level 12+ (FESTiVAL)", - "where": "chart.level = '12+'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28530,9 +31452,10 @@ "inactive": true, "legacyFolderID": "F1d193868363ae75791e8a850119e9f7d6e48c9716b7b97afae8f3a31f975f0f4", "searchTerms": [], + "slug": "12p-prism-omni", "title": "Level 12+ (PRiSM Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28540,9 +31463,10 @@ "inactive": false, "legacyFolderID": "F077af3ae7afcdce6793960707ea47a15eb492d983c5e314690f981f2993d8cd7", "searchTerms": [], + "slug": "12p-prismplus-omni", "title": "Level 12+ (PRiSM PLUS Omnimix)", - "where": "chart.level = '12+'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28550,9 +31474,10 @@ "inactive": false, "legacyFolderID": "Fe563867b324acf9e810547259c68ccc6e3df3a809a354e77918f2d213bfc7650", "searchTerms": [], + "slug": "12p-prismplus", "title": "Level 12+ (PRiSM PLUS)", - "where": "chart.level = '12+'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28560,9 +31485,10 @@ "inactive": true, "legacyFolderID": "F26947e241645f4dea4cea8a4120bca40648a1b22c81e921eed57fb847a94243b", "searchTerms": [], + "slug": "12p-prism", "title": "Level 12+ (PRiSM)", - "where": "chart.level = '12+'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28570,9 +31496,10 @@ "inactive": true, "legacyFolderID": "F2a3afb7dd7647eb9f1860e7e2111905d48f0093eeb2f11cca2d27478cd1e0d34", "searchTerms": [], + "slug": "12p-universeplus", "title": "Level 12+ (UNiVERSE PLUS)", - "where": "chart.level = '12+'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '12+'" }, { "game": "maimaidx", @@ -28580,9 +31507,10 @@ "inactive": true, "legacyFolderID": "F751d1bd69663fef1a3e0e6f39646efbce0a8f473451751986c9c796f5a5b1c73", "searchTerms": [], + "slug": "13-buddies-omni", "title": "Level 13 (BUDDiES Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28590,9 +31518,10 @@ "inactive": true, "legacyFolderID": "F9ab04224d1bc0df12a5a16e1f52f9da1c80e0d2c5baa7f234c8df3becd2c2243", "searchTerms": [], + "slug": "13-buddiesplus-omni", "title": "Level 13 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28600,9 +31529,10 @@ "inactive": true, "legacyFolderID": "Ff411e0c8e8ebeb3bba69ff93ced04760cbf7557b62cb833d143d4c5878d5718c", "searchTerms": [], + "slug": "13-buddiesplus", "title": "Level 13 (BUDDiES PLUS)", - "where": "chart.level = '13'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28610,9 +31540,10 @@ "inactive": true, "legacyFolderID": "Fc637e835fed1b11d9539f357771c4a774abcaed76dc7d210bbaf0ee7a3812179", "searchTerms": [], + "slug": "13-buddies", "title": "Level 13 (BUDDiES)", - "where": "chart.level = '13'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28620,9 +31551,10 @@ "inactive": false, "legacyFolderID": "Fc76d023b4aa0651369631987ad16ad5ce801c80190f0a45147b006e43fd3ce11", "searchTerms": [], + "slug": "13-circle", "title": "Level 13 (CiRCLE)", - "where": "chart.level = '13'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28630,9 +31562,10 @@ "inactive": true, "legacyFolderID": "Fc4c668440b3536d795e765436c1631c5b72d52a8a5d38695755a2fe09221bb14", "searchTerms": [], + "slug": "13-festivalplus", "title": "Level 13 (FESTiVAL PLUS)", - "where": "chart.level = '13'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28640,9 +31573,10 @@ "inactive": true, "legacyFolderID": "F00a4c2175bc58ae69d210c83497fd34cbcbaa43aa20421624cd975fd953f3a40", "searchTerms": [], + "slug": "13-festival", "title": "Level 13 (FESTiVAL)", - "where": "chart.level = '13'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28650,9 +31584,10 @@ "inactive": true, "legacyFolderID": "F9a2396c30c0ded0b8cb5524fbb7afe041bbbd64a4337ef10334632b0909feaa3", "searchTerms": [], + "slug": "13-prism-omni", "title": "Level 13 (PRiSM Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28660,9 +31595,10 @@ "inactive": false, "legacyFolderID": "Fa95ef38065e99bad0a2e6232fa2f5b9fecdff1073a930f756552e7c279cfae36", "searchTerms": [], + "slug": "13-prismplus-omni", "title": "Level 13 (PRiSM PLUS Omnimix)", - "where": "chart.level = '13'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28670,9 +31606,10 @@ "inactive": false, "legacyFolderID": "F145a80566790955d9624ce086b7d7fb7fc9994ad18274307b2177245a60bd9ea", "searchTerms": [], + "slug": "13-prismplus", "title": "Level 13 (PRiSM PLUS)", - "where": "chart.level = '13'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28680,9 +31617,10 @@ "inactive": true, "legacyFolderID": "Fa1b069cf4bda491cc0968d8e3e0fbfbb42300ab0f89e26875bced844465ffcb1", "searchTerms": [], + "slug": "13-prism", "title": "Level 13 (PRiSM)", - "where": "chart.level = '13'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28690,9 +31628,10 @@ "inactive": true, "legacyFolderID": "F883e52817a416cc64d0f01a735631e119fd6aded15b35421b560cf3159a10174", "searchTerms": [], + "slug": "13-universeplus", "title": "Level 13 (UNiVERSE PLUS)", - "where": "chart.level = '13'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '13'" }, { "game": "maimaidx", @@ -28700,9 +31639,10 @@ "inactive": true, "legacyFolderID": "Fb169568019fd0e60bf150938b904f2887dfd93c9972f493759f44b216d5c93ec", "searchTerms": [], + "slug": "13p-buddies-omni", "title": "Level 13+ (BUDDiES Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28710,9 +31650,10 @@ "inactive": true, "legacyFolderID": "Fc29f24d4995d540f2db132594cca1195fac9251048cc80f00756b5541633018c", "searchTerms": [], + "slug": "13p-buddiesplus-omni", "title": "Level 13+ (BUDDiES PLUS Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28720,9 +31661,10 @@ "inactive": true, "legacyFolderID": "Fa09dcc2155876643750834faea81f2a881b14badea5531ea47f201634e0a156d", "searchTerms": [], + "slug": "13p-buddiesplus", "title": "Level 13+ (BUDDiES PLUS)", - "where": "chart.level = '13+'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28730,9 +31672,10 @@ "inactive": true, "legacyFolderID": "F111ec00e7fcd5d79055eb1898f109eebe42e0ac592bc05208e7635b1977ac6e0", "searchTerms": [], + "slug": "13p-buddies", "title": "Level 13+ (BUDDiES)", - "where": "chart.level = '13+'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28740,9 +31683,10 @@ "inactive": false, "legacyFolderID": "Ffbef2634a665f654e7c1c94ffc33a677c00a7ee184846fcfc5875c4dccc91462", "searchTerms": [], + "slug": "13p-circle", "title": "Level 13+ (CiRCLE)", - "where": "chart.level = '13+'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28750,9 +31694,10 @@ "inactive": true, "legacyFolderID": "Fbd4ed8acc14521b02cab253af19531f17c4379cc20480301d068a9e9e3fcb460", "searchTerms": [], + "slug": "13p-festivalplus", "title": "Level 13+ (FESTiVAL PLUS)", - "where": "chart.level = '13+'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28760,9 +31705,10 @@ "inactive": true, "legacyFolderID": "F85ae698740031bb79c35a8aeedd4a635ca333a65e8c194b0ca206e01e970cfa1", "searchTerms": [], + "slug": "13p-festival", "title": "Level 13+ (FESTiVAL)", - "where": "chart.level = '13+'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28770,9 +31716,10 @@ "inactive": true, "legacyFolderID": "Fd9e5e33e55c3e23470e0b5eea673cc2879d3121d656f9a032fd2efbfbaf394f3", "searchTerms": [], + "slug": "13p-prism-omni", "title": "Level 13+ (PRiSM Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28780,9 +31727,10 @@ "inactive": false, "legacyFolderID": "Fb5cc01099fa2f59a7df50b94b48292a5ca53fd4763bc4985c0cafe3701356a70", "searchTerms": [], + "slug": "13p-prismplus-omni", "title": "Level 13+ (PRiSM PLUS Omnimix)", - "where": "chart.level = '13+'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28790,9 +31738,10 @@ "inactive": false, "legacyFolderID": "Fc80e67663bcdb4f8ef3a7d8d7c0375f21173073c220db484bf20e5e16dcaa4e7", "searchTerms": [], + "slug": "13p-prismplus", "title": "Level 13+ (PRiSM PLUS)", - "where": "chart.level = '13+'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28800,9 +31749,10 @@ "inactive": true, "legacyFolderID": "Fb87194e9c8f2adb097421dcae5c30202aed9650c5a0aa85dee22545eed96ef34", "searchTerms": [], + "slug": "13p-prism", "title": "Level 13+ (PRiSM)", - "where": "chart.level = '13+'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28810,9 +31760,10 @@ "inactive": true, "legacyFolderID": "Feb10967956ffc1a5c2169a17b09ba870de30cd88ba0c4f8c396db2b44137681d", "searchTerms": [], + "slug": "13p-universeplus", "title": "Level 13+ (UNiVERSE PLUS)", - "where": "chart.level = '13+'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '13+'" }, { "game": "maimaidx", @@ -28820,9 +31771,10 @@ "inactive": true, "legacyFolderID": "F581810c6dba545a6584754e9d7bb73ce071fdb6597bcec031f5aa13179fc6053", "searchTerms": [], + "slug": "14-buddies-omni", "title": "Level 14 (BUDDiES Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28830,9 +31782,10 @@ "inactive": true, "legacyFolderID": "Ffa3efb0c793b438af42817c51234e23b669ea768c6269b04cf31b21095b9550a", "searchTerms": [], + "slug": "14-buddiesplus-omni", "title": "Level 14 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28840,9 +31793,10 @@ "inactive": true, "legacyFolderID": "Ff76d7681a0d04f8e52e9b5dd306ad9b996ef50d7e05673e8cf13a40f4b6951b9", "searchTerms": [], + "slug": "14-buddiesplus", "title": "Level 14 (BUDDiES PLUS)", - "where": "chart.level = '14'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28850,9 +31804,10 @@ "inactive": true, "legacyFolderID": "F98d753fb4b81e7e9f6bcc39f97999bc25563c0476fa2794984ab7318de1e983c", "searchTerms": [], + "slug": "14-buddies", "title": "Level 14 (BUDDiES)", - "where": "chart.level = '14'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28860,9 +31815,10 @@ "inactive": false, "legacyFolderID": "F4aeecfede4dd6caa86ea15f6383ba66edb81f4202a7649910343ff5092733e9c", "searchTerms": [], + "slug": "14-circle", "title": "Level 14 (CiRCLE)", - "where": "chart.level = '14'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28870,9 +31826,10 @@ "inactive": true, "legacyFolderID": "F091c61a1e5a413bc5cda2163bae3b67e20f78a12b52303564dec02496a49bd32", "searchTerms": [], + "slug": "14-festivalplus", "title": "Level 14 (FESTiVAL PLUS)", - "where": "chart.level = '14'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28880,9 +31837,10 @@ "inactive": true, "legacyFolderID": "F096b807153464c8cdbd9193c297513b5381210627c226430dc1c01d2646edaf6", "searchTerms": [], + "slug": "14-festival", "title": "Level 14 (FESTiVAL)", - "where": "chart.level = '14'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28890,9 +31848,10 @@ "inactive": true, "legacyFolderID": "F584e77973f0c0e56b1d87e493ab6071372077a353a4342ef580ebc58f54762b3", "searchTerms": [], + "slug": "14-prism-omni", "title": "Level 14 (PRiSM Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28900,9 +31859,10 @@ "inactive": false, "legacyFolderID": "Fd5b3879651f0a284a72404d2ef3dcf2402d47070b6b847d07ec3d528faf3558e", "searchTerms": [], + "slug": "14-prismplus-omni", "title": "Level 14 (PRiSM PLUS Omnimix)", - "where": "chart.level = '14'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28910,9 +31870,10 @@ "inactive": false, "legacyFolderID": "Fb4e867ba6c890e3efe77c198dca502a32776783e0fc456e51c61a09620c80bf0", "searchTerms": [], + "slug": "14-prismplus", "title": "Level 14 (PRiSM PLUS)", - "where": "chart.level = '14'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28920,9 +31881,10 @@ "inactive": true, "legacyFolderID": "Fbc1b4c018a07b7fe2344fd2a5aa7da96a1c056a73d8330d877c8d215d999454d", "searchTerms": [], + "slug": "14-prism", "title": "Level 14 (PRiSM)", - "where": "chart.level = '14'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28930,9 +31892,10 @@ "inactive": true, "legacyFolderID": "Fa266a4d4f139a3a02c42f4fd1c1d1be8f2bfb9db98e5aac3b14b71e7058417f8", "searchTerms": [], + "slug": "14-universeplus", "title": "Level 14 (UNiVERSE PLUS)", - "where": "chart.level = '14'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '14'" }, { "game": "maimaidx", @@ -28940,9 +31903,10 @@ "inactive": true, "legacyFolderID": "Fbe3b384189d630eb6f44c55a82b8047acc3cb625f2123796fc12ad2ac1d32dc5", "searchTerms": [], + "slug": "14p-buddies-omni", "title": "Level 14+ (BUDDiES Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -28950,9 +31914,10 @@ "inactive": true, "legacyFolderID": "Ff3334ee95f0fd44e5a0ffebddc173179de176da69796299a4d9adee37d8d1826", "searchTerms": [], + "slug": "14p-buddiesplus-omni", "title": "Level 14+ (BUDDiES PLUS Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -28960,9 +31925,10 @@ "inactive": true, "legacyFolderID": "F47e93c31737047e344feddb1c3ff4038f66536bcfe7fa29a548568e9fd580060", "searchTerms": [], + "slug": "14p-buddiesplus", "title": "Level 14+ (BUDDiES PLUS)", - "where": "chart.level = '14+'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -28970,9 +31936,10 @@ "inactive": true, "legacyFolderID": "Fa0f3c5ec11cfcb42c3360f736e22aa4e311723d7e7e118a071af9a148f5f3a1d", "searchTerms": [], + "slug": "14p-buddies", "title": "Level 14+ (BUDDiES)", - "where": "chart.level = '14+'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -28980,9 +31947,10 @@ "inactive": false, "legacyFolderID": "Ff8568d8ee3a245752e7859f54ed6b77c2078075d301706a5269ac0dd05c29f98", "searchTerms": [], + "slug": "14p-circle", "title": "Level 14+ (CiRCLE)", - "where": "chart.level = '14+'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -28990,9 +31958,10 @@ "inactive": true, "legacyFolderID": "F3272f7922b405de1d5bb1b92c2ddcbbc246880bc38f742b56f95f58a1f28e777", "searchTerms": [], + "slug": "14p-festivalplus", "title": "Level 14+ (FESTiVAL PLUS)", - "where": "chart.level = '14+'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -29000,9 +31969,10 @@ "inactive": true, "legacyFolderID": "F475edb7c06e96e9fafbf90a2523afb4e917aca5346b84184a82133fd773a6f0a", "searchTerms": [], + "slug": "14p-festival", "title": "Level 14+ (FESTiVAL)", - "where": "chart.level = '14+'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -29010,9 +31980,10 @@ "inactive": true, "legacyFolderID": "Fe4bd60295b2d0a49149c5c114f97300e1304be94da437e20f21be4024dc21a72", "searchTerms": [], + "slug": "14p-prism-omni", "title": "Level 14+ (PRiSM Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -29020,9 +31991,10 @@ "inactive": false, "legacyFolderID": "F61734b95a4e47b232efe80eec9e6f0761001e633cfded8dbb4c085ed68b2bd51", "searchTerms": [], + "slug": "14p-prismplus-omni", "title": "Level 14+ (PRiSM PLUS Omnimix)", - "where": "chart.level = '14+'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -29030,9 +32002,10 @@ "inactive": false, "legacyFolderID": "Fbf58a6059e1295bb482f6659aae715d395c6031583d053396eef9eb83d1c7507", "searchTerms": [], + "slug": "14p-prismplus", "title": "Level 14+ (PRiSM PLUS)", - "where": "chart.level = '14+'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -29040,9 +32013,10 @@ "inactive": true, "legacyFolderID": "Ff6657197d9f199043bb3dcead56ea2fad14c26ab41bbf048dd5d20967bf27b95", "searchTerms": [], + "slug": "14p-prism", "title": "Level 14+ (PRiSM)", - "where": "chart.level = '14+'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -29050,9 +32024,10 @@ "inactive": true, "legacyFolderID": "F6dde73f8db05437924a9eb79f8fde8d89c37650f6af7b0590e417929830997b2", "searchTerms": [], + "slug": "14p-universeplus", "title": "Level 14+ (UNiVERSE PLUS)", - "where": "chart.level = '14+'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '14+'" }, { "game": "maimaidx", @@ -29060,9 +32035,10 @@ "inactive": true, "legacyFolderID": "Fcb58b6812a244eee2cc82fc45dca05205f6be7b75a55bdd213811fc063e02670", "searchTerms": [], + "slug": "15-buddies-omni", "title": "Level 15 (BUDDiES Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29070,9 +32046,10 @@ "inactive": true, "legacyFolderID": "F5c46879cba07b51173cb31ef153b894344c9e25835b7f8b72885b871f575f47b", "searchTerms": [], + "slug": "15-buddiesplus-omni", "title": "Level 15 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29080,9 +32057,10 @@ "inactive": true, "legacyFolderID": "Fd85a7efb82e9080d09ec23e458cc708bb4983e779168d51af7cea02f96ec1ef4", "searchTerms": [], + "slug": "15-buddiesplus", "title": "Level 15 (BUDDiES PLUS)", - "where": "chart.level = '15'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29090,9 +32068,10 @@ "inactive": true, "legacyFolderID": "F27358f4f2d040acfe4fa4994e2764ff9e980f4fd3d68e5af2d2e6e38484b444a", "searchTerms": [], + "slug": "15-buddies", "title": "Level 15 (BUDDiES)", - "where": "chart.level = '15'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29100,9 +32079,10 @@ "inactive": false, "legacyFolderID": "Fadb6e5f50803a81537cd3fb11103a691b3eda5553846f0b2e6c13a14a3ad990c", "searchTerms": [], + "slug": "15-circle", "title": "Level 15 (CiRCLE)", - "where": "chart.level = '15'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29110,9 +32090,10 @@ "inactive": true, "legacyFolderID": "F249c6b4ed09ef3ce477aa34c8ccf9ec102d9965f77af851f01ba55fe0b2df516", "searchTerms": [], + "slug": "15-festivalplus", "title": "Level 15 (FESTiVAL PLUS)", - "where": "chart.level = '15'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29120,9 +32101,10 @@ "inactive": true, "legacyFolderID": "F1ca300e8a7659e95d958c5eea4defb8f3ad43dd7818d923b55df854620ed4eec", "searchTerms": [], + "slug": "15-festival", "title": "Level 15 (FESTiVAL)", - "where": "chart.level = '15'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29130,9 +32112,10 @@ "inactive": true, "legacyFolderID": "Ff919e95125827edb34d0edb412c8ad4b1d3b4dc5dd8ea68442d5ffed14eab4ac", "searchTerms": [], + "slug": "15-prism-omni", "title": "Level 15 (PRiSM Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29140,9 +32123,10 @@ "inactive": false, "legacyFolderID": "F84f27d8ddea716786fda8c2d18c913a3b95799d6c3718d117b9c125f1492a583", "searchTerms": [], + "slug": "15-prismplus-omni", "title": "Level 15 (PRiSM PLUS Omnimix)", - "where": "chart.level = '15'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29150,9 +32134,10 @@ "inactive": false, "legacyFolderID": "F679496e23dabe3f9e700f3fbeb7d85475394808b9ebaf8663a31bec116ef1df8", "searchTerms": [], + "slug": "15-prismplus", "title": "Level 15 (PRiSM PLUS)", - "where": "chart.level = '15'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29160,9 +32145,10 @@ "inactive": true, "legacyFolderID": "Fc8644419818f6095bdf08eec2847234a9a34ecb8097f0fe9001d714c1d4efeca", "searchTerms": [], + "slug": "15-prism", "title": "Level 15 (PRiSM)", - "where": "chart.level = '15'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29170,9 +32156,10 @@ "inactive": true, "legacyFolderID": "F45bb90e158a0cbc0a3f5bea07751c7b5fb20aec64b338f37653583d9e9bf933c", "searchTerms": [], + "slug": "15-universeplus", "title": "Level 15 (UNiVERSE PLUS)", - "where": "chart.level = '15'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '15'" }, { "game": "maimaidx", @@ -29180,9 +32167,10 @@ "inactive": true, "legacyFolderID": "F6872d4a3f74c38eece093f94ccba83a8d3cc00d300db7aeb093064f23d66e7ae", "searchTerms": [], + "slug": "2-buddies-omni", "title": "Level 2 (BUDDiES Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29190,9 +32178,10 @@ "inactive": true, "legacyFolderID": "Fb27aaf2b45f41dc67c8e32245c15ca9f28ab89cdb990b93417158fb928702125", "searchTerms": [], + "slug": "2-buddiesplus-omni", "title": "Level 2 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29200,9 +32189,10 @@ "inactive": true, "legacyFolderID": "F74b6493bfd7ea5da6943e96a5931278fc7d6fc28b9077703892f507dcc5dc57d", "searchTerms": [], + "slug": "2-buddiesplus", "title": "Level 2 (BUDDiES PLUS)", - "where": "chart.level = '2'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29210,9 +32200,10 @@ "inactive": true, "legacyFolderID": "Fc5b103397b1ff0db69c2971b7a243af218468a189b6a34521ec586d23cd4786d", "searchTerms": [], + "slug": "2-buddies", "title": "Level 2 (BUDDiES)", - "where": "chart.level = '2'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29220,9 +32211,10 @@ "inactive": false, "legacyFolderID": "F3e9346868af3e1a9ccaa27ef98d5653a2e35683b9d18a24944211d3b021372a6", "searchTerms": [], + "slug": "2-circle", "title": "Level 2 (CiRCLE)", - "where": "chart.level = '2'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29230,9 +32222,10 @@ "inactive": true, "legacyFolderID": "F1c949078387d1d1f0932e1ce33855b93254b0c23842e462805cc808a32cadabf", "searchTerms": [], + "slug": "2-festivalplus", "title": "Level 2 (FESTiVAL PLUS)", - "where": "chart.level = '2'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29240,9 +32233,10 @@ "inactive": true, "legacyFolderID": "F282c25cac6cd3d4374d3857ddc77825dde5b5625a76cef501fcbe7f37b9663b6", "searchTerms": [], + "slug": "2-festival", "title": "Level 2 (FESTiVAL)", - "where": "chart.level = '2'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29250,9 +32244,10 @@ "inactive": true, "legacyFolderID": "F910c8d7ca397c828993e2744d93f27b2f57db95098cdc4a92942c0c3c9b68752", "searchTerms": [], + "slug": "2-prism-omni", "title": "Level 2 (PRiSM Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29260,9 +32255,10 @@ "inactive": false, "legacyFolderID": "F28d88a2391bce45441cc490af7bb9812b5c3656254a9d65f8763ad84d170a540", "searchTerms": [], + "slug": "2-prismplus-omni", "title": "Level 2 (PRiSM PLUS Omnimix)", - "where": "chart.level = '2'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29270,9 +32266,10 @@ "inactive": false, "legacyFolderID": "F2741e3c4774abf3aea7515cd7eb0b7cbf115b03eae75a5ca18d3c2b766bd0e35", "searchTerms": [], + "slug": "2-prismplus", "title": "Level 2 (PRiSM PLUS)", - "where": "chart.level = '2'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29280,9 +32277,10 @@ "inactive": true, "legacyFolderID": "F3fb8670665ce0587500f73ddda5ec584ba4b5d2ff7fea17d5e472590f7e978f3", "searchTerms": [], + "slug": "2-prism", "title": "Level 2 (PRiSM)", - "where": "chart.level = '2'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29290,9 +32288,10 @@ "inactive": true, "legacyFolderID": "F9af30d260328061478226ddf10ad43180bc5ffb55702dfed107cd59deaa6fb0f", "searchTerms": [], + "slug": "2-universeplus", "title": "Level 2 (UNiVERSE PLUS)", - "where": "chart.level = '2'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '2'" }, { "game": "maimaidx", @@ -29300,9 +32299,10 @@ "inactive": true, "legacyFolderID": "Fe51379e75d7468601646f9fd10eccd7f8c900634e3c2836a82a2dd9aefb6da5d", "searchTerms": [], + "slug": "3-buddies-omni", "title": "Level 3 (BUDDiES Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29310,9 +32310,10 @@ "inactive": true, "legacyFolderID": "F8f4d5941037165d3ea115be3778824a48fdec237bee7979961054c452a60d08e", "searchTerms": [], + "slug": "3-buddiesplus-omni", "title": "Level 3 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29320,9 +32321,10 @@ "inactive": true, "legacyFolderID": "F0fc01c632ea6da2ea0d749d68ed3e584bbc1b2c5c5849e117deda4510fea3549", "searchTerms": [], + "slug": "3-buddiesplus", "title": "Level 3 (BUDDiES PLUS)", - "where": "chart.level = '3'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29330,9 +32332,10 @@ "inactive": true, "legacyFolderID": "F46d9183887727748b93fa0cd876945e1019e75de0c5a2ce366e3163a3f2fb72f", "searchTerms": [], + "slug": "3-buddies", "title": "Level 3 (BUDDiES)", - "where": "chart.level = '3'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29340,9 +32343,10 @@ "inactive": false, "legacyFolderID": "F6524800df1f331b2bd1fbc6c8850d9309d944cc3f3cb339f654907738aa5bd7b", "searchTerms": [], + "slug": "3-circle", "title": "Level 3 (CiRCLE)", - "where": "chart.level = '3'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29350,9 +32354,10 @@ "inactive": true, "legacyFolderID": "Fb03ab9cd2656ce4c3af8c37b46484519cf365f1531e14856b6c3fae180ac09de", "searchTerms": [], + "slug": "3-festivalplus", "title": "Level 3 (FESTiVAL PLUS)", - "where": "chart.level = '3'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29360,9 +32365,10 @@ "inactive": true, "legacyFolderID": "Fb85abfb6af00e2596cf15cd365a72687e2d338795876785cf43ab87b5b6bc4e4", "searchTerms": [], + "slug": "3-festival", "title": "Level 3 (FESTiVAL)", - "where": "chart.level = '3'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29370,9 +32376,10 @@ "inactive": true, "legacyFolderID": "F4479bcd50183a2c46342b59abb06d4aa1988b9cc747949e9d4e1d20bb5e3a680", "searchTerms": [], + "slug": "3-prism-omni", "title": "Level 3 (PRiSM Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29380,9 +32387,10 @@ "inactive": false, "legacyFolderID": "F41b5ef0b4ae7882952e00895d2e7bb6d73c2456feeaa62597df228f4bd310383", "searchTerms": [], + "slug": "3-prismplus-omni", "title": "Level 3 (PRiSM PLUS Omnimix)", - "where": "chart.level = '3'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29390,9 +32398,10 @@ "inactive": false, "legacyFolderID": "F6ed61315bc580dd626acfd9926a02cfcecc6d64128eeee53d07fc91a2703187e", "searchTerms": [], + "slug": "3-prismplus", "title": "Level 3 (PRiSM PLUS)", - "where": "chart.level = '3'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29400,9 +32409,10 @@ "inactive": true, "legacyFolderID": "Fb7d6ce63fbd83ccf25672e3ee30f5f5d963af51624261f59bf7108d64027fc61", "searchTerms": [], + "slug": "3-prism", "title": "Level 3 (PRiSM)", - "where": "chart.level = '3'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29410,9 +32420,10 @@ "inactive": true, "legacyFolderID": "Ff6b33cc832a17f7b8e7ef273b611e59280798c941cad7af8d83347cdead9ecf3", "searchTerms": [], + "slug": "3-universeplus", "title": "Level 3 (UNiVERSE PLUS)", - "where": "chart.level = '3'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '3'" }, { "game": "maimaidx", @@ -29420,9 +32431,10 @@ "inactive": true, "legacyFolderID": "F316aee196cbd08222c4bce288e9dfc7712270aaf75b13a3895eb4134f5c2cbf4", "searchTerms": [], + "slug": "4-buddies-omni", "title": "Level 4 (BUDDiES Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29430,9 +32442,10 @@ "inactive": true, "legacyFolderID": "Fccc65f66118f4b60c49a87329a910ebb4c2ad4932ffe7988778356532052e915", "searchTerms": [], + "slug": "4-buddiesplus-omni", "title": "Level 4 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29440,9 +32453,10 @@ "inactive": true, "legacyFolderID": "F45117e65389a9cfe946265f1a9c337c14cc70874d1e9086310bcc6ecc5bd6266", "searchTerms": [], + "slug": "4-buddiesplus", "title": "Level 4 (BUDDiES PLUS)", - "where": "chart.level = '4'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29450,9 +32464,10 @@ "inactive": true, "legacyFolderID": "Fb15edb32fad5af468bb2bc3fa066d0d488d9b05fd81a3327fa32e0ab1a07cbd5", "searchTerms": [], + "slug": "4-buddies", "title": "Level 4 (BUDDiES)", - "where": "chart.level = '4'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29460,9 +32475,10 @@ "inactive": false, "legacyFolderID": "F5abb7d24ec22e7ad6ea9833777a7ae223fab3afa6c41cf5874bc9a80a49d469e", "searchTerms": [], + "slug": "4-circle", "title": "Level 4 (CiRCLE)", - "where": "chart.level = '4'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29470,9 +32486,10 @@ "inactive": true, "legacyFolderID": "F6bf37ad839452dfa34571d14536ff83f7532298cc6d91a1bfe9d9eca703c0aa9", "searchTerms": [], + "slug": "4-festivalplus", "title": "Level 4 (FESTiVAL PLUS)", - "where": "chart.level = '4'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29480,9 +32497,10 @@ "inactive": true, "legacyFolderID": "F29c124f28c8e9d487a784c9dd519d930e17be7764fbd5f9446e4dffb58060666", "searchTerms": [], + "slug": "4-festival", "title": "Level 4 (FESTiVAL)", - "where": "chart.level = '4'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29490,9 +32508,10 @@ "inactive": true, "legacyFolderID": "F8c7777d1f9da29990412f6803f1893e42f17855b12506f2e203f84fc9fcb5dbe", "searchTerms": [], + "slug": "4-prism-omni", "title": "Level 4 (PRiSM Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29500,9 +32519,10 @@ "inactive": false, "legacyFolderID": "F11f540be64140d5094c1b870d4a60e588a4f99a5b8e5a0195661b0ec5a168341", "searchTerms": [], + "slug": "4-prismplus-omni", "title": "Level 4 (PRiSM PLUS Omnimix)", - "where": "chart.level = '4'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29510,9 +32530,10 @@ "inactive": false, "legacyFolderID": "F0807b5b8eaf65521b23eeb2f94d922db4f24becd2b10f8baea751579bfa153d3", "searchTerms": [], + "slug": "4-prismplus", "title": "Level 4 (PRiSM PLUS)", - "where": "chart.level = '4'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29520,9 +32541,10 @@ "inactive": true, "legacyFolderID": "F87209fa50325972a3de0b068be2684ca8b143c4a5a8383de909382233d329d9c", "searchTerms": [], + "slug": "4-prism", "title": "Level 4 (PRiSM)", - "where": "chart.level = '4'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29530,9 +32552,10 @@ "inactive": true, "legacyFolderID": "F243e4b306bd8bea068d1ff5eeebb9a0e220616e83a5f3ae303f9cb6826e481c0", "searchTerms": [], + "slug": "4-universeplus", "title": "Level 4 (UNiVERSE PLUS)", - "where": "chart.level = '4'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '4'" }, { "game": "maimaidx", @@ -29540,9 +32563,10 @@ "inactive": true, "legacyFolderID": "Fca6bd60cdc44cb5882599af9296ae5dd31b9f88e547d739b9f871da12c62a937", "searchTerms": [], + "slug": "5-buddies-omni", "title": "Level 5 (BUDDiES Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29550,9 +32574,10 @@ "inactive": true, "legacyFolderID": "F324adbf7de00787e8a588770aeafb6cb8cba59ce987176b3523b872c3aacb968", "searchTerms": [], + "slug": "5-buddiesplus-omni", "title": "Level 5 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29560,9 +32585,10 @@ "inactive": true, "legacyFolderID": "Fdde7542d0215cffea61437e2427f9084dc47e6b1bf8e8b4af4a795f394cda954", "searchTerms": [], + "slug": "5-buddiesplus", "title": "Level 5 (BUDDiES PLUS)", - "where": "chart.level = '5'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29570,9 +32596,10 @@ "inactive": true, "legacyFolderID": "Fdc6000db2761ef641da81d4776213147a824cd5b0fed7d047d3b8cac9b6ffb89", "searchTerms": [], + "slug": "5-buddies", "title": "Level 5 (BUDDiES)", - "where": "chart.level = '5'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29580,9 +32607,10 @@ "inactive": false, "legacyFolderID": "F99b0979f23d688a772e004eaa5757552be3d70dee8c1863d1154c178514d182a", "searchTerms": [], + "slug": "5-circle", "title": "Level 5 (CiRCLE)", - "where": "chart.level = '5'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29590,9 +32618,10 @@ "inactive": true, "legacyFolderID": "Fe233e3a1f661b14fa34d30048ba42b0e9b6148a733ddf6d0a87c7a7b2c42553b", "searchTerms": [], + "slug": "5-festivalplus", "title": "Level 5 (FESTiVAL PLUS)", - "where": "chart.level = '5'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29600,9 +32629,10 @@ "inactive": true, "legacyFolderID": "Fb7e42efe21482a24c222c7c1d1b132dba26a8a612f7ad747c6aeef631d9cae22", "searchTerms": [], + "slug": "5-festival", "title": "Level 5 (FESTiVAL)", - "where": "chart.level = '5'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29610,9 +32640,10 @@ "inactive": true, "legacyFolderID": "Fd418c704cd9905df2447895654b2406cb8e28663eb0611108f8b6345b10b0e6d", "searchTerms": [], + "slug": "5-prism-omni", "title": "Level 5 (PRiSM Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29620,9 +32651,10 @@ "inactive": false, "legacyFolderID": "Fe371c4fbbffad1d20794a00d4a7026915f4603df5a6a9938f85699576d21db97", "searchTerms": [], + "slug": "5-prismplus-omni", "title": "Level 5 (PRiSM PLUS Omnimix)", - "where": "chart.level = '5'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29630,9 +32662,10 @@ "inactive": false, "legacyFolderID": "F01c75cc582b53a7b19cf81e34fdd000e2db34f3d9a49d0d2a80231bc3d728f4b", "searchTerms": [], + "slug": "5-prismplus", "title": "Level 5 (PRiSM PLUS)", - "where": "chart.level = '5'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29640,9 +32673,10 @@ "inactive": true, "legacyFolderID": "Febf0ccd7019da1946ea99b735c0cd92f3109d5dc570eef47ce2cd5f720e54204", "searchTerms": [], + "slug": "5-prism", "title": "Level 5 (PRiSM)", - "where": "chart.level = '5'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29650,9 +32684,10 @@ "inactive": true, "legacyFolderID": "F9f5017470109cb3915406a9a61584f0356d176f928479054dec79d828300025e", "searchTerms": [], + "slug": "5-universeplus", "title": "Level 5 (UNiVERSE PLUS)", - "where": "chart.level = '5'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '5'" }, { "game": "maimaidx", @@ -29660,9 +32695,10 @@ "inactive": true, "legacyFolderID": "F82750edced4830290ecc5451212ae39353ec010447b59ec0a58ca48f08522de4", "searchTerms": [], + "slug": "6-buddies-omni", "title": "Level 6 (BUDDiES Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29670,9 +32706,10 @@ "inactive": true, "legacyFolderID": "F5d3c63e5978c6976582d23ac612661756a6a6d2f396765ec59508dad7238000b", "searchTerms": [], + "slug": "6-buddiesplus-omni", "title": "Level 6 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29680,9 +32717,10 @@ "inactive": true, "legacyFolderID": "F9b0bd0bd2d7f2df3cf898ee1239eb2f83e94958386650d2355feb6b336c2568e", "searchTerms": [], + "slug": "6-buddiesplus", "title": "Level 6 (BUDDiES PLUS)", - "where": "chart.level = '6'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29690,9 +32728,10 @@ "inactive": true, "legacyFolderID": "F7e4758cbbb6bc5551e4d3606a7c4948744a9451913a5ab9ac09f84884b307bdd", "searchTerms": [], + "slug": "6-buddies", "title": "Level 6 (BUDDiES)", - "where": "chart.level = '6'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29700,9 +32739,10 @@ "inactive": false, "legacyFolderID": "F26f15f676ddff949a99b5416ab041c263dc14a35639da6953ca030b49bfef76e", "searchTerms": [], + "slug": "6-circle", "title": "Level 6 (CiRCLE)", - "where": "chart.level = '6'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29710,9 +32750,10 @@ "inactive": true, "legacyFolderID": "Fe7f71b4d744597a1cea719c6de50fc5f34e4563711350c572583ab0aeca9be37", "searchTerms": [], + "slug": "6-festivalplus", "title": "Level 6 (FESTiVAL PLUS)", - "where": "chart.level = '6'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29720,9 +32761,10 @@ "inactive": true, "legacyFolderID": "Fc8f4d7073646fd50f359ccc89fb881c1570c53453d01f61744cf2b68271799f1", "searchTerms": [], + "slug": "6-festival", "title": "Level 6 (FESTiVAL)", - "where": "chart.level = '6'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29730,9 +32772,10 @@ "inactive": true, "legacyFolderID": "F92602bba126abae062018290b5859a301c23505b799c4628a6d0bd9cd8cf8cf6", "searchTerms": [], + "slug": "6-prism-omni", "title": "Level 6 (PRiSM Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29740,9 +32783,10 @@ "inactive": false, "legacyFolderID": "F373843cfe29b6c7bb44e34cb4d6ac0058e37f77014571e9e62147544a5d3f3b6", "searchTerms": [], + "slug": "6-prismplus-omni", "title": "Level 6 (PRiSM PLUS Omnimix)", - "where": "chart.level = '6'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29750,9 +32794,10 @@ "inactive": false, "legacyFolderID": "F11812fb1445cbf6854798699dd726a86d87ed682b12c722377b8fa2b1b2fb77e", "searchTerms": [], + "slug": "6-prismplus", "title": "Level 6 (PRiSM PLUS)", - "where": "chart.level = '6'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29760,9 +32805,10 @@ "inactive": true, "legacyFolderID": "Fd91d6a826d3e1d079073435a6fb556ffdfa23716c9575d21a05742cd6517eebd", "searchTerms": [], + "slug": "6-prism", "title": "Level 6 (PRiSM)", - "where": "chart.level = '6'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29770,9 +32816,10 @@ "inactive": true, "legacyFolderID": "Fd041168c6965fddfca8dccd7f00d2f3afaa07c1275a1f73d2ad8bb4e636b540b", "searchTerms": [], + "slug": "6-universeplus", "title": "Level 6 (UNiVERSE PLUS)", - "where": "chart.level = '6'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '6'" }, { "game": "maimaidx", @@ -29780,9 +32827,10 @@ "inactive": true, "legacyFolderID": "Fcddd74a8e0ec2dd38954bf185d83ea6bd8a3bf3acdf81d20363b37aa65dbcb27", "searchTerms": [], + "slug": "7-buddies-omni", "title": "Level 7 (BUDDiES Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29790,9 +32838,10 @@ "inactive": true, "legacyFolderID": "F65e0ffb3aa519f91c127f942f2df892e2f2566e9b90c842a78a0c6296bf6ad94", "searchTerms": [], + "slug": "7-buddiesplus-omni", "title": "Level 7 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29800,9 +32849,10 @@ "inactive": true, "legacyFolderID": "Fd82cdb730ee281616accc5a1195b6fbbe532b8fd96fccd4856fc2d29e89020db", "searchTerms": [], + "slug": "7-buddiesplus", "title": "Level 7 (BUDDiES PLUS)", - "where": "chart.level = '7'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29810,9 +32860,10 @@ "inactive": true, "legacyFolderID": "Fba7a1cf6300906989c72be6bc77866995a2cfe6e243fd4dbb0ea2498297a5d6b", "searchTerms": [], + "slug": "7-buddies", "title": "Level 7 (BUDDiES)", - "where": "chart.level = '7'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29820,9 +32871,10 @@ "inactive": false, "legacyFolderID": "F81a995704b5ca4c08e3f8a1d7cd25f76a62954ab991db032838beeb281d42184", "searchTerms": [], + "slug": "7-circle", "title": "Level 7 (CiRCLE)", - "where": "chart.level = '7'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29830,9 +32882,10 @@ "inactive": true, "legacyFolderID": "Fc4e09b577ab32460324dbb63e5bd9cb07080e504a6801783c61abeb0f6d84e92", "searchTerms": [], + "slug": "7-festivalplus", "title": "Level 7 (FESTiVAL PLUS)", - "where": "chart.level = '7'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29840,9 +32893,10 @@ "inactive": true, "legacyFolderID": "Fd025d4d84d23b96ae00f42d8569d619f7a25a4b2afb117afa26c9fa3f067b603", "searchTerms": [], + "slug": "7-festival", "title": "Level 7 (FESTiVAL)", - "where": "chart.level = '7'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29850,9 +32904,10 @@ "inactive": true, "legacyFolderID": "F3ba7be069f854fe73d456644e08d82c9b886efd20d6db6627adcf4dd52ec7242", "searchTerms": [], + "slug": "7-prism-omni", "title": "Level 7 (PRiSM Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29860,9 +32915,10 @@ "inactive": false, "legacyFolderID": "F46a6951f8d7103d1076c704f96ff917b965364a60cfad695515d670585249d8e", "searchTerms": [], + "slug": "7-prismplus-omni", "title": "Level 7 (PRiSM PLUS Omnimix)", - "where": "chart.level = '7'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29870,9 +32926,10 @@ "inactive": false, "legacyFolderID": "F32134b24a7c88186ae3cbd67fb17d715c5bd6353fe5d400e491f801a08c5a07f", "searchTerms": [], + "slug": "7-prismplus", "title": "Level 7 (PRiSM PLUS)", - "where": "chart.level = '7'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29880,9 +32937,10 @@ "inactive": true, "legacyFolderID": "F930fe91d13c6248c8f3d16d68fcf1dd4b453d8bc2d18dbf54cde45811105464b", "searchTerms": [], + "slug": "7-prism", "title": "Level 7 (PRiSM)", - "where": "chart.level = '7'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29890,9 +32948,10 @@ "inactive": true, "legacyFolderID": "Fd49059e2a6cbc98387396f25de5c338af91dea470a60d1fe00ebf9f0e4d9fb05", "searchTerms": [], + "slug": "7-universeplus", "title": "Level 7 (UNiVERSE PLUS)", - "where": "chart.level = '7'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '7'" }, { "game": "maimaidx", @@ -29900,9 +32959,10 @@ "inactive": true, "legacyFolderID": "F85c39f61741a2817d1b056747ae59443fb3f5344a8f49d1e0704f9b3a4c61d1d", "searchTerms": [], + "slug": "7p-buddies-omni", "title": "Level 7+ (BUDDiES Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29910,9 +32970,10 @@ "inactive": true, "legacyFolderID": "F8089359f75ade8424bce2d42bbd2bc6bb68aa1103412052e03f40494d301332d", "searchTerms": [], + "slug": "7p-buddiesplus-omni", "title": "Level 7+ (BUDDiES PLUS Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29920,9 +32981,10 @@ "inactive": true, "legacyFolderID": "F75c3d6ac386e7e85dd6dc4c6638b877de1a82f8ce7f653a53538827667283b2f", "searchTerms": [], + "slug": "7p-buddiesplus", "title": "Level 7+ (BUDDiES PLUS)", - "where": "chart.level = '7+'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29930,9 +32992,10 @@ "inactive": true, "legacyFolderID": "F711c04281259d8a6d2a573babd40b97e4097a036147444e82553a989657dd912", "searchTerms": [], + "slug": "7p-buddies", "title": "Level 7+ (BUDDiES)", - "where": "chart.level = '7+'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29940,9 +33003,10 @@ "inactive": false, "legacyFolderID": "Fd35c0d38b593f15bc7d935ca0206fbd6e72d0a3d7982834d05e01174d82f3658", "searchTerms": [], + "slug": "7p-circle", "title": "Level 7+ (CiRCLE)", - "where": "chart.level = '7+'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29950,9 +33014,10 @@ "inactive": true, "legacyFolderID": "F415ceafb19ad3e4c8470ae1f65b0e0191092e92813791c7d815cb3575c9d4e40", "searchTerms": [], + "slug": "7p-festivalplus", "title": "Level 7+ (FESTiVAL PLUS)", - "where": "chart.level = '7+'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29960,9 +33025,10 @@ "inactive": true, "legacyFolderID": "F2ee0e897e9d44677daf0568a38861e22fdfc48299267d28868bc1b6907d4866b", "searchTerms": [], + "slug": "7p-festival", "title": "Level 7+ (FESTiVAL)", - "where": "chart.level = '7+'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29970,9 +33036,10 @@ "inactive": true, "legacyFolderID": "F254e5497a3d3fc0cf598b760fbe4433c95d2bd55717d2595801bf2af93b9cef5", "searchTerms": [], + "slug": "7p-prism-omni", "title": "Level 7+ (PRiSM Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29980,9 +33047,10 @@ "inactive": false, "legacyFolderID": "F2a707795fb8e74ed251c7a63647c478af179b7d84a9aadd9040be6240b3c544f", "searchTerms": [], + "slug": "7p-prismplus-omni", "title": "Level 7+ (PRiSM PLUS Omnimix)", - "where": "chart.level = '7+'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -29990,9 +33058,10 @@ "inactive": false, "legacyFolderID": "F49de5277fb0154fd7dc06d9b37ca2c0171747dde5fc26cc32c15a2236ca67f85", "searchTerms": [], + "slug": "7p-prismplus", "title": "Level 7+ (PRiSM PLUS)", - "where": "chart.level = '7+'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -30000,9 +33069,10 @@ "inactive": true, "legacyFolderID": "Ff6244798c3254973109be81385e4b4d9e1a07bf5a2d02a04ebbefedaeb6a3a21", "searchTerms": [], + "slug": "7p-prism", "title": "Level 7+ (PRiSM)", - "where": "chart.level = '7+'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -30010,9 +33080,10 @@ "inactive": true, "legacyFolderID": "F98d1103245906fa743cc0823938681806c1d9779d2c30bf15e3e6b7813baa302", "searchTerms": [], + "slug": "7p-universeplus", "title": "Level 7+ (UNiVERSE PLUS)", - "where": "chart.level = '7+'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '7+'" }, { "game": "maimaidx", @@ -30020,9 +33091,10 @@ "inactive": true, "legacyFolderID": "Fdf42c543637bf04e75eefa6c3ceaa966e9a947f444d099046a6ef8fcb1b50e67", "searchTerms": [], + "slug": "8-buddies-omni", "title": "Level 8 (BUDDiES Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30030,9 +33102,10 @@ "inactive": true, "legacyFolderID": "F5787d981c4f073d44782abf09e4bddcd3503b60d29481aa0643d205b662810c1", "searchTerms": [], + "slug": "8-buddiesplus-omni", "title": "Level 8 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30040,9 +33113,10 @@ "inactive": true, "legacyFolderID": "F18a62c4ea18e3b94a19110bcc4831bca51e6cb8834808fc689199c47aa7415df", "searchTerms": [], + "slug": "8-buddiesplus", "title": "Level 8 (BUDDiES PLUS)", - "where": "chart.level = '8'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30050,9 +33124,10 @@ "inactive": true, "legacyFolderID": "F2254693dd4dd780baab128512a5b9ce8ed68e97c865d430e307bec63a00ec15a", "searchTerms": [], + "slug": "8-buddies", "title": "Level 8 (BUDDiES)", - "where": "chart.level = '8'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30060,9 +33135,10 @@ "inactive": false, "legacyFolderID": "F14c296a8311483d4cd4b009ba673c883acf0333dba46ae2443af8336637be23a", "searchTerms": [], + "slug": "8-circle", "title": "Level 8 (CiRCLE)", - "where": "chart.level = '8'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30070,9 +33146,10 @@ "inactive": true, "legacyFolderID": "F19c85ce0d47f2ec3cb58f8af4bb907d3acbf2946cbf7a01068cd8232ed93c432", "searchTerms": [], + "slug": "8-festivalplus", "title": "Level 8 (FESTiVAL PLUS)", - "where": "chart.level = '8'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30080,9 +33157,10 @@ "inactive": true, "legacyFolderID": "F4f87aa38b75a7dd48e4b6603b079bdc51143d9942505b329a00ac55acd0a7cf9", "searchTerms": [], + "slug": "8-festival", "title": "Level 8 (FESTiVAL)", - "where": "chart.level = '8'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30090,9 +33168,10 @@ "inactive": true, "legacyFolderID": "F89e77bb3aae464e971663d77ea004be66f13e286f25df12920d9a9d689edd9fd", "searchTerms": [], + "slug": "8-prism-omni", "title": "Level 8 (PRiSM Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30100,9 +33179,10 @@ "inactive": false, "legacyFolderID": "F4aa34c87acd53b43eddf49f25fd73ca643ebcd971c22ab29014941ca48e35277", "searchTerms": [], + "slug": "8-prismplus-omni", "title": "Level 8 (PRiSM PLUS Omnimix)", - "where": "chart.level = '8'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30110,9 +33190,10 @@ "inactive": false, "legacyFolderID": "F4c7868f27d259eaee66785b2ec5a706a3863555951a43ca63969bcffcb65e377", "searchTerms": [], + "slug": "8-prismplus", "title": "Level 8 (PRiSM PLUS)", - "where": "chart.level = '8'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30120,9 +33201,10 @@ "inactive": true, "legacyFolderID": "Fb7e926dcdd2703bbef78fab09ab8dcb9061624883f7e1f19b9c2d5f16d6520f0", "searchTerms": [], + "slug": "8-prism", "title": "Level 8 (PRiSM)", - "where": "chart.level = '8'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30130,9 +33212,10 @@ "inactive": true, "legacyFolderID": "F402f329188a03ec04ecf4494d11b88c4d2865b3f2ff169f7c1221759ba9ca051", "searchTerms": [], + "slug": "8-universeplus", "title": "Level 8 (UNiVERSE PLUS)", - "where": "chart.level = '8'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '8'" }, { "game": "maimaidx", @@ -30140,9 +33223,10 @@ "inactive": true, "legacyFolderID": "F72771be20374af5d0fad2e0e5bba74df0a5a4cc6710d7975fa8c100e947f1668", "searchTerms": [], + "slug": "8p-buddies-omni", "title": "Level 8+ (BUDDiES Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30150,9 +33234,10 @@ "inactive": true, "legacyFolderID": "Ff8c159e7e1bd53e2adc7b41581b2593006bbe5e436e9fd769416427559d326ca", "searchTerms": [], + "slug": "8p-buddiesplus-omni", "title": "Level 8+ (BUDDiES PLUS Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30160,9 +33245,10 @@ "inactive": true, "legacyFolderID": "Fa34aef19a4453b76485f9079be7cdf8bd8a6cb9b3cc0942ac35867cff71d8243", "searchTerms": [], + "slug": "8p-buddiesplus", "title": "Level 8+ (BUDDiES PLUS)", - "where": "chart.level = '8+'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30170,9 +33256,10 @@ "inactive": true, "legacyFolderID": "Fceb713055431ec52fa4c8ecbb7ab2869440a764612ae74a485e162453a73bd7d", "searchTerms": [], + "slug": "8p-buddies", "title": "Level 8+ (BUDDiES)", - "where": "chart.level = '8+'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30180,9 +33267,10 @@ "inactive": false, "legacyFolderID": "F4e5cd0675b01831312aec0ea2b0ee42abd990227a206f86a4baeae47155deeb6", "searchTerms": [], + "slug": "8p-circle", "title": "Level 8+ (CiRCLE)", - "where": "chart.level = '8+'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30190,9 +33278,10 @@ "inactive": true, "legacyFolderID": "F68780aac362eccc701537c6ca3ce22d2000e668b78b4b0db440b70a58ede10a5", "searchTerms": [], + "slug": "8p-festivalplus", "title": "Level 8+ (FESTiVAL PLUS)", - "where": "chart.level = '8+'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30200,9 +33289,10 @@ "inactive": true, "legacyFolderID": "Fa4ecef3517a3e9abb5dc3882ce4f4fec1faaac1aea3aa7c0b5b57774377d1e14", "searchTerms": [], + "slug": "8p-festival", "title": "Level 8+ (FESTiVAL)", - "where": "chart.level = '8+'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30210,9 +33300,10 @@ "inactive": true, "legacyFolderID": "Fa1bbe79c17a47455883342dfda921f4b83743e5e03cd5bd472e7dea46bfa221a", "searchTerms": [], + "slug": "8p-prism-omni", "title": "Level 8+ (PRiSM Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30220,9 +33311,10 @@ "inactive": false, "legacyFolderID": "F625a5db4e64692027706872d3fa728ec1a5f76f80c1cf68b43b9005b3b57bf19", "searchTerms": [], + "slug": "8p-prismplus-omni", "title": "Level 8+ (PRiSM PLUS Omnimix)", - "where": "chart.level = '8+'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30230,9 +33322,10 @@ "inactive": false, "legacyFolderID": "F8158c5d9ffe50d5e63046f336492dda65feb10d01c30c6119f984bf6a8ee78be", "searchTerms": [], + "slug": "8p-prismplus", "title": "Level 8+ (PRiSM PLUS)", - "where": "chart.level = '8+'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30240,9 +33333,10 @@ "inactive": true, "legacyFolderID": "F6197c76dd2564d94e2b2e063b6f625f635238d2bd1a18dffa5200bf5be38e8b3", "searchTerms": [], + "slug": "8p-prism", "title": "Level 8+ (PRiSM)", - "where": "chart.level = '8+'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30250,9 +33344,10 @@ "inactive": true, "legacyFolderID": "Fa34a7464ac08a40b3d31f4cf7d30f89768e3cad6a4a0907e2cc3a5ddcca4fa76", "searchTerms": [], + "slug": "8p-universeplus", "title": "Level 8+ (UNiVERSE PLUS)", - "where": "chart.level = '8+'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '8+'" }, { "game": "maimaidx", @@ -30260,9 +33355,10 @@ "inactive": true, "legacyFolderID": "F47921e943dcdcadf61e353cec6902e53200c0d0666d4c68b8f12b51584548a6e", "searchTerms": [], + "slug": "9-buddies-omni", "title": "Level 9 (BUDDiES Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30270,9 +33366,10 @@ "inactive": true, "legacyFolderID": "F84fd0affe606801aa7b10a2ccd5a0dbdffceca5bf552438372c182fd2ae3df23", "searchTerms": [], + "slug": "9-buddiesplus-omni", "title": "Level 9 (BUDDiES PLUS Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30280,9 +33377,10 @@ "inactive": true, "legacyFolderID": "Fd136268d1daefafce66c0703d4cb8af0571b0ac5083048f5529cda07e65cb84d", "searchTerms": [], + "slug": "9-buddiesplus", "title": "Level 9 (BUDDiES PLUS)", - "where": "chart.level = '9'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30290,9 +33388,10 @@ "inactive": true, "legacyFolderID": "F7e2d733286bdc0c6264e4163e6014948bb6bb1f05b810119f317f5a57985d077", "searchTerms": [], + "slug": "9-buddies", "title": "Level 9 (BUDDiES)", - "where": "chart.level = '9'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30300,9 +33399,10 @@ "inactive": false, "legacyFolderID": "Fbe66537e71a1c8bc490bc25eb37fded8d8c15c0c37e6d6a4e123e79f0960da6f", "searchTerms": [], + "slug": "9-circle", "title": "Level 9 (CiRCLE)", - "where": "chart.level = '9'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30310,9 +33410,10 @@ "inactive": true, "legacyFolderID": "F85f8595cc4e4e50840aa5da914034412c7fff420b464b334cec3f8f1981ae8e5", "searchTerms": [], + "slug": "9-festivalplus", "title": "Level 9 (FESTiVAL PLUS)", - "where": "chart.level = '9'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30320,9 +33421,10 @@ "inactive": true, "legacyFolderID": "Fe7b0e93434a80b2e9fe85a888ca7ac1b231e40700bbb290f8804df5ce64eadb7", "searchTerms": [], + "slug": "9-festival", "title": "Level 9 (FESTiVAL)", - "where": "chart.level = '9'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30330,9 +33432,10 @@ "inactive": true, "legacyFolderID": "F68e2c1cf2bfb1de771e62cbe6f06b95ccebae88e55ecafddac009efd2034cd5d", "searchTerms": [], + "slug": "9-prism-omni", "title": "Level 9 (PRiSM Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30340,9 +33443,10 @@ "inactive": false, "legacyFolderID": "F0c24c415aa5317a0ac0e31040a8e527f2e8096ea9abfbcff9f9b388217570f10", "searchTerms": [], + "slug": "9-prismplus-omni", "title": "Level 9 (PRiSM PLUS Omnimix)", - "where": "chart.level = '9'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30350,9 +33454,10 @@ "inactive": false, "legacyFolderID": "F38dac8d5934e6a39c37930ed040bfdec7049c9a18af6b6fc78f54ec77d902215", "searchTerms": [], + "slug": "9-prismplus", "title": "Level 9 (PRiSM PLUS)", - "where": "chart.level = '9'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30360,9 +33465,10 @@ "inactive": true, "legacyFolderID": "F2b98cd8e7ef3508a6555ef851d32c0831183a337b86c6f21fd8ebff71e560418", "searchTerms": [], + "slug": "9-prism", "title": "Level 9 (PRiSM)", - "where": "chart.level = '9'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30370,9 +33476,10 @@ "inactive": true, "legacyFolderID": "F48dbb374e10aa45cab57c9e45c7199b86b808d6ae0802515171d7df2a8b71c9e", "searchTerms": [], + "slug": "9-universeplus", "title": "Level 9 (UNiVERSE PLUS)", - "where": "chart.level = '9'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '9'" }, { "game": "maimaidx", @@ -30380,9 +33487,10 @@ "inactive": true, "legacyFolderID": "F09897b1f3c61588da6b51767c7e6411ff386212c57b088484529788f3252ae7b", "searchTerms": [], + "slug": "9p-buddies-omni", "title": "Level 9+ (BUDDiES Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["buddies-omni"] + "versionFilter": ["buddies-omni"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30390,9 +33498,10 @@ "inactive": true, "legacyFolderID": "F7d417809020b5efb9e27af2a7f8c4c2a7c839cad1758b542ba9d4435eb94e0de", "searchTerms": [], + "slug": "9p-buddiesplus-omni", "title": "Level 9+ (BUDDiES PLUS Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["buddiesplus-omni"] + "versionFilter": ["buddiesplus-omni"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30400,9 +33509,10 @@ "inactive": true, "legacyFolderID": "Fc78dea6db6604fd646428a49cf9de6791e385e7003942522d88976d5123069c3", "searchTerms": [], + "slug": "9p-buddiesplus", "title": "Level 9+ (BUDDiES PLUS)", - "where": "chart.level = '9+'", - "versionFilter": ["buddiesplus"] + "versionFilter": ["buddiesplus"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30410,9 +33520,10 @@ "inactive": true, "legacyFolderID": "F9d069cb37a49c2dd33eab44d72e7f45d36b059c0ec826b9272ece746559c47f3", "searchTerms": [], + "slug": "9p-buddies", "title": "Level 9+ (BUDDiES)", - "where": "chart.level = '9+'", - "versionFilter": ["buddies"] + "versionFilter": ["buddies"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30420,9 +33531,10 @@ "inactive": false, "legacyFolderID": "Fcc5dbd80328ac7d1e74b8110a04663cffc265144f20c95406f72c71b99dd3e0e", "searchTerms": [], + "slug": "9p-circle", "title": "Level 9+ (CiRCLE)", - "where": "chart.level = '9+'", - "versionFilter": ["circle"] + "versionFilter": ["circle"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30430,9 +33542,10 @@ "inactive": true, "legacyFolderID": "F5d0f96d7589d1afdf04e98f7404ef2d9724c069944ae0e532a6a9ca02101a01a", "searchTerms": [], + "slug": "9p-festivalplus", "title": "Level 9+ (FESTiVAL PLUS)", - "where": "chart.level = '9+'", - "versionFilter": ["festivalplus"] + "versionFilter": ["festivalplus"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30440,9 +33553,10 @@ "inactive": true, "legacyFolderID": "Feae498a86b1aa344d90ed94c9045df41c655e3afe99a8006e19c1c0e308df954", "searchTerms": [], + "slug": "9p-festival", "title": "Level 9+ (FESTiVAL)", - "where": "chart.level = '9+'", - "versionFilter": ["festival"] + "versionFilter": ["festival"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30450,9 +33564,10 @@ "inactive": true, "legacyFolderID": "F27931074d9e6b4c69502d71252fc342cfbaf193bf188d63be7d7c0c327bde194", "searchTerms": [], + "slug": "9p-prism-omni", "title": "Level 9+ (PRiSM Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["prism-omni"] + "versionFilter": ["prism-omni"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30460,9 +33575,10 @@ "inactive": false, "legacyFolderID": "F1bd098d5f00f2d9b1866eec1de48b6572056a37aa3975d921d4a4a89cff4227f", "searchTerms": [], + "slug": "9p-prismplus-omni", "title": "Level 9+ (PRiSM PLUS Omnimix)", - "where": "chart.level = '9+'", - "versionFilter": ["prismplus-omni"] + "versionFilter": ["prismplus-omni"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30470,9 +33586,10 @@ "inactive": false, "legacyFolderID": "F4370275c4dba47e94adcf6885ea831378cae5a59c93df006465e214c2a5f3c2b", "searchTerms": [], + "slug": "9p-prismplus", "title": "Level 9+ (PRiSM PLUS)", - "where": "chart.level = '9+'", - "versionFilter": ["prismplus"] + "versionFilter": ["prismplus"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30480,9 +33597,10 @@ "inactive": true, "legacyFolderID": "F5926171d049b0cc45476c5015dc64197a4ca7388fbe185ed866714800677c0b4", "searchTerms": [], + "slug": "9p-prism", "title": "Level 9+ (PRiSM)", - "where": "chart.level = '9+'", - "versionFilter": ["prism"] + "versionFilter": ["prism"], + "where": "chart.level = '9+'" }, { "game": "maimaidx", @@ -30490,9 +33608,10 @@ "inactive": true, "legacyFolderID": "Fbfd07d6eeaf655262a15efbbef3a7d014b69ae6d19ed555c2c62f603fed43e4c", "searchTerms": [], + "slug": "9p-universeplus", "title": "Level 9+ (UNiVERSE PLUS)", - "where": "chart.level = '9+'", - "versionFilter": ["universeplus"] + "versionFilter": ["universeplus"], + "where": "chart.level = '9+'" }, { "game": "museca", @@ -30500,9 +33619,10 @@ "inactive": false, "legacyFolderID": "Fa6dd05737a2f54494e81b1fd59b4a6b8885f7cfc673dba6f62797db7135c5993", "searchTerms": [], + "slug": "1-1-5-b", "title": "Level 1 (1 + 1/2 Rev. B)", - "where": "chart.level = '1'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '1'" }, { "game": "museca", @@ -30510,9 +33630,10 @@ "inactive": false, "legacyFolderID": "Fa0bf1a555ff954866487ef254c0e62328feffa2c4e795ecfc28a0b014d4a557f", "searchTerms": [], + "slug": "1-1-5", "title": "Level 1 (1 + 1/2)", - "where": "chart.level = '1'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '1'" }, { "game": "museca", @@ -30520,9 +33641,10 @@ "inactive": false, "legacyFolderID": "Fb5033381b613ec9aa09c2266e3fb3b77d0d6e1e24cb7c626b0ae6571b18e019b", "searchTerms": [], + "slug": "10-1-5-b", "title": "Level 10 (1 + 1/2 Rev. B)", - "where": "chart.level = '10'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '10'" }, { "game": "museca", @@ -30530,9 +33652,10 @@ "inactive": false, "legacyFolderID": "F7713771f1f8dcea8c42c06a9a3e374c9b2f738bd72145e3cad1970fdddf4048d", "searchTerms": [], + "slug": "10-1-5", "title": "Level 10 (1 + 1/2)", - "where": "chart.level = '10'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '10'" }, { "game": "museca", @@ -30540,9 +33663,10 @@ "inactive": false, "legacyFolderID": "F26d0a597f316d99d3ed0bc3d82499b295482765be6b7f163d2610c1ea9f8a294", "searchTerms": [], + "slug": "11-1-5-b", "title": "Level 11 (1 + 1/2 Rev. B)", - "where": "chart.level = '11'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '11'" }, { "game": "museca", @@ -30550,9 +33674,10 @@ "inactive": false, "legacyFolderID": "F3fcf431120fbbc828b97b4645895876340839f79707e3b0ebc051ea2ade1a34d", "searchTerms": [], + "slug": "11-1-5", "title": "Level 11 (1 + 1/2)", - "where": "chart.level = '11'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '11'" }, { "game": "museca", @@ -30560,9 +33685,10 @@ "inactive": false, "legacyFolderID": "Feff48f2ba0c9a72ad2b7c10c9a64d8fbfaecb4336c008980c3c30b78a55631c2", "searchTerms": [], + "slug": "12-1-5-b", "title": "Level 12 (1 + 1/2 Rev. B)", - "where": "chart.level = '12'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '12'" }, { "game": "museca", @@ -30570,9 +33696,10 @@ "inactive": false, "legacyFolderID": "F347921c8d57ccc0eb20eb092c9b55bf5eabb4f9fe7da00738876699cf1035c8c", "searchTerms": [], + "slug": "12-1-5", "title": "Level 12 (1 + 1/2)", - "where": "chart.level = '12'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '12'" }, { "game": "museca", @@ -30580,9 +33707,10 @@ "inactive": false, "legacyFolderID": "Fac39f148518f65ab1d8ca5393a2319b81556b492611fd46fe91d71f164b54935", "searchTerms": [], + "slug": "13-1-5-b", "title": "Level 13 (1 + 1/2 Rev. B)", - "where": "chart.level = '13'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '13'" }, { "game": "museca", @@ -30590,9 +33718,10 @@ "inactive": false, "legacyFolderID": "F56d1157c3e10438262dfb2af88569075a8f5c484a7aef08a6a84566319af24ea", "searchTerms": [], + "slug": "13-1-5", "title": "Level 13 (1 + 1/2)", - "where": "chart.level = '13'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '13'" }, { "game": "museca", @@ -30600,9 +33729,10 @@ "inactive": false, "legacyFolderID": "Fb43a75d7eda130f0514abd297bbb4b4b09486c3ad81e0ecb18de2c5a968c7878", "searchTerms": [], + "slug": "14-1-5-b", "title": "Level 14 (1 + 1/2 Rev. B)", - "where": "chart.level = '14'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '14'" }, { "game": "museca", @@ -30610,9 +33740,10 @@ "inactive": false, "legacyFolderID": "Fc5911b7640bf2db62d2acca372f1c5f6c7baf12fec05d990bb8c927ca6241b77", "searchTerms": [], + "slug": "14-1-5", "title": "Level 14 (1 + 1/2)", - "where": "chart.level = '14'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '14'" }, { "game": "museca", @@ -30620,9 +33751,10 @@ "inactive": false, "legacyFolderID": "F8c18af2c4334e359dd271ec49cc0ba2052454104713c3ca17f96631abac66938", "searchTerms": [], + "slug": "15-1-5-b", "title": "Level 15 (1 + 1/2 Rev. B)", - "where": "chart.level = '15'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '15'" }, { "game": "museca", @@ -30630,9 +33762,10 @@ "inactive": false, "legacyFolderID": "Fe1171c1e6c031cddc03080911b081e494e3bd526d4274b99b72043135ff6e17d", "searchTerms": [], + "slug": "15-1-5", "title": "Level 15 (1 + 1/2)", - "where": "chart.level = '15'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '15'" }, { "game": "museca", @@ -30640,9 +33773,10 @@ "inactive": false, "legacyFolderID": "F4b269bccaa0fd9a1bed1346aa2d168307c1baae5a4d076e994750ca0862098bd", "searchTerms": [], + "slug": "16-1-5-b", "title": "Level 16 (1 + 1/2 Rev. B)", - "where": "chart.level = '16'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '16'" }, { "game": "museca", @@ -30650,9 +33784,10 @@ "inactive": false, "legacyFolderID": "F9ae7894021d3a3ee23d7a5079e75e2a2234bd54c4735b7caf6327a7c9f4642ed", "searchTerms": [], + "slug": "16-1-5", "title": "Level 16 (1 + 1/2)", - "where": "chart.level = '16'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '16'" }, { "game": "museca", @@ -30660,9 +33795,10 @@ "inactive": false, "legacyFolderID": "F3a9ad835fbcf803e5e134686d3c837a2e01bf2f90ee1a7ba076d205e451c5d0b", "searchTerms": [], + "slug": "2-1-5-b", "title": "Level 2 (1 + 1/2 Rev. B)", - "where": "chart.level = '2'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '2'" }, { "game": "museca", @@ -30670,9 +33806,10 @@ "inactive": false, "legacyFolderID": "F35c650cc79bd54fd0411919e9b143ec7e6507753b2b2bd5046ad36b56b49518d", "searchTerms": [], + "slug": "2-1-5", "title": "Level 2 (1 + 1/2)", - "where": "chart.level = '2'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '2'" }, { "game": "museca", @@ -30680,9 +33817,10 @@ "inactive": false, "legacyFolderID": "Fbfd9a58fe40449df1628e18f9645b9c10e1208057ff48032067c4d81901d8c62", "searchTerms": [], + "slug": "3-1-5-b", "title": "Level 3 (1 + 1/2 Rev. B)", - "where": "chart.level = '3'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '3'" }, { "game": "museca", @@ -30690,9 +33828,10 @@ "inactive": false, "legacyFolderID": "F061042918a3671ee7c8753058ee3c124f890296aba226a8dafc52c44648c7fde", "searchTerms": [], + "slug": "3-1-5", "title": "Level 3 (1 + 1/2)", - "where": "chart.level = '3'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '3'" }, { "game": "museca", @@ -30700,9 +33839,10 @@ "inactive": false, "legacyFolderID": "F07c81b52e42f32adb1e05ab2d7483fd6aa696556f0c07fe34c3fbc2c15ae226e", "searchTerms": [], + "slug": "4-1-5-b", "title": "Level 4 (1 + 1/2 Rev. B)", - "where": "chart.level = '4'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '4'" }, { "game": "museca", @@ -30710,9 +33850,10 @@ "inactive": false, "legacyFolderID": "Ff7677429f29a5d47b80feb524a4784748a26f1a1333058c8e99bf77450631b4d", "searchTerms": [], + "slug": "4-1-5", "title": "Level 4 (1 + 1/2)", - "where": "chart.level = '4'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '4'" }, { "game": "museca", @@ -30720,9 +33861,10 @@ "inactive": false, "legacyFolderID": "F8a42c3dd3b48652e6f41658e5f767183ce5c253d7c999ecbe573144d7be04377", "searchTerms": [], + "slug": "5-1-5-b", "title": "Level 5 (1 + 1/2 Rev. B)", - "where": "chart.level = '5'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '5'" }, { "game": "museca", @@ -30730,9 +33872,10 @@ "inactive": false, "legacyFolderID": "F10c5cef136f6c3047067c34b3335f0cc447d8b2d74d1665285830dc5b81c5073", "searchTerms": [], + "slug": "5-1-5", "title": "Level 5 (1 + 1/2)", - "where": "chart.level = '5'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '5'" }, { "game": "museca", @@ -30740,9 +33883,10 @@ "inactive": false, "legacyFolderID": "Fa75a6aa12dc70001ebc1ce87ede8aaf1c00095504be8c2b03f09947f1f20b642", "searchTerms": [], + "slug": "6-1-5-b", "title": "Level 6 (1 + 1/2 Rev. B)", - "where": "chart.level = '6'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '6'" }, { "game": "museca", @@ -30750,9 +33894,10 @@ "inactive": false, "legacyFolderID": "F0dccc13ec4ff539525f01a32811a526e2c179643fd836d0487ab962da549746f", "searchTerms": [], + "slug": "6-1-5", "title": "Level 6 (1 + 1/2)", - "where": "chart.level = '6'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '6'" }, { "game": "museca", @@ -30760,9 +33905,10 @@ "inactive": false, "legacyFolderID": "Fb5e10fa9199fd1d9bdf8b7ae0f966c839f22ff636fbb5b7a2a3d4ba40bc43b69", "searchTerms": [], + "slug": "7-1-5-b", "title": "Level 7 (1 + 1/2 Rev. B)", - "where": "chart.level = '7'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '7'" }, { "game": "museca", @@ -30770,9 +33916,10 @@ "inactive": false, "legacyFolderID": "Fdca377d51cd9091509316743b01c7a63742bab55ce0f5b3c3d636b258f52d4e0", "searchTerms": [], + "slug": "7-1-5", "title": "Level 7 (1 + 1/2)", - "where": "chart.level = '7'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '7'" }, { "game": "museca", @@ -30780,9 +33927,10 @@ "inactive": false, "legacyFolderID": "F6024c7cf3ec266d263308c9895cf18ee6ac7ac00dbf5844e354e3730a73fec01", "searchTerms": [], + "slug": "8-1-5-b", "title": "Level 8 (1 + 1/2 Rev. B)", - "where": "chart.level = '8'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '8'" }, { "game": "museca", @@ -30790,9 +33938,10 @@ "inactive": false, "legacyFolderID": "Fbce87b98d60bad78771cbdedd79f96e2b8ae93576e728a6ed4c68f2bed2611d5", "searchTerms": [], + "slug": "8-1-5", "title": "Level 8 (1 + 1/2)", - "where": "chart.level = '8'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '8'" }, { "game": "museca", @@ -30800,9 +33949,10 @@ "inactive": false, "legacyFolderID": "F87345cd5f190cbda888fad39e5de60fac215f9adc94c82fd99e2fbe1c7cdf4a0", "searchTerms": [], + "slug": "9-1-5-b", "title": "Level 9 (1 + 1/2 Rev. B)", - "where": "chart.level = '9'", - "versionFilter": ["1.5-b"] + "versionFilter": ["1.5-b"], + "where": "chart.level = '9'" }, { "game": "museca", @@ -30810,9 +33960,10 @@ "inactive": false, "legacyFolderID": "F27712325ab3663250d42d79b10eda50f074b6aa256553b0b7d194df98c3f39c3", "searchTerms": [], + "slug": "9-1-5", "title": "Level 9 (1 + 1/2)", - "where": "chart.level = '9'", - "versionFilter": ["1.5"] + "versionFilter": ["1.5"], + "where": "chart.level = '9'" }, { "game": "ongeki", @@ -30820,9 +33971,10 @@ "inactive": true, "legacyFolderID": "F305e869a319eed3730401d30ab9949d79960940cdf4b175d33b8134679f4a6be", "searchTerms": [], + "slug": "brightmemory2omni-advanced", "title": "ADVANCED (bright MEMORY Act.2 Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'" }, { "game": "ongeki", @@ -30830,9 +33982,10 @@ "inactive": false, "legacyFolderID": "F652bb1af4e067f7702be021b78cd5e65400831e878c1aad7f8378c59ad6d0b91", "searchTerms": [], + "slug": "brightmemory3omni-advanced", "title": "ADVANCED (bright MEMORY Act.3 Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'" }, { "game": "ongeki", @@ -30840,9 +33993,10 @@ "inactive": true, "legacyFolderID": "F3462b0a17574425bde8b7c37995cb004fc581d4f89e58c1d11710d687a213690", "searchTerms": [], + "slug": "brightmemory3-advanced", "title": "ADVANCED (bright MEMORY Act.3)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'" }, { "game": "ongeki", @@ -30850,9 +34004,10 @@ "inactive": false, "legacyFolderID": "F926f58195b6e502654570b7ae179a293a1996be8356389b5ac65f2f99632b191", "searchTerms": [], + "slug": "refreshomni-advanced", "title": "ADVANCED (Re:Fresh Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'" }, { "game": "ongeki", @@ -30860,9 +34015,10 @@ "inactive": false, "legacyFolderID": "F08f88693a50f0d3d5a3019d6b0095901fa83b440efd1ca17a3a38ae0d0534355", "searchTerms": [], + "slug": "refresh-advanced", "title": "ADVANCED (Re:Fresh)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'ADVANCED'" }, { "game": "ongeki", @@ -30870,9 +34026,10 @@ "inactive": true, "legacyFolderID": "F087a2527248b01ad86e42bbde0ee9cd62848440e1d9a860cfd2d6a610896cf51", "searchTerms": [], + "slug": "brightmemory2omni-basic", "title": "BASIC (bright MEMORY Act.2 Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'" }, { "game": "ongeki", @@ -30880,9 +34037,10 @@ "inactive": false, "legacyFolderID": "F57af019f3a219b19c27d08384c5406ff1745dae541da4c3c874e75fda3254bdf", "searchTerms": [], + "slug": "brightmemory3omni-basic", "title": "BASIC (bright MEMORY Act.3 Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'" }, { "game": "ongeki", @@ -30890,9 +34048,10 @@ "inactive": true, "legacyFolderID": "F78324dd4b99662a14299f4a79e387134304e43bf32370a359706619f829a0ee8", "searchTerms": [], + "slug": "brightmemory3-basic", "title": "BASIC (bright MEMORY Act.3)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'" }, { "game": "ongeki", @@ -30900,9 +34059,10 @@ "inactive": false, "legacyFolderID": "F6e0f79828e049cf59d5bf3f69fee1eca55b8c2ee973dd1f9a4c1e9181d80827b", "searchTerms": [], + "slug": "refreshomni-basic", "title": "BASIC (Re:Fresh Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'" }, { "game": "ongeki", @@ -30910,9 +34070,10 @@ "inactive": false, "legacyFolderID": "F2478c0debc6ab076945b12b501dcb110a0e2277ef56595b7b981c21d8b0e09c3", "searchTerms": [], + "slug": "refresh-basic", "title": "BASIC (Re:Fresh)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'BASIC'" }, { "game": "ongeki", @@ -30920,6 +34081,7 @@ "inactive": false, "legacyFolderID": "F34a8b0ba9ab1d49b73d4cb0c68aca2ca2fff5ef4f0eaa0d169c8cabaaf7f24e3", "searchTerms": [], + "slug": "bonus-tracks", "title": "Bonus tracks", "where": "(chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000" }, @@ -30929,9 +34091,10 @@ "inactive": true, "legacyFolderID": "Fb31352b04aa6e67f1e6596f90281ad7c65b8fa4d5aca71af0c903be7b10b0d71", "searchTerms": [], + "slug": "brightmemory2omni-expert", "title": "EXPERT (bright MEMORY Act.2 Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'" }, { "game": "ongeki", @@ -30939,9 +34102,10 @@ "inactive": false, "legacyFolderID": "F0c60ce5e02ca76c77a41c00df9f2a4909474195b5d04e2d9148a5eeb943d483e", "searchTerms": [], + "slug": "brightmemory3omni-expert", "title": "EXPERT (bright MEMORY Act.3 Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'" }, { "game": "ongeki", @@ -30949,9 +34113,10 @@ "inactive": true, "legacyFolderID": "F27605e21edd9f6753e44977aa7c30a85acd21d4ee6c28a176af93477d3bbd198", "searchTerms": [], + "slug": "brightmemory3-expert", "title": "EXPERT (bright MEMORY Act.3)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'" }, { "game": "ongeki", @@ -30959,9 +34124,10 @@ "inactive": false, "legacyFolderID": "Fe6dd33f70ca2a79fcce76739d1cf00a5ce0efaa6b62e22a90a33c3acf7f6ccfe", "searchTerms": [], + "slug": "refreshomni-expert", "title": "EXPERT (Re:Fresh Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'" }, { "game": "ongeki", @@ -30969,9 +34135,10 @@ "inactive": false, "legacyFolderID": "F9a0e4dfbdf5d0dfd51c0f738033de9e924df4ac5651e95e8087225052466695a", "searchTerms": [], + "slug": "refresh-expert", "title": "EXPERT (Re:Fresh)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'EXPERT'" }, { "game": "ongeki", @@ -30979,9 +34146,10 @@ "inactive": true, "legacyFolderID": "F9d846efcecb94b8379983e966007b5ebb19a060840dabfd959b3325b5ca871c2", "searchTerms": [], + "slug": "0-brightmemory2omni", "title": "Level 0 (bright MEMORY Act.2 Omnimix)", - "where": "chart.level = '0'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '0'" }, { "game": "ongeki", @@ -30989,9 +34157,10 @@ "inactive": false, "legacyFolderID": "F276de07bc9ce42088c76a4c65dcf962241c5b509533cb035558f9369565ab938", "searchTerms": [], + "slug": "0-brightmemory3omni", "title": "Level 0 (bright MEMORY Act.3 Omnimix)", - "where": "chart.level = '0'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "chart.level = '0'" }, { "game": "ongeki", @@ -30999,9 +34168,10 @@ "inactive": true, "legacyFolderID": "Fad5e5eb714f0e8d039f533564c92cb95990ca1f5e864ff8cb4766032283e2de2", "searchTerms": [], + "slug": "0-brightmemory3", "title": "Level 0 (bright MEMORY Act.3)", - "where": "chart.level = '0'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "chart.level = '0'" }, { "game": "ongeki", @@ -31009,9 +34179,10 @@ "inactive": false, "legacyFolderID": "Fd8e306cee04a195610cb6b504cba6a3a76d04523ea6a510275859949fea9c1fc", "searchTerms": [], + "slug": "0-refreshomni", "title": "Level 0 (Re:Fresh Omnimix)", - "where": "chart.level = '0'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "chart.level = '0'" }, { "game": "ongeki", @@ -31019,9 +34190,10 @@ "inactive": false, "legacyFolderID": "F4f2b698877b5bc95a61f5a855505f46cad196722785a6f77fae3e6fe8d8b7ce7", "searchTerms": [], + "slug": "0-refresh", "title": "Level 0 (Re:Fresh)", - "where": "chart.level = '0'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "chart.level = '0'" }, { "game": "ongeki", @@ -31029,9 +34201,10 @@ "inactive": true, "legacyFolderID": "F8fdf6f907e546759012cc28c886d41ac088ffc34cd4ee37a900cee5b3d2196fb", "searchTerms": [], + "slug": "1-brightmemory2omni", "title": "Level 1 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'" }, { "game": "ongeki", @@ -31039,9 +34212,10 @@ "inactive": true, "legacyFolderID": "Ffa12bf18b5972cb731dea1ebf4176ff2bc94e1acd45e2d0a3772c1df9a96536b", "searchTerms": [], + "slug": "1-brightmemory2omni-old", "title": "Level 1 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '1'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '1'" }, { "game": "ongeki", @@ -31049,9 +34223,10 @@ "inactive": false, "legacyFolderID": "F5acebab94a8b1d8824a5862cdeed6f4aa1b3ad5374cf30befdc9817b9487f14b", "searchTerms": [], + "slug": "1-brightmemory3omni", "title": "Level 1 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'" }, { "game": "ongeki", @@ -31059,9 +34234,10 @@ "inactive": true, "legacyFolderID": "F57210acc1e0285914ceac88b341c372fae3e785c8191e461c8c663bc4518694b", "searchTerms": [], + "slug": "1-brightmemory3", "title": "Level 1 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'" }, { "game": "ongeki", @@ -31069,9 +34245,10 @@ "inactive": false, "legacyFolderID": "F6153fa5d95ea654c5ce6ef8b3582444b4a771e1d549f0e11a5fccd995b4b5c52", "searchTerms": [], + "slug": "1-refreshomni", "title": "Level 1 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'" }, { "game": "ongeki", @@ -31079,9 +34256,10 @@ "inactive": false, "legacyFolderID": "Fafdee855b3eb2b7f15320ba5b612a9ace651ae577937a8fd617704638a66e164", "searchTerms": [], + "slug": "1-refresh", "title": "Level 1 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '1'" }, { "game": "ongeki", @@ -31089,9 +34267,10 @@ "inactive": true, "legacyFolderID": "F5630c4dec5b44a7635978c3c009e18a29400289dca668c465d7217bb95fa3060", "searchTerms": [], + "slug": "10-brightmemory2omni", "title": "Level 10 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'" }, { "game": "ongeki", @@ -31099,9 +34278,10 @@ "inactive": true, "legacyFolderID": "F0ba4c77645385e7a56356888ca70a997fc275bd17171fdbeaff69e26cb43dc0f", "searchTerms": [], + "slug": "10-brightmemory2omni-old", "title": "Level 10 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '10'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '10'" }, { "game": "ongeki", @@ -31109,9 +34289,10 @@ "inactive": false, "legacyFolderID": "Fb060da955527304b59c51e6ccbe959590fda4b46306599629f6115cfcb784b63", "searchTerms": [], + "slug": "10-brightmemory3omni", "title": "Level 10 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'" }, { "game": "ongeki", @@ -31119,9 +34300,10 @@ "inactive": true, "legacyFolderID": "Fc742f4657847cde003fc80df9f629667636d67c86e8e698052d005f0fe20422d", "searchTerms": [], + "slug": "10-brightmemory3", "title": "Level 10 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'" }, { "game": "ongeki", @@ -31129,9 +34311,10 @@ "inactive": false, "legacyFolderID": "F83efac88509a0c9dec80b8e65ced1fbe04b90b959fa363794770658edce71bb0", "searchTerms": [], + "slug": "10-refreshomni", "title": "Level 10 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'" }, { "game": "ongeki", @@ -31139,9 +34322,10 @@ "inactive": false, "legacyFolderID": "Ff2d48b2f8c8b08f48a50096fa4770e3d4e356a357b7f65acc477a81648b4836a", "searchTerms": [], + "slug": "10-refresh", "title": "Level 10 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10'" }, { "game": "ongeki", @@ -31149,9 +34333,10 @@ "inactive": true, "legacyFolderID": "F0eb80bef6791d6909360393bc3fe0e67d3e3500df936f59802545bdf4776d263", "searchTerms": [], + "slug": "10p-brightmemory2omni", "title": "Level 10+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'" }, { "game": "ongeki", @@ -31159,9 +34344,10 @@ "inactive": true, "legacyFolderID": "Ffd89626baa00e8a61e970a8d99623e2349e457d17c515632ea3968aedce81275", "searchTerms": [], + "slug": "10p-brightmemory2omni-old", "title": "Level 10+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '10+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '10+'" }, { "game": "ongeki", @@ -31169,9 +34355,10 @@ "inactive": false, "legacyFolderID": "Fa869483f698a6e4c1bc58c8c2923d778980bccab25321c549257bf83c8d78558", "searchTerms": [], + "slug": "10p-brightmemory3omni", "title": "Level 10+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'" }, { "game": "ongeki", @@ -31179,9 +34366,10 @@ "inactive": true, "legacyFolderID": "Fca7eb11a8f7505fec8a6f4a651e30c1629d30227642821cae1796baa9ad55492", "searchTerms": [], + "slug": "10p-brightmemory3", "title": "Level 10+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'" }, { "game": "ongeki", @@ -31189,9 +34377,10 @@ "inactive": false, "legacyFolderID": "Fa68a04e80b382dca1402e601293b8e1758ad6592b7cf4c8f2170dcca0da94347", "searchTerms": [], + "slug": "10p-refreshomni", "title": "Level 10+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'" }, { "game": "ongeki", @@ -31199,9 +34388,10 @@ "inactive": false, "legacyFolderID": "F980ee749bfb7d592e49408764441ad62066773055b2595e21c5a945b31daf106", "searchTerms": [], + "slug": "10p-refresh", "title": "Level 10+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '10+'" }, { "game": "ongeki", @@ -31209,9 +34399,10 @@ "inactive": true, "legacyFolderID": "F12ebd4bf3299da9f074096928c25056e760f357fc8d41a35b5f4076fba6a30b8", "searchTerms": [], + "slug": "11-brightmemory2omni", "title": "Level 11 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'" }, { "game": "ongeki", @@ -31219,9 +34410,10 @@ "inactive": true, "legacyFolderID": "F708faf85fa072155c1d17f6f923e108b4ed58fb255365655d1303f73e58f057c", "searchTerms": [], + "slug": "11-brightmemory2omni-old", "title": "Level 11 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '11'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '11'" }, { "game": "ongeki", @@ -31229,9 +34421,10 @@ "inactive": false, "legacyFolderID": "F261f4824da2c5cc641f01df3f10cb478eaa1c8ef2f2c8f6c2c306c17ab9e5204", "searchTerms": [], + "slug": "11-brightmemory3omni", "title": "Level 11 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'" }, { "game": "ongeki", @@ -31239,9 +34432,10 @@ "inactive": true, "legacyFolderID": "F2d66be8c998e9c75a05c7dc572b7f1d3b8ad909734b0ff07e84277faa89f3062", "searchTerms": [], + "slug": "11-brightmemory3", "title": "Level 11 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'" }, { "game": "ongeki", @@ -31249,9 +34443,10 @@ "inactive": false, "legacyFolderID": "F018b461820721d56e478ea2e234c6c6ca5fa0f700408e42b004ad3fa94a87303", "searchTerms": [], + "slug": "11-refreshomni", "title": "Level 11 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'" }, { "game": "ongeki", @@ -31259,9 +34454,10 @@ "inactive": false, "legacyFolderID": "F35d077e25e91ebcef00668a81e3c761cdf36f0875252eb093edd4476ee6861a8", "searchTerms": [], + "slug": "11-refresh", "title": "Level 11 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11'" }, { "game": "ongeki", @@ -31269,9 +34465,10 @@ "inactive": true, "legacyFolderID": "F4aedbc8792153701fa69c720bddf16f63c4e3309148a12edfa1f0961dc251129", "searchTerms": [], + "slug": "11p-brightmemory2omni", "title": "Level 11+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'" }, { "game": "ongeki", @@ -31279,9 +34476,10 @@ "inactive": true, "legacyFolderID": "F6b90d7cd9925650a018c6e2fbee2c38fbebfb52920d772be481687a849ece4ce", "searchTerms": [], + "slug": "11p-brightmemory2omni-old", "title": "Level 11+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '11+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '11+'" }, { "game": "ongeki", @@ -31289,9 +34487,10 @@ "inactive": false, "legacyFolderID": "F4028d6a4780aeb0971efdd5de08f376c995ba85094c0d9da6b562437e3c93c5d", "searchTerms": [], + "slug": "11p-brightmemory3omni", "title": "Level 11+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'" }, { "game": "ongeki", @@ -31299,9 +34498,10 @@ "inactive": true, "legacyFolderID": "Fca0ac80c053a131feba68dceca7a2e0c5558ab2ae5129d3482a67be803df5a84", "searchTerms": [], + "slug": "11p-brightmemory3", "title": "Level 11+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'" }, { "game": "ongeki", @@ -31309,9 +34509,10 @@ "inactive": false, "legacyFolderID": "F441bd43de7b24b297c8a8fa9842dc8c2182e444435f137aaa7d5e12ed7005bc1", "searchTerms": [], + "slug": "11p-refreshomni", "title": "Level 11+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'" }, { "game": "ongeki", @@ -31319,9 +34520,10 @@ "inactive": false, "legacyFolderID": "F0cad8733c234b93715c25070853cfb506f5eab043312679fa498585e9839f77a", "searchTerms": [], + "slug": "11p-refresh", "title": "Level 11+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '11+'" }, { "game": "ongeki", @@ -31329,9 +34531,10 @@ "inactive": true, "legacyFolderID": "F72cab226ec8f71c45991a52477669e5238c4839ab5dafb77c80b095580a0b44c", "searchTerms": [], + "slug": "12-brightmemory2omni", "title": "Level 12 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'" }, { "game": "ongeki", @@ -31339,9 +34542,10 @@ "inactive": true, "legacyFolderID": "F1f0dd1639ed0204b5824e18816b5227cc924099fbe2c60b7ecfcdeef7f4f22c2", "searchTerms": [], + "slug": "12-brightmemory2omni-old", "title": "Level 12 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '12'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '12'" }, { "game": "ongeki", @@ -31349,9 +34553,10 @@ "inactive": false, "legacyFolderID": "Fff68ad32f2ad2bf1007137b96a850154935697eb8b4d40885df05e136fc0fbe3", "searchTerms": [], + "slug": "12-brightmemory3omni", "title": "Level 12 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'" }, { "game": "ongeki", @@ -31359,9 +34564,10 @@ "inactive": true, "legacyFolderID": "F275d326f92c9aaec500f841ec376c20e7df37bfab74d4854f07be061b1c1fdca", "searchTerms": [], + "slug": "12-brightmemory3", "title": "Level 12 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'" }, { "game": "ongeki", @@ -31369,9 +34575,10 @@ "inactive": false, "legacyFolderID": "Fe9b5fc9e21aafd0cca4221ce85b8506c593c79e7b7c8b5e907128b03109a8e63", "searchTerms": [], + "slug": "12-refreshomni", "title": "Level 12 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'" }, { "game": "ongeki", @@ -31379,9 +34586,10 @@ "inactive": false, "legacyFolderID": "Fa126a4bfb5c021c029a5c60e417c4b52134868c7a66c7cb6cacfd4231847cd51", "searchTerms": [], + "slug": "12-refresh", "title": "Level 12 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12'" }, { "game": "ongeki", @@ -31389,9 +34597,10 @@ "inactive": true, "legacyFolderID": "Fa96e8bb0d309e691bafa9e49b5a459aa8908895fa7db0de1441ccbb9277f92f9", "searchTerms": [], + "slug": "12p-brightmemory2omni", "title": "Level 12+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'" }, { "game": "ongeki", @@ -31399,9 +34608,10 @@ "inactive": true, "legacyFolderID": "Fb3f1d6f643a8980b222451abc22b76c89a58933600555a60e59156a7015c656b", "searchTerms": [], + "slug": "12p-brightmemory2omni-old", "title": "Level 12+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '12+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '12+'" }, { "game": "ongeki", @@ -31409,9 +34619,10 @@ "inactive": false, "legacyFolderID": "F6edad91b176d42e0bcb10ef0f46d56a9f873f06e7bf56fb230676a31466ae7c7", "searchTerms": [], + "slug": "12p-brightmemory3omni", "title": "Level 12+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'" }, { "game": "ongeki", @@ -31419,9 +34630,10 @@ "inactive": true, "legacyFolderID": "F22d71ff3b20a28619360e01c6afa29fb661a4edfb393439182bb6e160bbf4277", "searchTerms": [], + "slug": "12p-brightmemory3", "title": "Level 12+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'" }, { "game": "ongeki", @@ -31429,9 +34641,10 @@ "inactive": false, "legacyFolderID": "F3fcd73d7fa1a31186bb9d48c3c615496b887c791f022efb5db354e4c2151adb2", "searchTerms": [], + "slug": "12p-refreshomni", "title": "Level 12+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'" }, { "game": "ongeki", @@ -31439,9 +34652,10 @@ "inactive": false, "legacyFolderID": "F324e892317cbe183e0baff6fc27ed55df54f721c9175588853d5c07aa8f2c0a6", "searchTerms": [], + "slug": "12p-refresh", "title": "Level 12+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '12+'" }, { "game": "ongeki", @@ -31449,9 +34663,10 @@ "inactive": true, "legacyFolderID": "Fde913b3904de0b88ce6ffbcd63e6d4e68079cdbb13a794bd4ccee6d9a6960269", "searchTerms": [], + "slug": "13-brightmemory2omni", "title": "Level 13 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'" }, { "game": "ongeki", @@ -31459,9 +34674,10 @@ "inactive": true, "legacyFolderID": "F03dfd9ab66ee36df9c755571856525960dd88df53a2ed3e09fa9c21c4c17f63c", "searchTerms": [], + "slug": "13-brightmemory2omni-old", "title": "Level 13 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '13'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '13'" }, { "game": "ongeki", @@ -31469,9 +34685,10 @@ "inactive": false, "legacyFolderID": "Fd7dfc091c2f0ecfde4fafa2b25198e7fac6fb448f3b745f8c9196f498a212c6f", "searchTerms": [], + "slug": "13-brightmemory3omni", "title": "Level 13 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'" }, { "game": "ongeki", @@ -31479,9 +34696,10 @@ "inactive": true, "legacyFolderID": "F71f7f8d35efbb2e444947cb4a8108ec5b4029d77b3fe6098e859b6ae6db85b6a", "searchTerms": [], + "slug": "13-brightmemory3", "title": "Level 13 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'" }, { "game": "ongeki", @@ -31489,9 +34707,10 @@ "inactive": false, "legacyFolderID": "F88e4061441b54406b89eae8970f78dc3bb47d641c67c50ec8d23ffe17b2c3d83", "searchTerms": [], + "slug": "13-refreshomni", "title": "Level 13 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'" }, { "game": "ongeki", @@ -31499,9 +34718,10 @@ "inactive": false, "legacyFolderID": "F576605e9d7276fe2e280eadc61a5415ff0f8bcc13aaef68aca6149ba0aa0c6e6", "searchTerms": [], + "slug": "13-refresh", "title": "Level 13 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13'" }, { "game": "ongeki", @@ -31509,9 +34729,10 @@ "inactive": true, "legacyFolderID": "F248bcff1e7e53f9268b81f49715c63542667a5ba2e7be13c31b8fb386d1956d4", "searchTerms": [], + "slug": "13p-brightmemory2omni", "title": "Level 13+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'" }, { "game": "ongeki", @@ -31519,9 +34740,10 @@ "inactive": true, "legacyFolderID": "F4c4c22d80d06b037df77787d776cfac622e2ba59f24535ee7e846ed3a1fc2b2e", "searchTerms": [], + "slug": "13p-brightmemory2omni-old", "title": "Level 13+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '13+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '13+'" }, { "game": "ongeki", @@ -31529,9 +34751,10 @@ "inactive": false, "legacyFolderID": "F768aae13fb84a97565843dd72279dc173253a941dff8bac4350115dfedc6f5c2", "searchTerms": [], + "slug": "13p-brightmemory3omni", "title": "Level 13+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'" }, { "game": "ongeki", @@ -31539,9 +34762,10 @@ "inactive": true, "legacyFolderID": "F02ff26a0fe73ea70dc2f45d98468f3fdddade10b328e4be791486f99c163a832", "searchTerms": [], + "slug": "13p-brightmemory3", "title": "Level 13+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'" }, { "game": "ongeki", @@ -31549,9 +34773,10 @@ "inactive": false, "legacyFolderID": "Faac3724955c27679c9cbf30139e9850db72a019bf9f4c7393150f70b129872e8", "searchTerms": [], + "slug": "13p-refreshomni", "title": "Level 13+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'" }, { "game": "ongeki", @@ -31559,9 +34784,10 @@ "inactive": false, "legacyFolderID": "F72d6326c3746cd58a3395f910085843bef6c8499edded40bbdd7e19821d514b1", "searchTerms": [], + "slug": "13p-refresh", "title": "Level 13+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '13+'" }, { "game": "ongeki", @@ -31569,9 +34795,10 @@ "inactive": true, "legacyFolderID": "Fa08113e0022b1979116a1252e9689c235ba29dde3d0b2c05380debdc10a04475", "searchTerms": [], + "slug": "14-brightmemory2omni", "title": "Level 14 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'" }, { "game": "ongeki", @@ -31579,9 +34806,10 @@ "inactive": true, "legacyFolderID": "F9901df91a94d83585f713470bb9a3565578eaef2174f44efa7ee792a9847271f", "searchTerms": [], + "slug": "14-brightmemory2omni-old", "title": "Level 14 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '14'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '14'" }, { "game": "ongeki", @@ -31589,9 +34817,10 @@ "inactive": false, "legacyFolderID": "Ff6b3db87a9ffee0fbf15a7c4e60c92382766f1a332896e7f625c6dfa3c6b883c", "searchTerms": [], + "slug": "14-brightmemory3omni", "title": "Level 14 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'" }, { "game": "ongeki", @@ -31599,9 +34828,10 @@ "inactive": true, "legacyFolderID": "Fe8686a32b2051c1a58acfd30da0ff4c28f4c41d5722886eee7f6250a1a5504da", "searchTerms": [], + "slug": "14-brightmemory3", "title": "Level 14 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'" }, { "game": "ongeki", @@ -31609,9 +34839,10 @@ "inactive": false, "legacyFolderID": "F3ec740b29bfa01dba1962dc3754b96cc5788544ccde91217671765ecb6dc42c8", "searchTerms": [], + "slug": "14-refreshomni", "title": "Level 14 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'" }, { "game": "ongeki", @@ -31619,9 +34850,10 @@ "inactive": false, "legacyFolderID": "Fdaed73b57b1148ab86196d301a7e9e68430f33c362cf17c1cea4a6d7a6bad313", "searchTerms": [], + "slug": "14-refresh", "title": "Level 14 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14'" }, { "game": "ongeki", @@ -31629,9 +34861,10 @@ "inactive": true, "legacyFolderID": "Ff19028611c4c016b22c1f17fe894f1fe92ca1386b48fe41d2ffe6c99561b824c", "searchTerms": [], + "slug": "14p-brightmemory2omni", "title": "Level 14+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'" }, { "game": "ongeki", @@ -31639,9 +34872,10 @@ "inactive": true, "legacyFolderID": "F7e21dff91f5e6d9dacd10ee8d1db152dc452c36b752920a283268af71bb228cb", "searchTerms": [], + "slug": "14p-brightmemory2omni-old", "title": "Level 14+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '14+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '14+'" }, { "game": "ongeki", @@ -31649,9 +34883,10 @@ "inactive": false, "legacyFolderID": "Fc0eab890500bd1620e9e8cec96fb1981541c7a610978a24e5cc5d87e28769c3a", "searchTerms": [], + "slug": "14p-brightmemory3omni", "title": "Level 14+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'" }, { "game": "ongeki", @@ -31659,9 +34894,10 @@ "inactive": true, "legacyFolderID": "Ff32f1712c3209b3c7d0da5d4928b8a909773b5c3eada589470410fe7e859b38e", "searchTerms": [], + "slug": "14p-brightmemory3", "title": "Level 14+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'" }, { "game": "ongeki", @@ -31669,9 +34905,10 @@ "inactive": false, "legacyFolderID": "F6a96eeb5acea0f446f057325665cdf3e66183183635cf9d46c446ba1344ddcb8", "searchTerms": [], + "slug": "14p-refreshomni", "title": "Level 14+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'" }, { "game": "ongeki", @@ -31679,9 +34916,10 @@ "inactive": false, "legacyFolderID": "Fb6164fc4bf400f691c290e06fb6780046461a105ada1d2f6b15ce53bdb6a98ae", "searchTerms": [], + "slug": "14p-refresh", "title": "Level 14+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '14+'" }, { "game": "ongeki", @@ -31689,9 +34927,10 @@ "inactive": true, "legacyFolderID": "F5339eb160ea0ecb785504267339bf2593d45f5680a12bd385c9c9f8a4ecdbb21", "searchTerms": [], + "slug": "15-brightmemory2omni", "title": "Level 15 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'" }, { "game": "ongeki", @@ -31699,9 +34938,10 @@ "inactive": true, "legacyFolderID": "Fde49c21512ee303bfb8dcd84db6e4546a648691106b74b5d5a30c54f995706c9", "searchTerms": [], + "slug": "15-brightmemory2omni-old", "title": "Level 15 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '15'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '15'" }, { "game": "ongeki", @@ -31709,9 +34949,10 @@ "inactive": false, "legacyFolderID": "F0749bb75febf7fec29660011800cf23d3dd6fcb78f8393c7b33df2e3b32349e7", "searchTerms": [], + "slug": "15-brightmemory3omni", "title": "Level 15 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'" }, { "game": "ongeki", @@ -31719,9 +34960,10 @@ "inactive": true, "legacyFolderID": "Fc03828d1947ce99ee1c8f8332c773797d9e5c611e549518bcf16f5bc6738e459", "searchTerms": [], + "slug": "15-brightmemory3", "title": "Level 15 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'" }, { "game": "ongeki", @@ -31729,9 +34971,10 @@ "inactive": false, "legacyFolderID": "F58b522c9c7858ddd194375e2b87ee44b396560a04a3732e17b4a9f959edeb5e5", "searchTerms": [], + "slug": "15-refreshomni", "title": "Level 15 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'" }, { "game": "ongeki", @@ -31739,9 +34982,10 @@ "inactive": false, "legacyFolderID": "F726a8bbdcacd7936192b90fdb79400b99ee444eb8ce37fdd6122826e1f9ab582", "searchTerms": [], + "slug": "15-refresh", "title": "Level 15 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15'" }, { "game": "ongeki", @@ -31749,9 +34993,10 @@ "inactive": true, "legacyFolderID": "F9aeebd014e88080ea4a7d7c2f47bee2a7444471e34c6446f3dbbd70ba1872c78", "searchTerms": [], + "slug": "15p-brightmemory2omni", "title": "Level 15+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'" }, { "game": "ongeki", @@ -31759,9 +35004,10 @@ "inactive": true, "legacyFolderID": "F80a36fbb268bdf8d7e8293149192de4ed245d22a809b9442db1faa434b0160ac", "searchTerms": [], + "slug": "15p-brightmemory2omni-old", "title": "Level 15+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '15+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '15+'" }, { "game": "ongeki", @@ -31769,9 +35015,10 @@ "inactive": false, "legacyFolderID": "F13726e32d4050d17bf7d9084398767e47b52a4f81529d1b63f54da0ad21d3a9a", "searchTerms": [], + "slug": "15p-brightmemory3omni", "title": "Level 15+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'" }, { "game": "ongeki", @@ -31779,9 +35026,10 @@ "inactive": true, "legacyFolderID": "F86294efc958b008dd48c16739a7024dc36820a2d7bd54c3a6d6588460eedb960", "searchTerms": [], + "slug": "15p-brightmemory3", "title": "Level 15+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'" }, { "game": "ongeki", @@ -31789,9 +35037,10 @@ "inactive": false, "legacyFolderID": "F8fa29b4aadc6248d20fbf805eff3634a570b02e7caf6781e87d59a0b391b5771", "searchTerms": [], + "slug": "15p-refreshomni", "title": "Level 15+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'" }, { "game": "ongeki", @@ -31799,9 +35048,10 @@ "inactive": false, "legacyFolderID": "Fef8139461c072d28b5261418f1487f1983cdac913fff218a4b395bc2054f9116", "searchTerms": [], + "slug": "15p-refresh", "title": "Level 15+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '15+'" }, { "game": "ongeki", @@ -31809,9 +35059,10 @@ "inactive": true, "legacyFolderID": "F4133e9af2418c256c37b74295a36e89e632077380643f1a6c89693341a500d78", "searchTerms": [], + "slug": "2-brightmemory2omni", "title": "Level 2 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'" }, { "game": "ongeki", @@ -31819,9 +35070,10 @@ "inactive": true, "legacyFolderID": "F6bb31ecb1546577581915363ba25492d50016e3c0b390dfc2535cf743982b957", "searchTerms": [], + "slug": "2-brightmemory2omni-old", "title": "Level 2 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '2'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '2'" }, { "game": "ongeki", @@ -31829,9 +35081,10 @@ "inactive": false, "legacyFolderID": "F39279dde79ba740b67a5baf2ca462afbce97497257eb49e046007bda9635f15e", "searchTerms": [], + "slug": "2-brightmemory3omni", "title": "Level 2 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'" }, { "game": "ongeki", @@ -31839,9 +35092,10 @@ "inactive": true, "legacyFolderID": "F8944f057f0eee2ef521db8194f09128acf115750ff34aabe768d14f2e8ed6b12", "searchTerms": [], + "slug": "2-brightmemory3", "title": "Level 2 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'" }, { "game": "ongeki", @@ -31849,9 +35103,10 @@ "inactive": false, "legacyFolderID": "F7d798153e4f69dbb8d73015ce78318287cda1d8386bb58368bbb43f0325bd912", "searchTerms": [], + "slug": "2-refreshomni", "title": "Level 2 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'" }, { "game": "ongeki", @@ -31859,9 +35114,10 @@ "inactive": false, "legacyFolderID": "F97e8b43aad0c3a62110b09bf723044d88743c2eb44069b43ffc7164cf0d49676", "searchTerms": [], + "slug": "2-refresh", "title": "Level 2 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '2'" }, { "game": "ongeki", @@ -31869,9 +35125,10 @@ "inactive": true, "legacyFolderID": "F2ba2ee87348815e1e5ff2bc41a0075b575538b6536bff28e5226f95c7c27717c", "searchTerms": [], + "slug": "3-brightmemory2omni", "title": "Level 3 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'" }, { "game": "ongeki", @@ -31879,9 +35136,10 @@ "inactive": true, "legacyFolderID": "Fec51c5b12461b198bd3baaf2465bc97ccf70c9b1d8e8ea3b922cfee59a457580", "searchTerms": [], + "slug": "3-brightmemory2omni-old", "title": "Level 3 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '3'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '3'" }, { "game": "ongeki", @@ -31889,9 +35147,10 @@ "inactive": false, "legacyFolderID": "Fb5d274f6ece57acf5f0c0247a91cd51d89cde7f1c98ada2d4a348aaa696b46d4", "searchTerms": [], + "slug": "3-brightmemory3omni", "title": "Level 3 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'" }, { "game": "ongeki", @@ -31899,9 +35158,10 @@ "inactive": true, "legacyFolderID": "F0ff7fa955deeb305461cc234141b967ce2ccd25def5d4008c1c2746861a0844e", "searchTerms": [], + "slug": "3-brightmemory3", "title": "Level 3 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'" }, { "game": "ongeki", @@ -31909,9 +35169,10 @@ "inactive": false, "legacyFolderID": "F235bb603eb18252b492f1832ee514b8f9486afcdd77e3812b35364421cb24908", "searchTerms": [], + "slug": "3-refreshomni", "title": "Level 3 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'" }, { "game": "ongeki", @@ -31919,9 +35180,10 @@ "inactive": false, "legacyFolderID": "F8d1c16529d7f38f214556d21ff392e9f2b6c478382ebdf6418db2baf6743b79e", "searchTerms": [], + "slug": "3-refresh", "title": "Level 3 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '3'" }, { "game": "ongeki", @@ -31929,9 +35191,10 @@ "inactive": true, "legacyFolderID": "F8e0abb58c7b8e2f299f95f0db28771626883166543146e82af7de8ebc0328757", "searchTerms": [], + "slug": "4-brightmemory2omni", "title": "Level 4 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'" }, { "game": "ongeki", @@ -31939,9 +35202,10 @@ "inactive": true, "legacyFolderID": "Fb075e2722e3eb427bdee6c568f1058daa7120ee03038d08281d2e892994861c0", "searchTerms": [], + "slug": "4-brightmemory2omni-old", "title": "Level 4 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '4'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '4'" }, { "game": "ongeki", @@ -31949,9 +35213,10 @@ "inactive": false, "legacyFolderID": "Fda26f82b1e60ff6df8398c837bd8c7921456c10114c332f984d10fa1f29a2c88", "searchTerms": [], + "slug": "4-brightmemory3omni", "title": "Level 4 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'" }, { "game": "ongeki", @@ -31959,9 +35224,10 @@ "inactive": true, "legacyFolderID": "Fe6199786609825fa50b5a84aa72a16c56bdf4318d0051f8a9faabae70a57aa7d", "searchTerms": [], + "slug": "4-brightmemory3", "title": "Level 4 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'" }, { "game": "ongeki", @@ -31969,9 +35235,10 @@ "inactive": false, "legacyFolderID": "F1a99521b9b71c5a1dd9e7305a911d2200a0e92585c700127e24bcafafeeae557", "searchTerms": [], + "slug": "4-refreshomni", "title": "Level 4 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'" }, { "game": "ongeki", @@ -31979,9 +35246,10 @@ "inactive": false, "legacyFolderID": "F6de5de1517ac2cf6ee05d229d9e9db719167acf3837dcb8b93cb58d3e8fd7480", "searchTerms": [], + "slug": "4-refresh", "title": "Level 4 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '4'" }, { "game": "ongeki", @@ -31989,9 +35257,10 @@ "inactive": true, "legacyFolderID": "F783a66a755afdb5e61187423a2292c6a3370ba248ca163c79a5c60dcff5c80a2", "searchTerms": [], + "slug": "5-brightmemory2omni", "title": "Level 5 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'" }, { "game": "ongeki", @@ -31999,9 +35268,10 @@ "inactive": true, "legacyFolderID": "F463f37929b6643fb22d3195fc2ddc920f71bd0878a8ea5ee2aadb266c93eb0e7", "searchTerms": [], + "slug": "5-brightmemory2omni-old", "title": "Level 5 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '5'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '5'" }, { "game": "ongeki", @@ -32009,9 +35279,10 @@ "inactive": false, "legacyFolderID": "F4f06197c3542575c84b2baab00c6fd3cc75a8d067bd6454c33a4eaa26976a399", "searchTerms": [], + "slug": "5-brightmemory3omni", "title": "Level 5 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'" }, { "game": "ongeki", @@ -32019,9 +35290,10 @@ "inactive": true, "legacyFolderID": "F6e5fd9dc8ab6d1278e4215d202365c4d110ea45628e7b65b122412f5e19bd8fd", "searchTerms": [], + "slug": "5-brightmemory3", "title": "Level 5 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'" }, { "game": "ongeki", @@ -32029,9 +35301,10 @@ "inactive": false, "legacyFolderID": "F0df2faf8990e125c2fb5a4a0152f03a08553dcdd8964e4bad9f323d9b3aacb00", "searchTerms": [], + "slug": "5-refreshomni", "title": "Level 5 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'" }, { "game": "ongeki", @@ -32039,9 +35312,10 @@ "inactive": false, "legacyFolderID": "Ffe603e48a940ab8552b87806ae94acaa4c143d78f7f828d9e6afc5313fafe698", "searchTerms": [], + "slug": "5-refresh", "title": "Level 5 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '5'" }, { "game": "ongeki", @@ -32049,9 +35323,10 @@ "inactive": true, "legacyFolderID": "Fe11c141fe53a8edc103a9748596af576adcad46bf82c84db96a0c6233546178f", "searchTerms": [], + "slug": "6-brightmemory2omni", "title": "Level 6 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'" }, { "game": "ongeki", @@ -32059,9 +35334,10 @@ "inactive": true, "legacyFolderID": "F4ee039b745af346c1f9ad5502f6ad7e0b1547e8204ada7455ba9c7095454ea66", "searchTerms": [], + "slug": "6-brightmemory2omni-old", "title": "Level 6 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '6'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '6'" }, { "game": "ongeki", @@ -32069,9 +35345,10 @@ "inactive": false, "legacyFolderID": "F0793a12545311857263af1522460803b7265ca1661b02642f251f35dcfe59d90", "searchTerms": [], + "slug": "6-brightmemory3omni", "title": "Level 6 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'" }, { "game": "ongeki", @@ -32079,9 +35356,10 @@ "inactive": true, "legacyFolderID": "F5ab3033bf5332a795a96e35b0ba91cf19892fb11034bc557a964ea4d4d2c4e63", "searchTerms": [], + "slug": "6-brightmemory3", "title": "Level 6 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'" }, { "game": "ongeki", @@ -32089,9 +35367,10 @@ "inactive": false, "legacyFolderID": "F38f9ef2100458ae414d19b9b30173e3add874cd5878e306cf1f7262745823187", "searchTerms": [], + "slug": "6-refreshomni", "title": "Level 6 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'" }, { "game": "ongeki", @@ -32099,9 +35378,10 @@ "inactive": false, "legacyFolderID": "F2141400b79e91a3dbb7183f3c5b35e92484492ede65b2ddc767d5d8d088f5984", "searchTerms": [], + "slug": "6-refresh", "title": "Level 6 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '6'" }, { "game": "ongeki", @@ -32109,9 +35389,10 @@ "inactive": true, "legacyFolderID": "Fd5e9fac8890d395ff278d25d29ff219b47c406cb55a67d678fd40e486fd33cc4", "searchTerms": [], + "slug": "7-brightmemory2omni", "title": "Level 7 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'" }, { "game": "ongeki", @@ -32119,9 +35400,10 @@ "inactive": true, "legacyFolderID": "F2bbf55e9d6a9749ffcc04a661c097928008722a2087e575666e22c453d637c7f", "searchTerms": [], + "slug": "7-brightmemory2omni-old", "title": "Level 7 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '7'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '7'" }, { "game": "ongeki", @@ -32129,9 +35411,10 @@ "inactive": false, "legacyFolderID": "Fd18bc3bb93412949eea6e975152ccf9abc7122a4c9971bd1c84a3f3ffc8a835d", "searchTerms": [], + "slug": "7-brightmemory3omni", "title": "Level 7 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'" }, { "game": "ongeki", @@ -32139,9 +35422,10 @@ "inactive": true, "legacyFolderID": "Fe13967a8ac33ee342c5e09048fb2b3fb536949cb7722413f57b11e18e57d1ccd", "searchTerms": [], + "slug": "7-brightmemory3", "title": "Level 7 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'" }, { "game": "ongeki", @@ -32149,9 +35433,10 @@ "inactive": false, "legacyFolderID": "F5bc76d43c23b60567e779ce96f1bae4cd68783c715f0fcc1915be7c905b95cdc", "searchTerms": [], + "slug": "7-refreshomni", "title": "Level 7 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'" }, { "game": "ongeki", @@ -32159,9 +35444,10 @@ "inactive": false, "legacyFolderID": "Fc29571054ff042500576479a66dc1620463a7f65100955171aa5b6a7386a5aa3", "searchTerms": [], + "slug": "7-refresh", "title": "Level 7 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7'" }, { "game": "ongeki", @@ -32169,9 +35455,10 @@ "inactive": true, "legacyFolderID": "Fc09e17c640bfed5fd483b56a3c61cc900666c22ef428905e2bd86af97f131c55", "searchTerms": [], + "slug": "7p-brightmemory2omni", "title": "Level 7+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'" }, { "game": "ongeki", @@ -32179,9 +35466,10 @@ "inactive": true, "legacyFolderID": "Fbee9632921aaae5ddf87fc4372392e8cf5f9649b6d2c5e831bf76d597ef0969a", "searchTerms": [], + "slug": "7p-brightmemory2omni-old", "title": "Level 7+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '7+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '7+'" }, { "game": "ongeki", @@ -32189,9 +35477,10 @@ "inactive": false, "legacyFolderID": "Fb93160d227e5468d0f287c875e594302a08e4c4b827c21130bd879c538a2ea97", "searchTerms": [], + "slug": "7p-brightmemory3omni", "title": "Level 7+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'" }, { "game": "ongeki", @@ -32199,9 +35488,10 @@ "inactive": true, "legacyFolderID": "F5dbf3709559338cbdce4b055435cf2b64b87e5e9a9befc4fa65d71d4965b4e05", "searchTerms": [], + "slug": "7p-brightmemory3", "title": "Level 7+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'" }, { "game": "ongeki", @@ -32209,9 +35499,10 @@ "inactive": false, "legacyFolderID": "Ff664d4785d2adec8607a6df9198e09181c81dfeffee23f70e44b88d2b73bd61b", "searchTerms": [], + "slug": "7p-refreshomni", "title": "Level 7+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'" }, { "game": "ongeki", @@ -32219,9 +35510,10 @@ "inactive": false, "legacyFolderID": "F4dc79ff218752a3f1f6aa51221056d7bd49a84d499310aaf069e916fe33848ad", "searchTerms": [], + "slug": "7p-refresh", "title": "Level 7+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '7+'" }, { "game": "ongeki", @@ -32229,9 +35521,10 @@ "inactive": true, "legacyFolderID": "F8f9f2fb62a09bbf93a6de13a13086f916ae89065a5ed1e1e5d24651b267083f3", "searchTerms": [], + "slug": "8-brightmemory2omni", "title": "Level 8 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'" }, { "game": "ongeki", @@ -32239,9 +35532,10 @@ "inactive": true, "legacyFolderID": "F7adcb7d81d0f626300c9a64a50fe940b3038308443eadd3a8fd4a49d984d0525", "searchTerms": [], + "slug": "8-brightmemory2omni-old", "title": "Level 8 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '8'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '8'" }, { "game": "ongeki", @@ -32249,9 +35543,10 @@ "inactive": false, "legacyFolderID": "F27bec374e201e69d4419498330961868e62e56651333b6ddffd1772cd64401e9", "searchTerms": [], + "slug": "8-brightmemory3omni", "title": "Level 8 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'" }, { "game": "ongeki", @@ -32259,9 +35554,10 @@ "inactive": true, "legacyFolderID": "Fb94d23d5dc158363ef53d2fbc2034fefed6a9a7ebbeeabcbfa9b318904515b9c", "searchTerms": [], + "slug": "8-brightmemory3", "title": "Level 8 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'" }, { "game": "ongeki", @@ -32269,9 +35565,10 @@ "inactive": false, "legacyFolderID": "Fb2bb451e447cccd32f09657620c86883db8cdc46420f2dae7891c47cb86a800e", "searchTerms": [], + "slug": "8-refreshomni", "title": "Level 8 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'" }, { "game": "ongeki", @@ -32279,9 +35576,10 @@ "inactive": false, "legacyFolderID": "F92734fd0505344aaeca3a329fe7c228107eb5dc59ab92b7a45fb6db21e263003", "searchTerms": [], + "slug": "8-refresh", "title": "Level 8 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8'" }, { "game": "ongeki", @@ -32289,9 +35587,10 @@ "inactive": true, "legacyFolderID": "F5eb442a5bfe703adcf5115c3a9fc3529b9d294b692a10e69a74776499ccc6d78", "searchTerms": [], + "slug": "8p-brightmemory2omni", "title": "Level 8+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'" }, { "game": "ongeki", @@ -32299,9 +35598,10 @@ "inactive": true, "legacyFolderID": "Fcec71331f03ae6c446809e48927dcb3b98b4306dc5fd082b5ae8c2bb6c67e182", "searchTerms": [], + "slug": "8p-brightmemory2omni-old", "title": "Level 8+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '8+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '8+'" }, { "game": "ongeki", @@ -32309,9 +35609,10 @@ "inactive": false, "legacyFolderID": "Fb390d08099d263303f85d1b6f02ae0798ba3e4e3348482e65fab32e6a32ff996", "searchTerms": [], + "slug": "8p-brightmemory3omni", "title": "Level 8+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'" }, { "game": "ongeki", @@ -32319,9 +35620,10 @@ "inactive": true, "legacyFolderID": "Ff2db63c5482aa620bdab5eaea332fbe0388bfeea94d19072639bb87f05cd1f81", "searchTerms": [], + "slug": "8p-brightmemory3", "title": "Level 8+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'" }, { "game": "ongeki", @@ -32329,9 +35631,10 @@ "inactive": false, "legacyFolderID": "Fc1a625eca8c6df9c318fd45a97105f2b9565b1a3a430e2191a3d505bb42d0736", "searchTerms": [], + "slug": "8p-refreshomni", "title": "Level 8+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'" }, { "game": "ongeki", @@ -32339,9 +35642,10 @@ "inactive": false, "legacyFolderID": "F2d299c8bf66e5e4f0666765984d4aa069ab844ccedba5e51f13ac449c846c399", "searchTerms": [], + "slug": "8p-refresh", "title": "Level 8+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '8+'" }, { "game": "ongeki", @@ -32349,9 +35653,10 @@ "inactive": true, "legacyFolderID": "Ff0c9c41bacf850d21e569f75fe17f5f938cd4af67113861022d2d78cb83626f9", "searchTerms": [], + "slug": "9-brightmemory2omni", "title": "Level 9 (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'" }, { "game": "ongeki", @@ -32359,9 +35664,10 @@ "inactive": true, "legacyFolderID": "F4f63ccf6e174bef78b4c0d5eee11593fb42f823ad07def8b65130c2401f4251e", "searchTerms": [], + "slug": "9-brightmemory2omni-old", "title": "Level 9 (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '9'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '9'" }, { "game": "ongeki", @@ -32369,9 +35675,10 @@ "inactive": false, "legacyFolderID": "F60255d538dbb5c30a18e2e3cef2500aac985794b73dfaa428b805264bd567b01", "searchTerms": [], + "slug": "9-brightmemory3omni", "title": "Level 9 (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'" }, { "game": "ongeki", @@ -32379,9 +35686,10 @@ "inactive": true, "legacyFolderID": "F99cf5bf0296feb4e726926177affbede0964e79b6f8f9db4e72667a95bf2a7e1", "searchTerms": [], + "slug": "9-brightmemory3", "title": "Level 9 (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'" }, { "game": "ongeki", @@ -32389,9 +35697,10 @@ "inactive": false, "legacyFolderID": "F06660ee6a2e0cc55ed0a619ffdae9ffde5edfe2d970f9da94f331a394f8797d8", "searchTerms": [], + "slug": "9-refreshomni", "title": "Level 9 (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'" }, { "game": "ongeki", @@ -32399,9 +35708,10 @@ "inactive": false, "legacyFolderID": "F8aa7a85b829aaebbb3c7832dee28d7e7ebd72479f10bb18d6209574a3cd43752", "searchTerms": [], + "slug": "9-refresh", "title": "Level 9 (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9'" }, { "game": "ongeki", @@ -32409,9 +35719,10 @@ "inactive": true, "legacyFolderID": "Fa5418f5d2891ee9ff75dff0661fcb2976faa10b41f282f0643a301b7cb36731d", "searchTerms": [], + "slug": "9p-brightmemory2omni", "title": "Level 9+ (bright MEMORY Act.2 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'" }, { "game": "ongeki", @@ -32419,9 +35730,10 @@ "inactive": true, "legacyFolderID": "F1337d8a3b3f813270d691cc0b0dfeba3168c7b1a8d20df477d0ed4e2b32ad8a8", "searchTerms": [], + "slug": "9p-brightmemory2omni-old", "title": "Level 9+ (bright MEMORY Act.2 Omnimix) (Old)", - "where": "chart.level = '9+'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.level = '9+'" }, { "game": "ongeki", @@ -32429,9 +35741,10 @@ "inactive": false, "legacyFolderID": "F634a6d47adc06b89d1232abdccc6f4d9f95b50d465fc83d2b86e26b5ecfe3408", "searchTerms": [], + "slug": "9p-brightmemory3omni", "title": "Level 9+ (bright MEMORY Act.3 Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'" }, { "game": "ongeki", @@ -32439,9 +35752,10 @@ "inactive": true, "legacyFolderID": "F213d8669e814fc8ba15e5c109407265aafb93b134e1487980493ec559ab0056d", "searchTerms": [], + "slug": "9p-brightmemory3", "title": "Level 9+ (bright MEMORY Act.3)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'" }, { "game": "ongeki", @@ -32449,9 +35763,10 @@ "inactive": false, "legacyFolderID": "F3fbc01fda64baeb4ca08294e7e1d9717b5566a9bcc425388b940aad0e6c2f00b", "searchTerms": [], + "slug": "9p-refreshomni", "title": "Level 9+ (Re:Fresh Omnimix)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'" }, { "game": "ongeki", @@ -32459,9 +35774,10 @@ "inactive": false, "legacyFolderID": "Fd6ea6197839c9af4d62b58f3554fb07b3230586cc3da9c9e746c8f6046a7a1e6", "searchTerms": [], + "slug": "9p-refresh", "title": "Level 9+ (Re:Fresh)", - "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "NOT ((chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000) AND chart.level = '9+'" }, { "game": "ongeki", @@ -32469,9 +35785,10 @@ "inactive": true, "legacyFolderID": "F7df57e1be3f591b417fe94e10cd157d251b9780325a9812f9e7b1ed3ea207080", "searchTerms": [], + "slug": "brightmemory2omni-lunatic", "title": "LUNATIC (bright MEMORY Act.2 Omnimix)", - "where": "chart.difficulty = 'LUNATIC'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "chart.difficulty = 'LUNATIC'" }, { "game": "ongeki", @@ -32479,9 +35796,10 @@ "inactive": false, "legacyFolderID": "F32de08ef7420094b8b1c9719858afb6d5176bc97f17b60827c11ce2a3a5af83b", "searchTerms": [], + "slug": "brightmemory3omni-lunatic", "title": "LUNATIC (bright MEMORY Act.3 Omnimix)", - "where": "chart.data->>'isReMaster' = 'false' AND chart.difficulty = 'LUNATIC'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "chart.data->>'isReMaster' = 'false' AND chart.difficulty = 'LUNATIC'" }, { "game": "ongeki", @@ -32489,9 +35807,10 @@ "inactive": true, "legacyFolderID": "F095c535be64825c713bebc26d34a0ac8449d487bee1fed3f7df37c16f30b37e4", "searchTerms": [], + "slug": "brightmemory3-lunatic", "title": "LUNATIC (bright MEMORY Act.3)", - "where": "chart.data->>'isReMaster' = 'false' AND chart.difficulty = 'LUNATIC'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "chart.data->>'isReMaster' = 'false' AND chart.difficulty = 'LUNATIC'" }, { "game": "ongeki", @@ -32499,9 +35818,10 @@ "inactive": false, "legacyFolderID": "F2d15f52de88e1ecfefb23a5c245aa47166b8717262c09750e952978df1416e5d", "searchTerms": [], + "slug": "refreshomni-lunatic", "title": "LUNATIC (Re:Fresh Omnimix)", - "where": "chart.data->>'isReMaster' = 'false' AND chart.difficulty = 'LUNATIC'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "chart.data->>'isReMaster' = 'false' AND chart.difficulty = 'LUNATIC'" }, { "game": "ongeki", @@ -32509,9 +35829,10 @@ "inactive": false, "legacyFolderID": "F2de5fd8f784e94c6e2c60c5960b1306aadb76c54ce9b502421813cfa66532a36", "searchTerms": [], + "slug": "refresh-lunatic", "title": "LUNATIC (Re:Fresh)", - "where": "chart.data->>'isReMaster' = 'false' AND chart.difficulty = 'LUNATIC'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "chart.data->>'isReMaster' = 'false' AND chart.difficulty = 'LUNATIC'" }, { "game": "ongeki", @@ -32519,6 +35840,7 @@ "inactive": false, "legacyFolderID": "F9d16ef4904c44f91ae78d8c71c7b1ddf19e8706006fb93e80be277bc97bdae1e", "searchTerms": [], + "slug": "lunatic-only-songs", "title": "LUNATIC-only songs", "where": "song.data->>'genre' = 'LUNATIC'" }, @@ -32528,9 +35850,10 @@ "inactive": true, "legacyFolderID": "F00ae78d6354d5f27e55943c22f09e81b024a35e88c651dad7bc65cdbb21a9362", "searchTerms": [], + "slug": "brightmemory2omni-master", "title": "MASTER (bright MEMORY Act.2 Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'", - "versionFilter": ["brightMemory2Omni"] + "versionFilter": ["brightMemory2Omni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'" }, { "game": "ongeki", @@ -32538,9 +35861,10 @@ "inactive": false, "legacyFolderID": "F52d744e85dd5d03c08ac9bdb4d4e412ac41159916cbc3cb924f4aded30c830ed", "searchTerms": [], + "slug": "brightmemory3omni-master", "title": "MASTER (bright MEMORY Act.3 Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'" }, { "game": "ongeki", @@ -32548,9 +35872,10 @@ "inactive": true, "legacyFolderID": "F429c5805303941ee8be692b54d5932f37675da4cf06a1e7d00d7ea59c7db3f6e", "searchTerms": [], + "slug": "brightmemory3-master", "title": "MASTER (bright MEMORY Act.3)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'" }, { "game": "ongeki", @@ -32558,9 +35883,10 @@ "inactive": false, "legacyFolderID": "Fe1c195cb0076220594ffc04a8dd94bcd903a2e2f9ed59d8ccd7b194bf37d0543", "searchTerms": [], + "slug": "refreshomni-master", "title": "MASTER (Re:Fresh Omnimix)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'" }, { "game": "ongeki", @@ -32568,9 +35894,10 @@ "inactive": false, "legacyFolderID": "Fe4a9d5778b692ee1e73acaa424da2f996a0b0939f46f346e16c2adc69a896453", "searchTerms": [], + "slug": "refresh-master", "title": "MASTER (Re:Fresh)", - "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "(chart.data->>'inGameID')::numeric < 7000 AND chart.difficulty = 'MASTER'" }, { "game": "ongeki", @@ -32578,9 +35905,10 @@ "inactive": false, "legacyFolderID": "F20d1e739fd77625944c1b7985e9f06a8e81baa3d9b29cbcdf3b2eb3689d79189", "searchTerms": [], + "slug": "brightmemory3omni-lunatic-remaster", "title": "Re:MASTER (bright MEMORY Act.3 Omnimix)", - "where": "chart.data->>'isReMaster' = 'true' AND chart.difficulty = 'LUNATIC'", - "versionFilter": ["brightMemory3Omni"] + "versionFilter": ["brightMemory3Omni"], + "where": "chart.data->>'isReMaster' = 'true' AND chart.difficulty = 'LUNATIC'" }, { "game": "ongeki", @@ -32588,9 +35916,10 @@ "inactive": true, "legacyFolderID": "Fddd5da8078803f869db6dc4e92220c3291a1163f25f375d32525160aed09dc8e", "searchTerms": [], + "slug": "brightmemory3-lunatic-remaster", "title": "Re:MASTER (bright MEMORY Act.3)", - "where": "chart.data->>'isReMaster' = 'true' AND chart.difficulty = 'LUNATIC'", - "versionFilter": ["brightMemory3"] + "versionFilter": ["brightMemory3"], + "where": "chart.data->>'isReMaster' = 'true' AND chart.difficulty = 'LUNATIC'" }, { "game": "ongeki", @@ -32598,9 +35927,10 @@ "inactive": false, "legacyFolderID": "F543fcc76da5eb064c53309bc6170b9ae8fe0fc34ada85cb358d56282a3ea326b", "searchTerms": [], + "slug": "refreshomni-lunatic-remaster", "title": "Re:MASTER (Re:Fresh Omnimix)", - "where": "chart.data->>'isReMaster' = 'true' AND chart.difficulty = 'LUNATIC'", - "versionFilter": ["refreshOmni"] + "versionFilter": ["refreshOmni"], + "where": "chart.data->>'isReMaster' = 'true' AND chart.difficulty = 'LUNATIC'" }, { "game": "ongeki", @@ -32608,9 +35938,10 @@ "inactive": false, "legacyFolderID": "F591906452a12c984f9a5e436543802e493d15782b678b518dbeba3705720c77c", "searchTerms": [], + "slug": "refresh-lunatic-remaster", "title": "Re:MASTER (Re:Fresh)", - "where": "chart.data->>'isReMaster' = 'true' AND chart.difficulty = 'LUNATIC'", - "versionFilter": ["refresh"] + "versionFilter": ["refresh"], + "where": "chart.data->>'isReMaster' = 'true' AND chart.difficulty = 'LUNATIC'" }, { "game": "pms-controller", @@ -32618,6 +35949,7 @@ "inactive": false, "legacyFolderID": "F096ee79889eee5218c27f7122a93c3a3875b2560315849b3fd5dca4f4fc1c1ff", "searchTerms": [], + "slug": "tf-pmscircle-excl-i", "title": "●!i", "where": "(chart.data->'tableFolders'->>'●') = '!i'" }, @@ -32627,6 +35959,7 @@ "inactive": false, "legacyFolderID": "F09b9055d424d2da2e17496bac71c72e50ba742b2266a29d0f89cf531ad58f1d2", "searchTerms": [], + "slug": "tf-pmscircle-1", "title": "●1", "where": "(chart.data->'tableFolders'->>'●') = '1'" }, @@ -32636,6 +35969,7 @@ "inactive": false, "legacyFolderID": "F37e6cb503945ea0ca1c125b1fabec87f3836a4d4b52d9da64da9a8d700172786", "searchTerms": [], + "slug": "tf-pmscircle-1-0", "title": "●10", "where": "(chart.data->'tableFolders'->>'●') = '10'" }, @@ -32645,6 +35979,7 @@ "inactive": false, "legacyFolderID": "F9c1cd7ad49e78a78fe1020a102774d2dc7f2e818a429b20365918a07942e439f", "searchTerms": [], + "slug": "tf-pmscircle-1-1", "title": "●11", "where": "(chart.data->'tableFolders'->>'●') = '11'" }, @@ -32654,6 +35989,7 @@ "inactive": false, "legacyFolderID": "F4d850f5a73bf0711f99ee404b29ddbf184f5ac88b8019370ab8b6269743e52d9", "searchTerms": [], + "slug": "tf-pmscircle-1-2", "title": "●12", "where": "(chart.data->'tableFolders'->>'●') = '12'" }, @@ -32663,6 +35999,7 @@ "inactive": false, "legacyFolderID": "F4e51325c7feffadd64e50f8e206b43915368f6ef6951f04ecb184e80af109a16", "searchTerms": [], + "slug": "tf-pmscircle-1-3", "title": "●13", "where": "(chart.data->'tableFolders'->>'●') = '13'" }, @@ -32672,6 +36009,7 @@ "inactive": false, "legacyFolderID": "Fc4d0b23b31a8ae0493be592d640048ac4b7f4136c3e8619778569bc08cc6f7bf", "searchTerms": [], + "slug": "tf-pmscircle-1-4", "title": "●14", "where": "(chart.data->'tableFolders'->>'●') = '14'" }, @@ -32681,6 +36019,7 @@ "inactive": false, "legacyFolderID": "F2199a31f8d23275da85b08caa177a7d5af322e2f6b4f99b159e9eaeca450434c", "searchTerms": [], + "slug": "tf-pmscircle-1-5", "title": "●15", "where": "(chart.data->'tableFolders'->>'●') = '15'" }, @@ -32690,6 +36029,7 @@ "inactive": false, "legacyFolderID": "F861fe1dcaf39e871fd9e2d761d365934f66212f935cd06356be5f3ee86359e8e", "searchTerms": [], + "slug": "tf-pmscircle-1-6", "title": "●16", "where": "(chart.data->'tableFolders'->>'●') = '16'" }, @@ -32699,6 +36039,7 @@ "inactive": false, "legacyFolderID": "Fb7c07fcbe42e35ffb460e3b3585ad0d9b5515287b55ca201a07c23fc44dbc6b4", "searchTerms": [], + "slug": "tf-pmscircle-1-7", "title": "●17", "where": "(chart.data->'tableFolders'->>'●') = '17'" }, @@ -32708,6 +36049,7 @@ "inactive": false, "legacyFolderID": "F88f478632bef692d8260503524efcda8cffb0178cf3843702ab35b3773e31f3d", "searchTerms": [], + "slug": "tf-pmscircle-1-8", "title": "●18", "where": "(chart.data->'tableFolders'->>'●') = '18'" }, @@ -32717,6 +36059,7 @@ "inactive": false, "legacyFolderID": "F22cdb614965c7f06977b6750f37848dd38d615313cbfe30142350932e273d41b", "searchTerms": [], + "slug": "tf-pmscircle-1-9", "title": "●19", "where": "(chart.data->'tableFolders'->>'●') = '19'" }, @@ -32726,6 +36069,7 @@ "inactive": false, "legacyFolderID": "Ffbcfed800b1ba6d6aae27433f19711e03d3a78cf5421a47d0338a59735928bce", "searchTerms": [], + "slug": "tf-pmscircle-2", "title": "●2", "where": "(chart.data->'tableFolders'->>'●') = '2'" }, @@ -32735,6 +36079,7 @@ "inactive": false, "legacyFolderID": "F4815468f1345407acee2ef95bdeb54f5a116505fe313e608d3aa6734f4930952", "searchTerms": [], + "slug": "tf-pmscircle-3", "title": "●3", "where": "(chart.data->'tableFolders'->>'●') = '3'" }, @@ -32744,6 +36089,7 @@ "inactive": false, "legacyFolderID": "Fc9325e6a7e9f93c4b9bad34faedfd5e011c3bd05c30607da78fc3ca1879e5d3c", "searchTerms": [], + "slug": "tf-pmscircle-4", "title": "●4", "where": "(chart.data->'tableFolders'->>'●') = '4'" }, @@ -32753,6 +36099,7 @@ "inactive": false, "legacyFolderID": "Fbd2dd11574e11a5d2f81b3541eaeec24cf40f15604ec29e8b9ba68f1882dd8ed", "searchTerms": [], + "slug": "tf-pmscircle-5", "title": "●5", "where": "(chart.data->'tableFolders'->>'●') = '5'" }, @@ -32762,6 +36109,7 @@ "inactive": false, "legacyFolderID": "F7aa05e22c30aabf019098477658b36133396952912c205c4907e669e7a6ce94e", "searchTerms": [], + "slug": "tf-pmscircle-6", "title": "●6", "where": "(chart.data->'tableFolders'->>'●') = '6'" }, @@ -32771,6 +36119,7 @@ "inactive": false, "legacyFolderID": "Fedecab07a5f83d5e1cfe25be8132d74bdb349819dd677faf89caec473d69fb2c", "searchTerms": [], + "slug": "tf-pmscircle-7", "title": "●7", "where": "(chart.data->'tableFolders'->>'●') = '7'" }, @@ -32780,6 +36129,7 @@ "inactive": false, "legacyFolderID": "F5998e545324149baf8e217e642038e2319617c4623170d54be2237ef75563675", "searchTerms": [], + "slug": "tf-pmscircle-8", "title": "●8", "where": "(chart.data->'tableFolders'->>'●') = '8'" }, @@ -32789,6 +36139,7 @@ "inactive": false, "legacyFolderID": "F78eb773bf369c3d40cfcc290976117758973ba9245f1ee30f2c16fa84c8b88a3", "searchTerms": [], + "slug": "tf-pmscircle-9", "title": "●9", "where": "(chart.data->'tableFolders'->>'●') = '9'" }, @@ -32798,6 +36149,7 @@ "inactive": false, "legacyFolderID": "F1353d03e6c515f87efce95dcd2efd7127d983cd24abf60a0799030406e70ab37", "searchTerms": [], + "slug": "tf-pmspstar-excl-i", "title": "P●!i", "where": "(chart.data->'tableFolders'->>'P●') = '!i'" }, @@ -32807,6 +36159,7 @@ "inactive": false, "legacyFolderID": "Fb716409d54c239b59cd12adbff91cc2195f97b4974f3a77d492032892b4f422c", "searchTerms": [], + "slug": "tf-pmspstar-uff1f", "title": "P●?", "where": "(chart.data->'tableFolders'->>'P●') = '?'" }, @@ -32816,6 +36169,7 @@ "inactive": false, "legacyFolderID": "F523c03475e523fea62a390e0c4dbd9e0f0fb8fccf06ebcbffed00105e694bf3a", "searchTerms": [], + "slug": "tf-pmspstar-uff5e", "title": "P●~", "where": "(chart.data->'tableFolders'->>'P●') = '~'" }, @@ -32825,6 +36179,7 @@ "inactive": false, "legacyFolderID": "F1417879fc8af818c67026dcd741f46c176d6e687bba29448dfad06193e8b51de", "searchTerms": [], + "slug": "tf-pmspstar-u25c6", "title": "P●◆", "where": "(chart.data->'tableFolders'->>'P●') = '◆'" }, @@ -32834,6 +36189,7 @@ "inactive": false, "legacyFolderID": "F61ec7abc85fa130ee65fa7ac869897f3ae986ae3a4df5e461b48431ba0c89c69", "searchTerms": [], + "slug": "tf-pmspstar-1", "title": "P●1", "where": "(chart.data->'tableFolders'->>'P●') = '1'" }, @@ -32843,6 +36199,7 @@ "inactive": false, "legacyFolderID": "F3b94f34fb0de4c53b50f284a597e6ae71ccc0e3d61790998437707a546dbe902", "searchTerms": [], + "slug": "tf-pmspstar-1-0", "title": "P●10", "where": "(chart.data->'tableFolders'->>'P●') = '10'" }, @@ -32852,6 +36209,7 @@ "inactive": false, "legacyFolderID": "Fd989f900193b5e70ced5d54209a4b5bf43b4b5f31799a65d51cc9953a2da5fa7", "searchTerms": [], + "slug": "tf-pmspstar-1-1", "title": "P●11", "where": "(chart.data->'tableFolders'->>'P●') = '11'" }, @@ -32861,6 +36219,7 @@ "inactive": false, "legacyFolderID": "F62d24bcb3c53291e7b186d817c46381ae938fc182356b837319602384d0c805c", "searchTerms": [], + "slug": "tf-pmspstar-1-2", "title": "P●12", "where": "(chart.data->'tableFolders'->>'P●') = '12'" }, @@ -32870,6 +36229,7 @@ "inactive": false, "legacyFolderID": "F14265aa4313e9468fa89d387bd443b98ab51a29595b78573bc786a54dea526e8", "searchTerms": [], + "slug": "tf-pmspstar-1-3", "title": "P●13", "where": "(chart.data->'tableFolders'->>'P●') = '13'" }, @@ -32879,6 +36239,7 @@ "inactive": false, "legacyFolderID": "F1329002b3480c1f5341055c3f6bb656c96e392e8eef0e2a50e7647fefba9c215", "searchTerms": [], + "slug": "tf-pmspstar-1-4", "title": "P●14", "where": "(chart.data->'tableFolders'->>'P●') = '14'" }, @@ -32888,6 +36249,7 @@ "inactive": false, "legacyFolderID": "Fd71455c185b690b90fae50a5eff109058f60589dfef319d677d0e8a1614d6527", "searchTerms": [], + "slug": "tf-pmspstar-1-5", "title": "P●15", "where": "(chart.data->'tableFolders'->>'P●') = '15'" }, @@ -32897,6 +36259,7 @@ "inactive": false, "legacyFolderID": "Fade4036b6ceab2e4549a3a0a412c4c35482f61c9bca289efa95c3e651c8ce728", "searchTerms": [], + "slug": "tf-pmspstar-1-6", "title": "P●16", "where": "(chart.data->'tableFolders'->>'P●') = '16'" }, @@ -32906,6 +36269,7 @@ "inactive": false, "legacyFolderID": "Fc9bca91891a907fbaf926f8f7f9ba22058e7bd93586d1b834d453b269d422d54", "searchTerms": [], + "slug": "tf-pmspstar-1-7", "title": "P●17", "where": "(chart.data->'tableFolders'->>'P●') = '17'" }, @@ -32915,6 +36279,7 @@ "inactive": false, "legacyFolderID": "Fbab8a052894336cea2127b12126c6bbbab7423b3ad7f7758722aef84c42b05b8", "searchTerms": [], + "slug": "tf-pmspstar-1-8", "title": "P●18", "where": "(chart.data->'tableFolders'->>'P●') = '18'" }, @@ -32924,6 +36289,7 @@ "inactive": false, "legacyFolderID": "F78534b8d6dce7f06639e58e0586b5e03c3d8a0bf9e5d93dcacd0ee3d75f093fa", "searchTerms": [], + "slug": "tf-pmspstar-1-9", "title": "P●19", "where": "(chart.data->'tableFolders'->>'P●') = '19'" }, @@ -32933,6 +36299,7 @@ "inactive": false, "legacyFolderID": "F2fbc35aa8a0dd0e93faad8b0b9ddb0e2159773a44ceec29218320f1668a389c4", "searchTerms": [], + "slug": "tf-pmspstar-2", "title": "P●2", "where": "(chart.data->'tableFolders'->>'P●') = '2'" }, @@ -32942,6 +36309,7 @@ "inactive": false, "legacyFolderID": "F518a431ef896094d1a6497c019e0694d6b2a983979d72e021143167f5eeeaa0d", "searchTerms": [], + "slug": "tf-pmspstar-2-0", "title": "P●20", "where": "(chart.data->'tableFolders'->>'P●') = '20'" }, @@ -32951,6 +36319,7 @@ "inactive": false, "legacyFolderID": "Fad0fbde175d91ac3f4920b1648346049d3d5ce6e8ae37cc783f0c56c672e6165", "searchTerms": [], + "slug": "tf-pmspstar-3", "title": "P●3", "where": "(chart.data->'tableFolders'->>'P●') = '3'" }, @@ -32960,6 +36329,7 @@ "inactive": false, "legacyFolderID": "F496cd75679020e69b2f4c341b320c5e565306b1ea9cf82f722014c8f4e6a5f0e", "searchTerms": [], + "slug": "tf-pmspstar-4", "title": "P●4", "where": "(chart.data->'tableFolders'->>'P●') = '4'" }, @@ -32969,6 +36339,7 @@ "inactive": false, "legacyFolderID": "F0163f7e7b0c18d71252fc60111f77319dc183d9c7a139a87781b2fd792f99d69", "searchTerms": [], + "slug": "tf-pmspstar-5", "title": "P●5", "where": "(chart.data->'tableFolders'->>'P●') = '5'" }, @@ -32978,6 +36349,7 @@ "inactive": false, "legacyFolderID": "Fbbbd9101ace6f5e13c018385081b8ee1a39c978f151df7c95773415e2f04ddf1", "searchTerms": [], + "slug": "tf-pmspstar-6", "title": "P●6", "where": "(chart.data->'tableFolders'->>'P●') = '6'" }, @@ -32987,6 +36359,7 @@ "inactive": false, "legacyFolderID": "Fca9ac60721a3d867d35805607156b97962347a1893d5a441b2699b979ee0bace", "searchTerms": [], + "slug": "tf-pmspstar-7", "title": "P●7", "where": "(chart.data->'tableFolders'->>'P●') = '7'" }, @@ -32996,6 +36369,7 @@ "inactive": false, "legacyFolderID": "F90a8508a57457d6fae102d7471fc2424cc965cf897e2d9cd8b5c56b264c1d344", "searchTerms": [], + "slug": "tf-pmspstar-8", "title": "P●8", "where": "(chart.data->'tableFolders'->>'P●') = '8'" }, @@ -33005,6 +36379,7 @@ "inactive": false, "legacyFolderID": "Fafd60a08d28e087389f15d784a0ff63c3291b1e27e7f58359b6f73241d4fc49b", "searchTerms": [], + "slug": "tf-pmspstar-9", "title": "P●9", "where": "(chart.data->'tableFolders'->>'P●') = '9'" }, @@ -33014,6 +36389,7 @@ "inactive": false, "legacyFolderID": "F796aa4c6b21281a0561435fb2cd84d677d5a90c71feee06b9061dfb117ebaf57", "searchTerms": [], + "slug": "tf-pmsplv-q", "title": "PLv?", "where": "(chart.data->'tableFolders'->>'PLv') = '?'" }, @@ -33023,6 +36399,7 @@ "inactive": false, "legacyFolderID": "Fe7c76d571712ff7ad0acb8d9357d86a81d4fd8e6d58184fd555107432ea41d27", "searchTerms": [], + "slug": "tf-pmsplv-1", "title": "PLv1", "where": "(chart.data->'tableFolders'->>'PLv') = '1'" }, @@ -33032,6 +36409,7 @@ "inactive": false, "legacyFolderID": "F62bc91687212be67abb20ec06ac396332dad8c3ed876be0482e98a468934d76b", "searchTerms": [], + "slug": "tf-pmsplv-1-0", "title": "PLv10", "where": "(chart.data->'tableFolders'->>'PLv') = '10'" }, @@ -33041,6 +36419,7 @@ "inactive": false, "legacyFolderID": "F2352d24bd8d527b611ecb8c26f0ffae7e3dc9414f1347ce186e6dc8f9981e1f5", "searchTerms": [], + "slug": "tf-pmsplv-1-1", "title": "PLv11", "where": "(chart.data->'tableFolders'->>'PLv') = '11'" }, @@ -33050,6 +36429,7 @@ "inactive": false, "legacyFolderID": "F3ec80ef97792057dddd33ba9863c3b8827b2f90f09023a24af5f2d81903e1ad9", "searchTerms": [], + "slug": "tf-pmsplv-1-2", "title": "PLv12", "where": "(chart.data->'tableFolders'->>'PLv') = '12'" }, @@ -33059,6 +36439,7 @@ "inactive": false, "legacyFolderID": "Fa83ff8a786abe29f01d95ef1853a55f2225b704cffcab1c411f6b9b5bb8731ca", "searchTerms": [], + "slug": "tf-pmsplv-1-3", "title": "PLv13", "where": "(chart.data->'tableFolders'->>'PLv') = '13'" }, @@ -33068,6 +36449,7 @@ "inactive": false, "legacyFolderID": "Fed82f31fa258c1f9f8fd46b366ea602413f57a21ec2f962f4f18146ac81b615e", "searchTerms": [], + "slug": "tf-pmsplv-1-4", "title": "PLv14", "where": "(chart.data->'tableFolders'->>'PLv') = '14'" }, @@ -33077,6 +36459,7 @@ "inactive": false, "legacyFolderID": "Fb7a349864989e373f2c778f1db3a5636fcac6cf4b58a9a56cde09fae217ed88a", "searchTerms": [], + "slug": "tf-pmsplv-1-5", "title": "PLv15", "where": "(chart.data->'tableFolders'->>'PLv') = '15'" }, @@ -33086,6 +36469,7 @@ "inactive": false, "legacyFolderID": "Fdce0515f8add13b171718a00e6d310666faa67f070af2c93e2a0e77f91ad1ca6", "searchTerms": [], + "slug": "tf-pmsplv-1-6", "title": "PLv16", "where": "(chart.data->'tableFolders'->>'PLv') = '16'" }, @@ -33095,6 +36479,7 @@ "inactive": false, "legacyFolderID": "Ffc5334f71dbe649ec901b2c7f003976277e8f56482b6edfed2963282f27f9b21", "searchTerms": [], + "slug": "tf-pmsplv-1-7", "title": "PLv17", "where": "(chart.data->'tableFolders'->>'PLv') = '17'" }, @@ -33104,6 +36489,7 @@ "inactive": false, "legacyFolderID": "F07770c3f207ff1f80b3b378992b791c4d0ac22b1e9b55bda772bce39c782b50c", "searchTerms": [], + "slug": "tf-pmsplv-1-8", "title": "PLv18", "where": "(chart.data->'tableFolders'->>'PLv') = '18'" }, @@ -33113,6 +36499,7 @@ "inactive": false, "legacyFolderID": "F28a080fd18a0ce733e2441d1f3f7150c1f0e1f3affd06ae0fb58de266b553603", "searchTerms": [], + "slug": "tf-pmsplv-1-9", "title": "PLv19", "where": "(chart.data->'tableFolders'->>'PLv') = '19'" }, @@ -33122,6 +36509,7 @@ "inactive": false, "legacyFolderID": "F4f527f0eee7a093d973411ed845ccc8dddbce8fc2060af8cdc7b923fa304ba4d", "searchTerms": [], + "slug": "tf-pmsplv-2", "title": "PLv2", "where": "(chart.data->'tableFolders'->>'PLv') = '2'" }, @@ -33131,6 +36519,7 @@ "inactive": false, "legacyFolderID": "F667e83e6042d9eb806cc5cf23c72e3bad4b28d8a6e1214458237a564e611e336", "searchTerms": [], + "slug": "tf-pmsplv-2-0", "title": "PLv20", "where": "(chart.data->'tableFolders'->>'PLv') = '20'" }, @@ -33140,6 +36529,7 @@ "inactive": false, "legacyFolderID": "F6f35330c750f8fb6e0e32029a8b3db97f6063597197c1f48b9f9b500f89789a7", "searchTerms": [], + "slug": "tf-pmsplv-2-1", "title": "PLv21", "where": "(chart.data->'tableFolders'->>'PLv') = '21'" }, @@ -33149,6 +36539,7 @@ "inactive": false, "legacyFolderID": "F47e355e1e56962199182190c39d12c7a8c64388683954cabc95a6266f4f29eaf", "searchTerms": [], + "slug": "tf-pmsplv-2-2", "title": "PLv22", "where": "(chart.data->'tableFolders'->>'PLv') = '22'" }, @@ -33158,6 +36549,7 @@ "inactive": false, "legacyFolderID": "F28652f8913b9ed69108eab1615024adbb3ed3747bbdede227b20b751de40d55b", "searchTerms": [], + "slug": "tf-pmsplv-2-3", "title": "PLv23", "where": "(chart.data->'tableFolders'->>'PLv') = '23'" }, @@ -33167,6 +36559,7 @@ "inactive": false, "legacyFolderID": "F6a4c22c06c2168f14694a9fddf0b10627f21b3d0ca7444df44d5d2a2629a530a", "searchTerms": [], + "slug": "tf-pmsplv-2-4", "title": "PLv24", "where": "(chart.data->'tableFolders'->>'PLv') = '24'" }, @@ -33176,6 +36569,7 @@ "inactive": false, "legacyFolderID": "F0d64d1612873f559c5a06c9e89c669006a8d0496b79078db7784715cfaebfea2", "searchTerms": [], + "slug": "tf-pmsplv-2-5", "title": "PLv25", "where": "(chart.data->'tableFolders'->>'PLv') = '25'" }, @@ -33185,6 +36579,7 @@ "inactive": false, "legacyFolderID": "F3fb20988f503842feb4d54703f11f56a6aed385ca40df76557ad0143ecca3d3f", "searchTerms": [], + "slug": "tf-pmsplv-2-6", "title": "PLv26", "where": "(chart.data->'tableFolders'->>'PLv') = '26'" }, @@ -33194,6 +36589,7 @@ "inactive": false, "legacyFolderID": "F490abfa35c5275ebb55ddee701ef8a07bb7e836b26d4e29c8f5c5f71ae194ef9", "searchTerms": [], + "slug": "tf-pmsplv-2-7", "title": "PLv27", "where": "(chart.data->'tableFolders'->>'PLv') = '27'" }, @@ -33203,6 +36599,7 @@ "inactive": false, "legacyFolderID": "F8f0c4b3f364f4a88698ef49d420e8dc5af1f98f4d4a3d9a2085f2c7b35b03dc5", "searchTerms": [], + "slug": "tf-pmsplv-2-8", "title": "PLv28", "where": "(chart.data->'tableFolders'->>'PLv') = '28'" }, @@ -33212,6 +36609,7 @@ "inactive": false, "legacyFolderID": "F9c9329d36a648a0e608dc841fc942425715cb76dfc3bf9d0bed48445bfe65284", "searchTerms": [], + "slug": "tf-pmsplv-2-9", "title": "PLv29", "where": "(chart.data->'tableFolders'->>'PLv') = '29'" }, @@ -33221,6 +36619,7 @@ "inactive": false, "legacyFolderID": "F877a669e42c8e3d3f80b209bbe90a97b3b3d21df6613257257ce3480803452eb", "searchTerms": [], + "slug": "tf-pmsplv-3", "title": "PLv3", "where": "(chart.data->'tableFolders'->>'PLv') = '3'" }, @@ -33230,6 +36629,7 @@ "inactive": false, "legacyFolderID": "Fc2fb91955d802c8c6686cd5b8a5111223a2d757beabaffca621a4b9f5305e919", "searchTerms": [], + "slug": "tf-pmsplv-3-0", "title": "PLv30", "where": "(chart.data->'tableFolders'->>'PLv') = '30'" }, @@ -33239,6 +36639,7 @@ "inactive": false, "legacyFolderID": "Fa86deb6482fc7f72310a270ee17f2edcddd342fe2a6c9b253affddb74f3315cb", "searchTerms": [], + "slug": "tf-pmsplv-3-1", "title": "PLv31", "where": "(chart.data->'tableFolders'->>'PLv') = '31'" }, @@ -33248,6 +36649,7 @@ "inactive": false, "legacyFolderID": "F2ee924054d125cf1a0b0ea9bf5a545f4db96fd2d199b7ed419a93b64e2be04a7", "searchTerms": [], + "slug": "tf-pmsplv-3-2", "title": "PLv32", "where": "(chart.data->'tableFolders'->>'PLv') = '32'" }, @@ -33257,6 +36659,7 @@ "inactive": false, "legacyFolderID": "F97b2173032b0be741b172160e918bb0454b6836a67502d57a44ebd442093ebee", "searchTerms": [], + "slug": "tf-pmsplv-3-3", "title": "PLv33", "where": "(chart.data->'tableFolders'->>'PLv') = '33'" }, @@ -33266,6 +36669,7 @@ "inactive": false, "legacyFolderID": "F3b4663b9115a6e6fbe8515762e8b203094126310f3677cc939c201a5f5a5624d", "searchTerms": [], + "slug": "tf-pmsplv-3-4", "title": "PLv34", "where": "(chart.data->'tableFolders'->>'PLv') = '34'" }, @@ -33275,6 +36679,7 @@ "inactive": false, "legacyFolderID": "Fd571b4f46b15a6e31d9af2bb371d0da40bb563be711edf9cf9b5e73cf19bb34e", "searchTerms": [], + "slug": "tf-pmsplv-3-5", "title": "PLv35", "where": "(chart.data->'tableFolders'->>'PLv') = '35'" }, @@ -33284,6 +36689,7 @@ "inactive": false, "legacyFolderID": "F7689567337fe5bca5d9011fad5a8605da8a7a6d272a640cd415c7768dd1bb0f4", "searchTerms": [], + "slug": "tf-pmsplv-3-6", "title": "PLv36", "where": "(chart.data->'tableFolders'->>'PLv') = '36'" }, @@ -33293,6 +36699,7 @@ "inactive": false, "legacyFolderID": "Ff200c79e8877980187df9c3908c2ea18d0b2a00eb79fa834933f07fe14baf716", "searchTerms": [], + "slug": "tf-pmsplv-3-7", "title": "PLv37", "where": "(chart.data->'tableFolders'->>'PLv') = '37'" }, @@ -33302,6 +36709,7 @@ "inactive": false, "legacyFolderID": "F876f76b35a88e11b912f6186ccd0bace84127ce1b14e381d1d280a33daa5ea9c", "searchTerms": [], + "slug": "tf-pmsplv-3-8", "title": "PLv38", "where": "(chart.data->'tableFolders'->>'PLv') = '38'" }, @@ -33311,6 +36719,7 @@ "inactive": false, "legacyFolderID": "F665907e061377cd7e7f701a854709f1f86dc3a2f0627cecbd438bb11f0a97b35", "searchTerms": [], + "slug": "tf-pmsplv-3-9", "title": "PLv39", "where": "(chart.data->'tableFolders'->>'PLv') = '39'" }, @@ -33320,6 +36729,7 @@ "inactive": false, "legacyFolderID": "F481dd93a08b9bc6e3456723164201aed10d1580684f49b80cb65ffc681581a5e", "searchTerms": [], + "slug": "tf-pmsplv-4", "title": "PLv4", "where": "(chart.data->'tableFolders'->>'PLv') = '4'" }, @@ -33329,6 +36739,7 @@ "inactive": false, "legacyFolderID": "F7a3ed3beab11e570a8d2866124afd6af4ab9dcfa01b277a6a88ad8b525a112b4", "searchTerms": [], + "slug": "tf-pmsplv-4-0", "title": "PLv40", "where": "(chart.data->'tableFolders'->>'PLv') = '40'" }, @@ -33338,6 +36749,7 @@ "inactive": false, "legacyFolderID": "Fcd08f25171b2c0ba8c4c855aa60471aa40c040005ca75fd1f69257d8e71f9eb7", "searchTerms": [], + "slug": "tf-pmsplv-4-1", "title": "PLv41", "where": "(chart.data->'tableFolders'->>'PLv') = '41'" }, @@ -33347,6 +36759,7 @@ "inactive": false, "legacyFolderID": "Fec86edfd70b13fc2fc9ac59020050d1dc0c5885210e15ed97397f00562e47130", "searchTerms": [], + "slug": "tf-pmsplv-4-2", "title": "PLv42", "where": "(chart.data->'tableFolders'->>'PLv') = '42'" }, @@ -33356,6 +36769,7 @@ "inactive": false, "legacyFolderID": "F379e08eed3754385003d0e67b58548cee0d36dc729afd9a4b4e375b01a50d466", "searchTerms": [], + "slug": "tf-pmsplv-4-3", "title": "PLv43", "where": "(chart.data->'tableFolders'->>'PLv') = '43'" }, @@ -33365,6 +36779,7 @@ "inactive": false, "legacyFolderID": "Ffc955044aa87efd4d8a0ec510ff43e2858816ecf8252cf617fdb9de7dc85ca2d", "searchTerms": [], + "slug": "tf-pmsplv-4-4", "title": "PLv44", "where": "(chart.data->'tableFolders'->>'PLv') = '44'" }, @@ -33374,6 +36789,7 @@ "inactive": false, "legacyFolderID": "F4d46cfe4fa4f52667292b46c0c1d9e150baaf217cbac13176b296b33e9e31598", "searchTerms": [], + "slug": "tf-pmsplv-4-5", "title": "PLv45", "where": "(chart.data->'tableFolders'->>'PLv') = '45'" }, @@ -33383,6 +36799,7 @@ "inactive": false, "legacyFolderID": "F6427eb154dde3bad925b0159ecb69ba02ce38256304ca9439c268d0e158f579d", "searchTerms": [], + "slug": "tf-pmsplv-4-5-p", "title": "PLv45+", "where": "(chart.data->'tableFolders'->>'PLv') = '45+'" }, @@ -33392,6 +36809,7 @@ "inactive": false, "legacyFolderID": "F9fe37d043cf7af5552a53ac9d4c846a83e7a05c29892639ba72d971c3bf371af", "searchTerms": [], + "slug": "tf-pmsplv-5", "title": "PLv5", "where": "(chart.data->'tableFolders'->>'PLv') = '5'" }, @@ -33401,6 +36819,7 @@ "inactive": false, "legacyFolderID": "Fe3641b84604ea0674de54891bb0375add7ec4af261903624d12d299bd74922e5", "searchTerms": [], + "slug": "tf-pmsplv-6", "title": "PLv6", "where": "(chart.data->'tableFolders'->>'PLv') = '6'" }, @@ -33410,6 +36829,7 @@ "inactive": false, "legacyFolderID": "Fc4e0e745364eb9d6a502f65cf7d2ef90dbe36692182d25d8cafa324a82ae9069", "searchTerms": [], + "slug": "tf-pmsplv-7", "title": "PLv7", "where": "(chart.data->'tableFolders'->>'PLv') = '7'" }, @@ -33419,6 +36839,7 @@ "inactive": false, "legacyFolderID": "F4c7a3c6bdfae70be7c6b5e840d2aaf56efad15642089b296799f3d438681fc0a", "searchTerms": [], + "slug": "tf-pmsplv-8", "title": "PLv8", "where": "(chart.data->'tableFolders'->>'PLv') = '8'" }, @@ -33428,6 +36849,7 @@ "inactive": false, "legacyFolderID": "Fb56866ea160457858643e0138c1bdcf793651af52b840db08dc36b1489f29c50", "searchTerms": [], + "slug": "tf-pmsplv-9", "title": "PLv9", "where": "(chart.data->'tableFolders'->>'PLv') = '9'" }, @@ -33437,6 +36859,7 @@ "inactive": false, "legacyFolderID": "F4b3d0292f13c2edae1dcec6357766e1563c0aafba88be2afce3059cf2e3c9633", "searchTerms": [], + "slug": "tf-pmscircle-excl-i", "title": "●!i", "where": "(chart.data->'tableFolders'->>'●') = '!i'" }, @@ -33446,6 +36869,7 @@ "inactive": false, "legacyFolderID": "F7c77c8093a66c6d132110cccb4aa5736a57a9d58159075ff93bebaf6d2c70d26", "searchTerms": [], + "slug": "tf-pmscircle-1", "title": "●1", "where": "(chart.data->'tableFolders'->>'●') = '1'" }, @@ -33455,6 +36879,7 @@ "inactive": false, "legacyFolderID": "Fbab77f70d9948b8dde86facdfb33adb1150ce94f90f64c2a847f71f2fee448df", "searchTerms": [], + "slug": "tf-pmscircle-1-0", "title": "●10", "where": "(chart.data->'tableFolders'->>'●') = '10'" }, @@ -33464,6 +36889,7 @@ "inactive": false, "legacyFolderID": "F8ce8a7b65118edce6c684e1641808211e4a23e6d64324b6961f990ac2ffed4a7", "searchTerms": [], + "slug": "tf-pmscircle-1-1", "title": "●11", "where": "(chart.data->'tableFolders'->>'●') = '11'" }, @@ -33473,6 +36899,7 @@ "inactive": false, "legacyFolderID": "Fd2324e40267ab1598ef4b82bc47d27f03664033bedeb3974a11dfaa946dd6fb9", "searchTerms": [], + "slug": "tf-pmscircle-1-2", "title": "●12", "where": "(chart.data->'tableFolders'->>'●') = '12'" }, @@ -33482,6 +36909,7 @@ "inactive": false, "legacyFolderID": "Fbe7429d2e873f386f140ce3dd0b5b1bc4447a18b2f27b226b69966ff2c61605b", "searchTerms": [], + "slug": "tf-pmscircle-1-3", "title": "●13", "where": "(chart.data->'tableFolders'->>'●') = '13'" }, @@ -33491,6 +36919,7 @@ "inactive": false, "legacyFolderID": "F457a44cb58db458377709345a581c2f6a58588f806dcd46ebe52d53248176ff1", "searchTerms": [], + "slug": "tf-pmscircle-1-4", "title": "●14", "where": "(chart.data->'tableFolders'->>'●') = '14'" }, @@ -33500,6 +36929,7 @@ "inactive": false, "legacyFolderID": "Fc2ad38a666b998d90a7b13fceb2001284b08722fe8bb923128c556d8c3abced6", "searchTerms": [], + "slug": "tf-pmscircle-1-5", "title": "●15", "where": "(chart.data->'tableFolders'->>'●') = '15'" }, @@ -33509,6 +36939,7 @@ "inactive": false, "legacyFolderID": "Fc77fabdfa5351de0b31a60fc65af9a72d11ee816903a304ce1ad25b8bc9e6292", "searchTerms": [], + "slug": "tf-pmscircle-1-6", "title": "●16", "where": "(chart.data->'tableFolders'->>'●') = '16'" }, @@ -33518,6 +36949,7 @@ "inactive": false, "legacyFolderID": "Fae946366acf5507b4d6cb0701f90ba345e6b5b6dec1bf9e1de6d3c31c7e57fc5", "searchTerms": [], + "slug": "tf-pmscircle-1-7", "title": "●17", "where": "(chart.data->'tableFolders'->>'●') = '17'" }, @@ -33527,6 +36959,7 @@ "inactive": false, "legacyFolderID": "F2e2fcd3a98f25b3931ac4b77127a601183b39931f6a3077b0b772f9eb6312f52", "searchTerms": [], + "slug": "tf-pmscircle-1-8", "title": "●18", "where": "(chart.data->'tableFolders'->>'●') = '18'" }, @@ -33536,6 +36969,7 @@ "inactive": false, "legacyFolderID": "Faf6b2be2b32d485f98ed4d24712c9adb538a0e9abec0d4bcc39f9c3e03827b5c", "searchTerms": [], + "slug": "tf-pmscircle-1-9", "title": "●19", "where": "(chart.data->'tableFolders'->>'●') = '19'" }, @@ -33545,6 +36979,7 @@ "inactive": false, "legacyFolderID": "F549bd083bc87523dba91a7b90edadcbf1ece6cea79745596a3b3a9222abb46e2", "searchTerms": [], + "slug": "tf-pmscircle-2", "title": "●2", "where": "(chart.data->'tableFolders'->>'●') = '2'" }, @@ -33554,6 +36989,7 @@ "inactive": false, "legacyFolderID": "Fdfb6834b74507ed3a99edf92f8a5aae52110254b98885ec28cf796a399d4357a", "searchTerms": [], + "slug": "tf-pmscircle-3", "title": "●3", "where": "(chart.data->'tableFolders'->>'●') = '3'" }, @@ -33563,6 +36999,7 @@ "inactive": false, "legacyFolderID": "F7de3fba4badf9f26f799e92239d66c377b632be23bfda7fb8b2d1b6091582d96", "searchTerms": [], + "slug": "tf-pmscircle-4", "title": "●4", "where": "(chart.data->'tableFolders'->>'●') = '4'" }, @@ -33572,6 +37009,7 @@ "inactive": false, "legacyFolderID": "F152020b4c90db432d4c5bde4a03057dd550f6c630742f8375dce1212c9387a3d", "searchTerms": [], + "slug": "tf-pmscircle-5", "title": "●5", "where": "(chart.data->'tableFolders'->>'●') = '5'" }, @@ -33581,6 +37019,7 @@ "inactive": false, "legacyFolderID": "F4f765a935ac82fa98758abfa0d0a733900525362b1e06d54393acc8ac39b98e1", "searchTerms": [], + "slug": "tf-pmscircle-6", "title": "●6", "where": "(chart.data->'tableFolders'->>'●') = '6'" }, @@ -33590,6 +37029,7 @@ "inactive": false, "legacyFolderID": "Fc37d3417a18ef783122f83a4fd6367c72662a523291d18ca6dfc2c572b60e2b7", "searchTerms": [], + "slug": "tf-pmscircle-7", "title": "●7", "where": "(chart.data->'tableFolders'->>'●') = '7'" }, @@ -33599,6 +37039,7 @@ "inactive": false, "legacyFolderID": "Fd4df3056bd3afa6369f4a8076bc331735bd9ece1b03394f3064e933ab1e7fe1d", "searchTerms": [], + "slug": "tf-pmscircle-8", "title": "●8", "where": "(chart.data->'tableFolders'->>'●') = '8'" }, @@ -33608,6 +37049,7 @@ "inactive": false, "legacyFolderID": "F06a78b6b65034cfe7c8cfcb2ed6eadd2884e5c0ded1dd3014f1579efc43bf2b7", "searchTerms": [], + "slug": "tf-pmscircle-9", "title": "●9", "where": "(chart.data->'tableFolders'->>'●') = '9'" }, @@ -33617,6 +37059,7 @@ "inactive": false, "legacyFolderID": "Facaaee0dc05d05642aa2265b688f0f6157796549b4c5b854346947c737bc875f", "searchTerms": [], + "slug": "tf-pmspstar-excl-i", "title": "P●!i", "where": "(chart.data->'tableFolders'->>'P●') = '!i'" }, @@ -33626,6 +37069,7 @@ "inactive": false, "legacyFolderID": "Fc6d533d8ed464fc300373580b1caec280a4953a8667cd42d3930727b5d597b59", "searchTerms": [], + "slug": "tf-pmspstar-uff1f", "title": "P●?", "where": "(chart.data->'tableFolders'->>'P●') = '?'" }, @@ -33635,6 +37079,7 @@ "inactive": false, "legacyFolderID": "F6cfc555e0f94901bf96dbb4677149ac8f59fbf6c078d3c02bc1908e6efe014b2", "searchTerms": [], + "slug": "tf-pmspstar-uff5e", "title": "P●~", "where": "(chart.data->'tableFolders'->>'P●') = '~'" }, @@ -33644,6 +37089,7 @@ "inactive": false, "legacyFolderID": "Fd0df1cd2956760f9e5a40125be33090c46701b073ba1c64300196d33f91fec91", "searchTerms": [], + "slug": "tf-pmspstar-u25c6", "title": "P●◆", "where": "(chart.data->'tableFolders'->>'P●') = '◆'" }, @@ -33653,6 +37099,7 @@ "inactive": false, "legacyFolderID": "F4662e90f4ebeee1b32a85c48eab704498dc16dce12d66bd1b5634811141d77ed", "searchTerms": [], + "slug": "tf-pmspstar-1", "title": "P●1", "where": "(chart.data->'tableFolders'->>'P●') = '1'" }, @@ -33662,6 +37109,7 @@ "inactive": false, "legacyFolderID": "Fd2c3bad50c3684cd803bda81c657d24b0223426f5fbe2bf516420b3fed3e59cb", "searchTerms": [], + "slug": "tf-pmspstar-1-0", "title": "P●10", "where": "(chart.data->'tableFolders'->>'P●') = '10'" }, @@ -33671,6 +37119,7 @@ "inactive": false, "legacyFolderID": "F42a8e8c06765978b3cd227069903c67c6c987aa056d07cff5cb0e8adef36743a", "searchTerms": [], + "slug": "tf-pmspstar-1-1", "title": "P●11", "where": "(chart.data->'tableFolders'->>'P●') = '11'" }, @@ -33680,6 +37129,7 @@ "inactive": false, "legacyFolderID": "F67356b38c03388e46c405b59a9bd3e9b1cd3e2795d6ddd0b4668d8d60fdea38d", "searchTerms": [], + "slug": "tf-pmspstar-1-2", "title": "P●12", "where": "(chart.data->'tableFolders'->>'P●') = '12'" }, @@ -33689,6 +37139,7 @@ "inactive": false, "legacyFolderID": "Fdb947367632e0816cb31ff73e6a2c1c602b03c43696f1fa42a569bdcfa8127e6", "searchTerms": [], + "slug": "tf-pmspstar-1-3", "title": "P●13", "where": "(chart.data->'tableFolders'->>'P●') = '13'" }, @@ -33698,6 +37149,7 @@ "inactive": false, "legacyFolderID": "F11bbd4c22f210b0b6546b31170902a140a6dea35ac903c92c14435f184fb267a", "searchTerms": [], + "slug": "tf-pmspstar-1-4", "title": "P●14", "where": "(chart.data->'tableFolders'->>'P●') = '14'" }, @@ -33707,6 +37159,7 @@ "inactive": false, "legacyFolderID": "Fd93f1ddd8bb42bf9bc752d70b8d1faea83b1d122485c9d6efb821aeebff3efd9", "searchTerms": [], + "slug": "tf-pmspstar-1-5", "title": "P●15", "where": "(chart.data->'tableFolders'->>'P●') = '15'" }, @@ -33716,6 +37169,7 @@ "inactive": false, "legacyFolderID": "F7410cf2db52cb076df50850b71ca8e00cfadf3dbf93453b5ffe8b7bbf57986b6", "searchTerms": [], + "slug": "tf-pmspstar-1-6", "title": "P●16", "where": "(chart.data->'tableFolders'->>'P●') = '16'" }, @@ -33725,6 +37179,7 @@ "inactive": false, "legacyFolderID": "Fee503d47f1aafc19d22ab7872523e87b5187c60f838af57aa1e20566ca92ded6", "searchTerms": [], + "slug": "tf-pmspstar-1-7", "title": "P●17", "where": "(chart.data->'tableFolders'->>'P●') = '17'" }, @@ -33734,6 +37189,7 @@ "inactive": false, "legacyFolderID": "Fbaec5452393950a43211e6956eff397d1768ea128788b9550b94741b108c24dd", "searchTerms": [], + "slug": "tf-pmspstar-1-8", "title": "P●18", "where": "(chart.data->'tableFolders'->>'P●') = '18'" }, @@ -33743,6 +37199,7 @@ "inactive": false, "legacyFolderID": "F8c8b865593323a92489c45a2b99731bd021223128a1eb11ac9c3fcf0ddf39d1c", "searchTerms": [], + "slug": "tf-pmspstar-1-9", "title": "P●19", "where": "(chart.data->'tableFolders'->>'P●') = '19'" }, @@ -33752,6 +37209,7 @@ "inactive": false, "legacyFolderID": "F3686dfa517c746528ac49e56b422eb691ecbcf1bae1a0d9a30a7ed014f8de3b4", "searchTerms": [], + "slug": "tf-pmspstar-2", "title": "P●2", "where": "(chart.data->'tableFolders'->>'P●') = '2'" }, @@ -33761,6 +37219,7 @@ "inactive": false, "legacyFolderID": "F36bf4fba7f008c8e096f5523ac139165f8370a58da294d943f7b95dd5d69df14", "searchTerms": [], + "slug": "tf-pmspstar-2-0", "title": "P●20", "where": "(chart.data->'tableFolders'->>'P●') = '20'" }, @@ -33770,6 +37229,7 @@ "inactive": false, "legacyFolderID": "F449c39468baedc394ef65b96426cd654253fe3b2f3ace5bea12e2f1791f32f7c", "searchTerms": [], + "slug": "tf-pmspstar-3", "title": "P●3", "where": "(chart.data->'tableFolders'->>'P●') = '3'" }, @@ -33779,6 +37239,7 @@ "inactive": false, "legacyFolderID": "F3793237ebec7de13413c47433266947a12cdbcae1a68fd672b7b3b9999adde46", "searchTerms": [], + "slug": "tf-pmspstar-4", "title": "P●4", "where": "(chart.data->'tableFolders'->>'P●') = '4'" }, @@ -33788,6 +37249,7 @@ "inactive": false, "legacyFolderID": "F7a64f310c11c825acd6424d5c2e4a699860bcc4283d759448262af0d137e0a23", "searchTerms": [], + "slug": "tf-pmspstar-5", "title": "P●5", "where": "(chart.data->'tableFolders'->>'P●') = '5'" }, @@ -33797,6 +37259,7 @@ "inactive": false, "legacyFolderID": "F25b626d12be7b9c8ecb95af7e971497844c17a7e289ee3d551bcdb5797536c1c", "searchTerms": [], + "slug": "tf-pmspstar-6", "title": "P●6", "where": "(chart.data->'tableFolders'->>'P●') = '6'" }, @@ -33806,6 +37269,7 @@ "inactive": false, "legacyFolderID": "Ff75f45782ee3efff5e6171907253c4fb97667effe7e260f9117e293dcc5271c8", "searchTerms": [], + "slug": "tf-pmspstar-7", "title": "P●7", "where": "(chart.data->'tableFolders'->>'P●') = '7'" }, @@ -33815,6 +37279,7 @@ "inactive": false, "legacyFolderID": "Fb9eb3d8b3f3101c7bfe5553e48d27a2b4bc2af7f79b9f4fc2c496573ea6050a4", "searchTerms": [], + "slug": "tf-pmspstar-8", "title": "P●8", "where": "(chart.data->'tableFolders'->>'P●') = '8'" }, @@ -33824,6 +37289,7 @@ "inactive": false, "legacyFolderID": "F278ba96f5de41e2af4157154afa4271275cd650c597246aa7e4566c785733f4f", "searchTerms": [], + "slug": "tf-pmspstar-9", "title": "P●9", "where": "(chart.data->'tableFolders'->>'P●') = '9'" }, @@ -33833,6 +37299,7 @@ "inactive": false, "legacyFolderID": "Ffae1491c6beb0ae1ac2a5124258c347857b5524ff643d16820c5df608d00af1f", "searchTerms": [], + "slug": "tf-pmsplv-q", "title": "PLv?", "where": "(chart.data->'tableFolders'->>'PLv') = '?'" }, @@ -33842,6 +37309,7 @@ "inactive": false, "legacyFolderID": "Febc5cb2215b47403055caf049a88747ccdde1bf1d087c873f4321d4676e93c78", "searchTerms": [], + "slug": "tf-pmsplv-1", "title": "PLv1", "where": "(chart.data->'tableFolders'->>'PLv') = '1'" }, @@ -33851,6 +37319,7 @@ "inactive": false, "legacyFolderID": "F401c5da954df574bb3276b0d22fcd54f120a784ab829ab07b37c3b90076a8ef7", "searchTerms": [], + "slug": "tf-pmsplv-1-0", "title": "PLv10", "where": "(chart.data->'tableFolders'->>'PLv') = '10'" }, @@ -33860,6 +37329,7 @@ "inactive": false, "legacyFolderID": "Faf883bfec26982f6cd92d8a6974f11e371166ec94e4d15fc04d4f9f4176ae921", "searchTerms": [], + "slug": "tf-pmsplv-1-1", "title": "PLv11", "where": "(chart.data->'tableFolders'->>'PLv') = '11'" }, @@ -33869,6 +37339,7 @@ "inactive": false, "legacyFolderID": "Fd30693161b16d7657121c428262b6918b903b031a7758a0d6f0e8e993b2175d2", "searchTerms": [], + "slug": "tf-pmsplv-1-2", "title": "PLv12", "where": "(chart.data->'tableFolders'->>'PLv') = '12'" }, @@ -33878,6 +37349,7 @@ "inactive": false, "legacyFolderID": "Fea6f3f38a4fa24bdcaac0f26f6703c74e554fe707b4f777ccb4c0f73d5eb8853", "searchTerms": [], + "slug": "tf-pmsplv-1-3", "title": "PLv13", "where": "(chart.data->'tableFolders'->>'PLv') = '13'" }, @@ -33887,6 +37359,7 @@ "inactive": false, "legacyFolderID": "Fac068bfa0f65c32511642bffb00ada6d5af3b02f84d556918e1e2e994080d2b6", "searchTerms": [], + "slug": "tf-pmsplv-1-4", "title": "PLv14", "where": "(chart.data->'tableFolders'->>'PLv') = '14'" }, @@ -33896,6 +37369,7 @@ "inactive": false, "legacyFolderID": "Fca19fc0891bfde439ce2b1ea329ed72f36fc78046808eb424c4e0bd9337523c5", "searchTerms": [], + "slug": "tf-pmsplv-1-5", "title": "PLv15", "where": "(chart.data->'tableFolders'->>'PLv') = '15'" }, @@ -33905,6 +37379,7 @@ "inactive": false, "legacyFolderID": "Fee4f8897438416123692bd0d3d4d3f8091b16375180cf3f8e81f765209bf5004", "searchTerms": [], + "slug": "tf-pmsplv-1-6", "title": "PLv16", "where": "(chart.data->'tableFolders'->>'PLv') = '16'" }, @@ -33914,6 +37389,7 @@ "inactive": false, "legacyFolderID": "Ff3a24e87c844546c9ccab9667d3651afeaa08abb74bdac78322f7ecbd9a64b60", "searchTerms": [], + "slug": "tf-pmsplv-1-7", "title": "PLv17", "where": "(chart.data->'tableFolders'->>'PLv') = '17'" }, @@ -33923,6 +37399,7 @@ "inactive": false, "legacyFolderID": "F7d4c49e759005456070c7891a3592aa302c664da663f30a85dd25b66d71349c9", "searchTerms": [], + "slug": "tf-pmsplv-1-8", "title": "PLv18", "where": "(chart.data->'tableFolders'->>'PLv') = '18'" }, @@ -33932,6 +37409,7 @@ "inactive": false, "legacyFolderID": "F61f7f7eb909707fcfa45173e6aa30601978031a4111767ace73d0663528943e0", "searchTerms": [], + "slug": "tf-pmsplv-1-9", "title": "PLv19", "where": "(chart.data->'tableFolders'->>'PLv') = '19'" }, @@ -33941,6 +37419,7 @@ "inactive": false, "legacyFolderID": "Ffc0d32fcceddaa2483ae13ff604502bdb2985cd3d6275f1ccfda43344ebdef6a", "searchTerms": [], + "slug": "tf-pmsplv-2", "title": "PLv2", "where": "(chart.data->'tableFolders'->>'PLv') = '2'" }, @@ -33950,6 +37429,7 @@ "inactive": false, "legacyFolderID": "F4ef51a0848557c9a31676a281080e313cbba1587f747575a755750fdc85ee3f6", "searchTerms": [], + "slug": "tf-pmsplv-2-0", "title": "PLv20", "where": "(chart.data->'tableFolders'->>'PLv') = '20'" }, @@ -33959,6 +37439,7 @@ "inactive": false, "legacyFolderID": "F20043425630f8a9d4eae934e8bf6eb785d152cca7ddcb59a3e1bfe0fac62db0d", "searchTerms": [], + "slug": "tf-pmsplv-2-1", "title": "PLv21", "where": "(chart.data->'tableFolders'->>'PLv') = '21'" }, @@ -33968,6 +37449,7 @@ "inactive": false, "legacyFolderID": "Ffa5391f9252d30733b558f749eb1e506d5ecd17943f3a40aef1a800132fcb1f1", "searchTerms": [], + "slug": "tf-pmsplv-2-2", "title": "PLv22", "where": "(chart.data->'tableFolders'->>'PLv') = '22'" }, @@ -33977,6 +37459,7 @@ "inactive": false, "legacyFolderID": "F5c347cbb8393db787c2aa91a336b0713e9d3e24b9743f03a9633cb2ce850011b", "searchTerms": [], + "slug": "tf-pmsplv-2-3", "title": "PLv23", "where": "(chart.data->'tableFolders'->>'PLv') = '23'" }, @@ -33986,6 +37469,7 @@ "inactive": false, "legacyFolderID": "Fad276a9df76a6d304a27e64e04c49d3b562f7b3581fb356d0ed47ecc1b0fbc20", "searchTerms": [], + "slug": "tf-pmsplv-2-4", "title": "PLv24", "where": "(chart.data->'tableFolders'->>'PLv') = '24'" }, @@ -33995,6 +37479,7 @@ "inactive": false, "legacyFolderID": "F6b2c013eabd2178ea6ad83b56499b725ea1c507f5ac32b30ab7074c4f1ce7221", "searchTerms": [], + "slug": "tf-pmsplv-2-5", "title": "PLv25", "where": "(chart.data->'tableFolders'->>'PLv') = '25'" }, @@ -34004,6 +37489,7 @@ "inactive": false, "legacyFolderID": "Fe3a9aa786e9c7929748a9b1421d7ae836a240c6d095107b1c9d45f87e2d9b0bf", "searchTerms": [], + "slug": "tf-pmsplv-2-6", "title": "PLv26", "where": "(chart.data->'tableFolders'->>'PLv') = '26'" }, @@ -34013,6 +37499,7 @@ "inactive": false, "legacyFolderID": "F1b024da5194e2cdefe2ce12cbc154b8c5ea3bde86d2906cffd40cf7c666f693b", "searchTerms": [], + "slug": "tf-pmsplv-2-7", "title": "PLv27", "where": "(chart.data->'tableFolders'->>'PLv') = '27'" }, @@ -34022,6 +37509,7 @@ "inactive": false, "legacyFolderID": "F8c3dee4ce15e521ec6c6f3816db7c18becfa1d24061a9a0c33406c21ac983f10", "searchTerms": [], + "slug": "tf-pmsplv-2-8", "title": "PLv28", "where": "(chart.data->'tableFolders'->>'PLv') = '28'" }, @@ -34031,6 +37519,7 @@ "inactive": false, "legacyFolderID": "F268af4f5510371c12c36d543c1fc322fd7721455291f0b3c3c7ec92f66a2f3e0", "searchTerms": [], + "slug": "tf-pmsplv-2-9", "title": "PLv29", "where": "(chart.data->'tableFolders'->>'PLv') = '29'" }, @@ -34040,6 +37529,7 @@ "inactive": false, "legacyFolderID": "Fc80e3691280dfb9405734d1288bfef27c8935bdc58e62c80e0c54ee2b7c83828", "searchTerms": [], + "slug": "tf-pmsplv-3", "title": "PLv3", "where": "(chart.data->'tableFolders'->>'PLv') = '3'" }, @@ -34049,6 +37539,7 @@ "inactive": false, "legacyFolderID": "F14b18d970366ebd01925f89b425e44644d32f8b53c53d1fc874771d2b6ea5c41", "searchTerms": [], + "slug": "tf-pmsplv-3-0", "title": "PLv30", "where": "(chart.data->'tableFolders'->>'PLv') = '30'" }, @@ -34058,6 +37549,7 @@ "inactive": false, "legacyFolderID": "F05909a6753332f8187e3ff3f3907af95703370f325a821c46848a8aa253c9393", "searchTerms": [], + "slug": "tf-pmsplv-3-1", "title": "PLv31", "where": "(chart.data->'tableFolders'->>'PLv') = '31'" }, @@ -34067,6 +37559,7 @@ "inactive": false, "legacyFolderID": "Fdefb3b31de0ff725c8ea2142271ebfd293b32f9f576d31324b9c8296f0ce4dec", "searchTerms": [], + "slug": "tf-pmsplv-3-2", "title": "PLv32", "where": "(chart.data->'tableFolders'->>'PLv') = '32'" }, @@ -34076,6 +37569,7 @@ "inactive": false, "legacyFolderID": "Ff7002d77380fa23e576788387a84d47395e070ce8b99d66b75a0b5bf20c8b53d", "searchTerms": [], + "slug": "tf-pmsplv-3-3", "title": "PLv33", "where": "(chart.data->'tableFolders'->>'PLv') = '33'" }, @@ -34085,6 +37579,7 @@ "inactive": false, "legacyFolderID": "F5626b4e8e76e0dfa6cd61a52b3abca01120c8ac508e65638cc3202a7f14e1e99", "searchTerms": [], + "slug": "tf-pmsplv-3-4", "title": "PLv34", "where": "(chart.data->'tableFolders'->>'PLv') = '34'" }, @@ -34094,6 +37589,7 @@ "inactive": false, "legacyFolderID": "F19f7a23565d1913f2e2423822f3e5c1b5e39ca91a56f56ff47bcc829176e7793", "searchTerms": [], + "slug": "tf-pmsplv-3-5", "title": "PLv35", "where": "(chart.data->'tableFolders'->>'PLv') = '35'" }, @@ -34103,6 +37599,7 @@ "inactive": false, "legacyFolderID": "F51c3711ae4a760d9df8f81660d4e48b268a2323443fa3e8242198a12b23d2804", "searchTerms": [], + "slug": "tf-pmsplv-3-6", "title": "PLv36", "where": "(chart.data->'tableFolders'->>'PLv') = '36'" }, @@ -34112,6 +37609,7 @@ "inactive": false, "legacyFolderID": "Fb8f8758b7f6dad4fdb55edeb3dfb92b066f6a42fcaf1b7ef155b1f3afa6d3916", "searchTerms": [], + "slug": "tf-pmsplv-3-7", "title": "PLv37", "where": "(chart.data->'tableFolders'->>'PLv') = '37'" }, @@ -34121,6 +37619,7 @@ "inactive": false, "legacyFolderID": "F78e813c86aecd3fd209c8e30b143384b3464879de99ac8c15825e19efa42268b", "searchTerms": [], + "slug": "tf-pmsplv-3-8", "title": "PLv38", "where": "(chart.data->'tableFolders'->>'PLv') = '38'" }, @@ -34130,6 +37629,7 @@ "inactive": false, "legacyFolderID": "Ffb420bc499e7b23ab98e68f0da818e531f3f6d2e0926b7b9c6bd1e8cc053f791", "searchTerms": [], + "slug": "tf-pmsplv-3-9", "title": "PLv39", "where": "(chart.data->'tableFolders'->>'PLv') = '39'" }, @@ -34139,6 +37639,7 @@ "inactive": false, "legacyFolderID": "F818bbc061d5286cd79b493041f89023ec280ce520bdb1a8dfc7b5e7efcfd0192", "searchTerms": [], + "slug": "tf-pmsplv-4", "title": "PLv4", "where": "(chart.data->'tableFolders'->>'PLv') = '4'" }, @@ -34148,6 +37649,7 @@ "inactive": false, "legacyFolderID": "Fb86585f9f8731faeaf8c1b75ba3e6b90207af0af5b9c67361c914988f8dfa35b", "searchTerms": [], + "slug": "tf-pmsplv-4-0", "title": "PLv40", "where": "(chart.data->'tableFolders'->>'PLv') = '40'" }, @@ -34157,6 +37659,7 @@ "inactive": false, "legacyFolderID": "Fcde4aa2025dcde89f2ead383eddfa4490e66b04ac3be6e5d4a23ff6df97c2a20", "searchTerms": [], + "slug": "tf-pmsplv-4-1", "title": "PLv41", "where": "(chart.data->'tableFolders'->>'PLv') = '41'" }, @@ -34166,6 +37669,7 @@ "inactive": false, "legacyFolderID": "F9e85bd2f8a7b6d4a272a149b9cf5f9c8690039d3d36c9e49dfcb69bb705708db", "searchTerms": [], + "slug": "tf-pmsplv-4-2", "title": "PLv42", "where": "(chart.data->'tableFolders'->>'PLv') = '42'" }, @@ -34175,6 +37679,7 @@ "inactive": false, "legacyFolderID": "Fda6293fb86aac77756def7469d42aef2cb898bb0cbd96daf0c8f12b736226097", "searchTerms": [], + "slug": "tf-pmsplv-4-3", "title": "PLv43", "where": "(chart.data->'tableFolders'->>'PLv') = '43'" }, @@ -34184,6 +37689,7 @@ "inactive": false, "legacyFolderID": "F5b372e1dfd26ad1ed5c0076efa21e2a2e70b13563b6faa4be05e5fccf5e427d8", "searchTerms": [], + "slug": "tf-pmsplv-4-4", "title": "PLv44", "where": "(chart.data->'tableFolders'->>'PLv') = '44'" }, @@ -34193,6 +37699,7 @@ "inactive": false, "legacyFolderID": "F69a3910cdf6e29803dd5d8e5cec4f192047746cd07bcd081af3f71ecc5f5bdb5", "searchTerms": [], + "slug": "tf-pmsplv-4-5", "title": "PLv45", "where": "(chart.data->'tableFolders'->>'PLv') = '45'" }, @@ -34202,6 +37709,7 @@ "inactive": false, "legacyFolderID": "F8649fbee88a322c512bc1b500dec6f4ea88115254b8e78b2820c4a5e406e2bde", "searchTerms": [], + "slug": "tf-pmsplv-4-5-p", "title": "PLv45+", "where": "(chart.data->'tableFolders'->>'PLv') = '45+'" }, @@ -34211,6 +37719,7 @@ "inactive": false, "legacyFolderID": "F36c0b69e07adf2a434b82ee326d90aa3cba7a8a4e881beb98f238f43a2c9dea2", "searchTerms": [], + "slug": "tf-pmsplv-5", "title": "PLv5", "where": "(chart.data->'tableFolders'->>'PLv') = '5'" }, @@ -34220,6 +37729,7 @@ "inactive": false, "legacyFolderID": "F55e7f22e22b19efbaf6a44ba8f94bcea8feed86e52d81cb8625e2feec5269316", "searchTerms": [], + "slug": "tf-pmsplv-6", "title": "PLv6", "where": "(chart.data->'tableFolders'->>'PLv') = '6'" }, @@ -34229,6 +37739,7 @@ "inactive": false, "legacyFolderID": "F1a10f742f58149c97907119787dab28d9887eaff17030991c3c3e6bc25df0383", "searchTerms": [], + "slug": "tf-pmsplv-7", "title": "PLv7", "where": "(chart.data->'tableFolders'->>'PLv') = '7'" }, @@ -34238,6 +37749,7 @@ "inactive": false, "legacyFolderID": "F7b5582cae50f36762a06676166f8e99ff5028517af16fe5434ead42c96b7c9a1", "searchTerms": [], + "slug": "tf-pmsplv-8", "title": "PLv8", "where": "(chart.data->'tableFolders'->>'PLv') = '8'" }, @@ -34247,6 +37759,7 @@ "inactive": false, "legacyFolderID": "F01e639929ef834e68c983624c68398e8efed0801ebdfd367eba2124625203e9e", "searchTerms": [], + "slug": "tf-pmsplv-9", "title": "PLv9", "where": "(chart.data->'tableFolders'->>'PLv') = '9'" }, @@ -34256,9 +37769,10 @@ "inactive": false, "legacyFolderID": "F1dae653d7e838a57ccbb14ee7b5b2fce52427edad023fe5892ae70e46c512881", "searchTerms": [], + "slug": "1-kaimei", "title": "Level 1 (Kaimei Riddles)", - "where": "chart.level = '1'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '1'" }, { "game": "popn", @@ -34266,9 +37780,10 @@ "inactive": true, "legacyFolderID": "Fe8b4afaddf9c4f5052f034969e88da684319db2efd74c698cf4903d1a1566625", "searchTerms": [], + "slug": "1-peace", "title": "Level 1 (peace)", - "where": "chart.level = '1'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '1'" }, { "game": "popn", @@ -34276,9 +37791,10 @@ "inactive": false, "legacyFolderID": "Fc16a3e59241c973c696ae94da201a3a2f7598ff6a4b9db5c95a285aa24c01908", "searchTerms": [], + "slug": "1-unilab", "title": "Level 1 (Unilab)", - "where": "chart.level = '1'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '1'" }, { "game": "popn", @@ -34286,9 +37802,10 @@ "inactive": false, "legacyFolderID": "F432b3d528571350d85c36e3020bed7d3214596ef2fa8253dbb50c5870aed0a9f", "searchTerms": [], + "slug": "ge-1-le-9-kaimei", "title": "Level 1-9 (Kaimei Riddles)", - "where": "chart.level_num >= 1 AND chart.level_num <= 9", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level_num >= 1 AND chart.level_num <= 9" }, { "game": "popn", @@ -34296,9 +37813,10 @@ "inactive": true, "legacyFolderID": "F0eba4509733294349b3d9140e943f33c80cc545050a61f87b62ebd9980916f0a", "searchTerms": [], + "slug": "ge-1-le-9-peace", "title": "Level 1-9 (peace)", - "where": "chart.level_num >= 1 AND chart.level_num <= 9", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level_num >= 1 AND chart.level_num <= 9" }, { "game": "popn", @@ -34306,9 +37824,10 @@ "inactive": false, "legacyFolderID": "F31bb32091f07c65b233c022a1703e31dbab62c9f880502858891ff00888c48b7", "searchTerms": [], + "slug": "ge-1-le-9-unilab", "title": "Level 1-9 (Unilab)", - "where": "chart.level_num >= 1 AND chart.level_num <= 9", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level_num >= 1 AND chart.level_num <= 9" }, { "game": "popn", @@ -34316,9 +37835,10 @@ "inactive": false, "legacyFolderID": "F0f11e1a4b39aeaaa7b381992c6a404e3eb5fc338f22c0e8b60446dd96f1294ed", "searchTerms": [], + "slug": "10-kaimei", "title": "Level 10 (Kaimei Riddles)", - "where": "chart.level = '10'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '10'" }, { "game": "popn", @@ -34326,9 +37846,10 @@ "inactive": true, "legacyFolderID": "Febf65f4694c75e90c60461bff77c6e72ae2ba6f321b81d22c8e7ab0a6347eddb", "searchTerms": [], + "slug": "10-peace", "title": "Level 10 (peace)", - "where": "chart.level = '10'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '10'" }, { "game": "popn", @@ -34336,9 +37857,10 @@ "inactive": false, "legacyFolderID": "Fa6865793a37f4c61b24aa3dad173a796e58ecec93efa2863b601f7bc71694e86", "searchTerms": [], + "slug": "10-unilab", "title": "Level 10 (Unilab)", - "where": "chart.level = '10'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '10'" }, { "game": "popn", @@ -34346,9 +37868,10 @@ "inactive": false, "legacyFolderID": "F0812a30c822139cb0844ddfd1ad6a72996aefc9fad283a84e5572be5e6143b25", "searchTerms": [], + "slug": "ge-10-le-19-kaimei", "title": "Level 10-19 (Kaimei Riddles)", - "where": "chart.level_num >= 10 AND chart.level_num <= 19", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level_num >= 10 AND chart.level_num <= 19" }, { "game": "popn", @@ -34356,9 +37879,10 @@ "inactive": true, "legacyFolderID": "Fa30199d64c0e5c0962bd1a341a0da7c44a923e893d2746eb2d0d996cb1586c7b", "searchTerms": [], + "slug": "ge-10-le-19-peace", "title": "Level 10-19 (peace)", - "where": "chart.level_num >= 10 AND chart.level_num <= 19", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level_num >= 10 AND chart.level_num <= 19" }, { "game": "popn", @@ -34366,9 +37890,10 @@ "inactive": false, "legacyFolderID": "F82fb4cb2f4368273899b23794712588f41c5b6facd212eec54e3a27c462ef758", "searchTerms": [], + "slug": "ge-10-le-19-unilab", "title": "Level 10-19 (Unilab)", - "where": "chart.level_num >= 10 AND chart.level_num <= 19", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level_num >= 10 AND chart.level_num <= 19" }, { "game": "popn", @@ -34376,9 +37901,10 @@ "inactive": false, "legacyFolderID": "F61fd802646e66e09f154f0cd5a77da11c499d83538b305175b636b6ba819433a", "searchTerms": [], + "slug": "11-kaimei", "title": "Level 11 (Kaimei Riddles)", - "where": "chart.level = '11'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '11'" }, { "game": "popn", @@ -34386,9 +37912,10 @@ "inactive": true, "legacyFolderID": "Fc984b514fa58307f658ec3703f105891bfdb8ff35dc584290f1c7cef7453e429", "searchTerms": [], + "slug": "11-peace", "title": "Level 11 (peace)", - "where": "chart.level = '11'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '11'" }, { "game": "popn", @@ -34396,9 +37923,10 @@ "inactive": false, "legacyFolderID": "F3d5cae48b934aeda8006a99f006a5e3c0874eb538248b6faf2f2e09c1c7e8c8d", "searchTerms": [], + "slug": "11-unilab", "title": "Level 11 (Unilab)", - "where": "chart.level = '11'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '11'" }, { "game": "popn", @@ -34406,9 +37934,10 @@ "inactive": false, "legacyFolderID": "Ff5f00baf9594072bd4ac829aedea3f6307df3bdb5e287a3add87f8d0f74afc5d", "searchTerms": [], + "slug": "12-kaimei", "title": "Level 12 (Kaimei Riddles)", - "where": "chart.level = '12'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '12'" }, { "game": "popn", @@ -34416,9 +37945,10 @@ "inactive": true, "legacyFolderID": "F1769fc0c330932dabe9486f945f047fa0f5ee4fa5adabda01a78ab5aea6a0292", "searchTerms": [], + "slug": "12-peace", "title": "Level 12 (peace)", - "where": "chart.level = '12'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '12'" }, { "game": "popn", @@ -34426,9 +37956,10 @@ "inactive": false, "legacyFolderID": "Fe0818e5d452d78d63a11b5136c3f8409294e5a8b02ade4403b0ba4f3a983fdd8", "searchTerms": [], + "slug": "12-unilab", "title": "Level 12 (Unilab)", - "where": "chart.level = '12'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '12'" }, { "game": "popn", @@ -34436,9 +37967,10 @@ "inactive": false, "legacyFolderID": "F17c6326cbbd0cfa8f4c68150cb2f858cc9c05a6397390f1f28b2105cb1265a33", "searchTerms": [], + "slug": "13-kaimei", "title": "Level 13 (Kaimei Riddles)", - "where": "chart.level = '13'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '13'" }, { "game": "popn", @@ -34446,9 +37978,10 @@ "inactive": true, "legacyFolderID": "F883076b3dde512283ec4797276d9116a6140c717db213fb8d4bd6906656971cd", "searchTerms": [], + "slug": "13-peace", "title": "Level 13 (peace)", - "where": "chart.level = '13'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '13'" }, { "game": "popn", @@ -34456,9 +37989,10 @@ "inactive": false, "legacyFolderID": "F924c37c9bce17b0f22383b2651db6619516e959356debf98084aef772977567f", "searchTerms": [], + "slug": "13-unilab", "title": "Level 13 (Unilab)", - "where": "chart.level = '13'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '13'" }, { "game": "popn", @@ -34466,9 +38000,10 @@ "inactive": false, "legacyFolderID": "Feca28607a37a26db87770557dee1fe82be209df280c0e28558b060ae6f9df803", "searchTerms": [], + "slug": "14-kaimei", "title": "Level 14 (Kaimei Riddles)", - "where": "chart.level = '14'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '14'" }, { "game": "popn", @@ -34476,9 +38011,10 @@ "inactive": true, "legacyFolderID": "Fa039f444f46585a3066360e830d8bbd9b3d672aa23c710c5a33c5d6fe3170597", "searchTerms": [], + "slug": "14-peace", "title": "Level 14 (peace)", - "where": "chart.level = '14'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '14'" }, { "game": "popn", @@ -34486,9 +38022,10 @@ "inactive": false, "legacyFolderID": "F58e2c8776005494db5e710bc980962a5682f6e39e5582200ac9d6e4b48efc9b0", "searchTerms": [], + "slug": "14-unilab", "title": "Level 14 (Unilab)", - "where": "chart.level = '14'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '14'" }, { "game": "popn", @@ -34496,9 +38033,10 @@ "inactive": false, "legacyFolderID": "F22619e6dd83962e6293c7a23b16be7c9a49f66b89716ae910827c30ae080feb2", "searchTerms": [], + "slug": "15-kaimei", "title": "Level 15 (Kaimei Riddles)", - "where": "chart.level = '15'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '15'" }, { "game": "popn", @@ -34506,9 +38044,10 @@ "inactive": true, "legacyFolderID": "F75de7d2a9fc91f1ca8f101879b563c713cf55d194b12fb5b0b5dde1d37bea29f", "searchTerms": [], + "slug": "15-peace", "title": "Level 15 (peace)", - "where": "chart.level = '15'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '15'" }, { "game": "popn", @@ -34516,9 +38055,10 @@ "inactive": false, "legacyFolderID": "F52a8d173b8cc44cd0765a3880c0f612c0a0ee15762bf4fef1e14cf9dcf5325de", "searchTerms": [], + "slug": "15-unilab", "title": "Level 15 (Unilab)", - "where": "chart.level = '15'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '15'" }, { "game": "popn", @@ -34526,9 +38066,10 @@ "inactive": false, "legacyFolderID": "F91419b1c9ac3af6c668e8ede81d3b2ec3e7f62d149e78154f71fb3d7156f6450", "searchTerms": [], + "slug": "16-kaimei", "title": "Level 16 (Kaimei Riddles)", - "where": "chart.level = '16'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '16'" }, { "game": "popn", @@ -34536,9 +38077,10 @@ "inactive": true, "legacyFolderID": "F1d4ceff818f5620be4ac60eb9e4ea5e0360dde3107695c12f9663fb880cc3792", "searchTerms": [], + "slug": "16-peace", "title": "Level 16 (peace)", - "where": "chart.level = '16'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '16'" }, { "game": "popn", @@ -34546,9 +38088,10 @@ "inactive": false, "legacyFolderID": "F1bcd22810a7be3b85ac8f1823913e16a0417b823de2cf6cde5d2e7b614c2f250", "searchTerms": [], + "slug": "16-unilab", "title": "Level 16 (Unilab)", - "where": "chart.level = '16'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '16'" }, { "game": "popn", @@ -34556,9 +38099,10 @@ "inactive": false, "legacyFolderID": "Fe4eb0073eb309411d3c1f9a9f51e4fd0f8735c942b076a59e35b6b5b424adf1f", "searchTerms": [], + "slug": "17-kaimei", "title": "Level 17 (Kaimei Riddles)", - "where": "chart.level = '17'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '17'" }, { "game": "popn", @@ -34566,9 +38110,10 @@ "inactive": true, "legacyFolderID": "Ff8571cfa8e25c313036cd44093045658d8735b2acc09a4734ae7e33ae33b1ebd", "searchTerms": [], + "slug": "17-peace", "title": "Level 17 (peace)", - "where": "chart.level = '17'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '17'" }, { "game": "popn", @@ -34576,9 +38121,10 @@ "inactive": false, "legacyFolderID": "F39f4c3cafa0dec47b17c1cf9307a0bea6b3db96ba7d4b493d0c5931d8d0e2a41", "searchTerms": [], + "slug": "17-unilab", "title": "Level 17 (Unilab)", - "where": "chart.level = '17'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '17'" }, { "game": "popn", @@ -34586,9 +38132,10 @@ "inactive": false, "legacyFolderID": "Fbdd6eca59f066ae0710b8ff96e7d3f68900849d814df13506672b035b3015520", "searchTerms": [], + "slug": "18-kaimei", "title": "Level 18 (Kaimei Riddles)", - "where": "chart.level = '18'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '18'" }, { "game": "popn", @@ -34596,9 +38143,10 @@ "inactive": true, "legacyFolderID": "F9c5fef65312b856c6f1fd39c301552a8fb966e3ea4d4828b08f044ffec983a92", "searchTerms": [], + "slug": "18-peace", "title": "Level 18 (peace)", - "where": "chart.level = '18'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '18'" }, { "game": "popn", @@ -34606,9 +38154,10 @@ "inactive": false, "legacyFolderID": "Ffb0814ebaca88c589f361adb58064fdc47201792e6c410804d3061e566f304ea", "searchTerms": [], + "slug": "18-unilab", "title": "Level 18 (Unilab)", - "where": "chart.level = '18'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '18'" }, { "game": "popn", @@ -34616,9 +38165,10 @@ "inactive": false, "legacyFolderID": "F28d4ee9a9a3ba52245db773a56a669b974f5031905761a9c12d7f2c4034a2c9d", "searchTerms": [], + "slug": "19-kaimei", "title": "Level 19 (Kaimei Riddles)", - "where": "chart.level = '19'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '19'" }, { "game": "popn", @@ -34626,9 +38176,10 @@ "inactive": true, "legacyFolderID": "F64f4bfa76b6452496f47bca937c0a7942c1ab470733aa48e37e1301f33f7ac81", "searchTerms": [], + "slug": "19-peace", "title": "Level 19 (peace)", - "where": "chart.level = '19'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '19'" }, { "game": "popn", @@ -34636,9 +38187,10 @@ "inactive": false, "legacyFolderID": "F1dd648ab8c58821c708845a8d63b6ce50f13a723ac6a32fa93d69c917f2a8de8", "searchTerms": [], + "slug": "19-unilab", "title": "Level 19 (Unilab)", - "where": "chart.level = '19'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '19'" }, { "game": "popn", @@ -34646,9 +38198,10 @@ "inactive": false, "legacyFolderID": "Fc4194e74ed372abeaab54cbe682b0c931369dfda66b8a9a7567e601447c472be", "searchTerms": [], + "slug": "2-kaimei", "title": "Level 2 (Kaimei Riddles)", - "where": "chart.level = '2'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '2'" }, { "game": "popn", @@ -34656,9 +38209,10 @@ "inactive": true, "legacyFolderID": "F10d9c0c9f04c47ec2fc75f9d6608666259817f7e9ea396e12170cab433857ce4", "searchTerms": [], + "slug": "2-peace", "title": "Level 2 (peace)", - "where": "chart.level = '2'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '2'" }, { "game": "popn", @@ -34666,9 +38220,10 @@ "inactive": false, "legacyFolderID": "F7257b2f3d4c5a16198c5f4cefcd5e6ec97372ecf835ec2f601a09147cfa4a737", "searchTerms": [], + "slug": "2-unilab", "title": "Level 2 (Unilab)", - "where": "chart.level = '2'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '2'" }, { "game": "popn", @@ -34676,9 +38231,10 @@ "inactive": false, "legacyFolderID": "F2b0872b14c2bc785242151d12d62cc5c76ea0795db834879d07946111f781471", "searchTerms": [], + "slug": "20-kaimei", "title": "Level 20 (Kaimei Riddles)", - "where": "chart.level = '20'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '20'" }, { "game": "popn", @@ -34686,9 +38242,10 @@ "inactive": true, "legacyFolderID": "Fd94d8fb2497bdedde433f084cc9c70c617fd5aa0e179a201fb2ba2ab31d79a91", "searchTerms": [], + "slug": "20-peace", "title": "Level 20 (peace)", - "where": "chart.level = '20'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '20'" }, { "game": "popn", @@ -34696,9 +38253,10 @@ "inactive": false, "legacyFolderID": "Fa627b5f49062a79c891c694623028a30b18f34b667e35848ad10f8035710156f", "searchTerms": [], + "slug": "20-unilab", "title": "Level 20 (Unilab)", - "where": "chart.level = '20'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '20'" }, { "game": "popn", @@ -34706,9 +38264,10 @@ "inactive": false, "legacyFolderID": "Fb93617697ed20a1feb6d6f8a5b6e192c3e9c510fa66a509095282595c4ba7ca1", "searchTerms": [], + "slug": "ge-20-le-29-kaimei", "title": "Level 20-29 (Kaimei Riddles)", - "where": "chart.level_num >= 20 AND chart.level_num <= 29", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level_num >= 20 AND chart.level_num <= 29" }, { "game": "popn", @@ -34716,9 +38275,10 @@ "inactive": true, "legacyFolderID": "Fb429c7dd6ccb37a4fdab99947c9af2532ac08342bd17acfd2e62898be2488310", "searchTerms": [], + "slug": "ge-20-le-29-peace", "title": "Level 20-29 (peace)", - "where": "chart.level_num >= 20 AND chart.level_num <= 29", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level_num >= 20 AND chart.level_num <= 29" }, { "game": "popn", @@ -34726,9 +38286,10 @@ "inactive": false, "legacyFolderID": "F76a6ad8cc6142ad0b4796c3fd1f94332569a4b048af639da46e83e7ef0945bc6", "searchTerms": [], + "slug": "ge-20-le-29-unilab", "title": "Level 20-29 (Unilab)", - "where": "chart.level_num >= 20 AND chart.level_num <= 29", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level_num >= 20 AND chart.level_num <= 29" }, { "game": "popn", @@ -34736,9 +38297,10 @@ "inactive": false, "legacyFolderID": "F8752d62cd659c58dab58983751b4e34c04edb8ff9b34aef48fb90884a709b669", "searchTerms": [], + "slug": "21-kaimei", "title": "Level 21 (Kaimei Riddles)", - "where": "chart.level = '21'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '21'" }, { "game": "popn", @@ -34746,9 +38308,10 @@ "inactive": true, "legacyFolderID": "F93cdd761961851de000218a4235a60477a8fa13064ec5f5492f1bffe9b446091", "searchTerms": [], + "slug": "21-peace", "title": "Level 21 (peace)", - "where": "chart.level = '21'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '21'" }, { "game": "popn", @@ -34756,9 +38319,10 @@ "inactive": false, "legacyFolderID": "F746cf6442a767afc5a311b3cc3332e543b047cbb3ca2465c57ac74fe0d5695e3", "searchTerms": [], + "slug": "21-unilab", "title": "Level 21 (Unilab)", - "where": "chart.level = '21'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '21'" }, { "game": "popn", @@ -34766,9 +38330,10 @@ "inactive": false, "legacyFolderID": "F893f44f1288b992892fc3fba7dbaf889ab983ecd1348d65e50a8e5def34cff7d", "searchTerms": [], + "slug": "22-kaimei", "title": "Level 22 (Kaimei Riddles)", - "where": "chart.level = '22'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '22'" }, { "game": "popn", @@ -34776,9 +38341,10 @@ "inactive": true, "legacyFolderID": "Fe6ad94121764e2d224de023089b4113810067ffb14aa13dbc207d5b477dbb7a2", "searchTerms": [], + "slug": "22-peace", "title": "Level 22 (peace)", - "where": "chart.level = '22'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '22'" }, { "game": "popn", @@ -34786,9 +38352,10 @@ "inactive": false, "legacyFolderID": "F69bd925ff234b217837333d9f47a526c57a1f5a428be72e1b4fe9008096a4082", "searchTerms": [], + "slug": "22-unilab", "title": "Level 22 (Unilab)", - "where": "chart.level = '22'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '22'" }, { "game": "popn", @@ -34796,9 +38363,10 @@ "inactive": false, "legacyFolderID": "F970a7358d34084758327b813803f92dac807a70fe1709ce0a9c160c96fce8689", "searchTerms": [], + "slug": "23-kaimei", "title": "Level 23 (Kaimei Riddles)", - "where": "chart.level = '23'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '23'" }, { "game": "popn", @@ -34806,9 +38374,10 @@ "inactive": true, "legacyFolderID": "Ff5914a801172e106314cbadd554bc23dde43f998cbfdae8a9366e77f58671357", "searchTerms": [], + "slug": "23-peace", "title": "Level 23 (peace)", - "where": "chart.level = '23'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '23'" }, { "game": "popn", @@ -34816,9 +38385,10 @@ "inactive": false, "legacyFolderID": "Fa8bd04ab0d6eed2609eccc895fe626a4c64d91d90d44d861028085dccfac71a5", "searchTerms": [], + "slug": "23-unilab", "title": "Level 23 (Unilab)", - "where": "chart.level = '23'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '23'" }, { "game": "popn", @@ -34826,9 +38396,10 @@ "inactive": false, "legacyFolderID": "F93a73c88a13e27a30ab0ee44b0d2246f5b200eb2f9f76136f997ffa2553aa462", "searchTerms": [], + "slug": "24-kaimei", "title": "Level 24 (Kaimei Riddles)", - "where": "chart.level = '24'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '24'" }, { "game": "popn", @@ -34836,9 +38407,10 @@ "inactive": true, "legacyFolderID": "Fb75c8a98702bc0027b239ede1c44ef200fd61aa5b1cdd070eb0d88ac7bd89b48", "searchTerms": [], + "slug": "24-peace", "title": "Level 24 (peace)", - "where": "chart.level = '24'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '24'" }, { "game": "popn", @@ -34846,9 +38418,10 @@ "inactive": false, "legacyFolderID": "F5be142a4a5b0cf0b007ec0283360cb50b1133f8d8871d2aa50ba00202a5636fa", "searchTerms": [], + "slug": "24-unilab", "title": "Level 24 (Unilab)", - "where": "chart.level = '24'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '24'" }, { "game": "popn", @@ -34856,9 +38429,10 @@ "inactive": false, "legacyFolderID": "Ff7c0ee7932747e06a620bd7cedef3e8c152907411c827f38c3e20512d761b7cf", "searchTerms": [], + "slug": "25-kaimei", "title": "Level 25 (Kaimei Riddles)", - "where": "chart.level = '25'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '25'" }, { "game": "popn", @@ -34866,9 +38440,10 @@ "inactive": true, "legacyFolderID": "Fe4c9fa7a4213751e2d0a59eab0722835928b50deaa6fd5f8c34286b213d6f779", "searchTerms": [], + "slug": "25-peace", "title": "Level 25 (peace)", - "where": "chart.level = '25'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '25'" }, { "game": "popn", @@ -34876,9 +38451,10 @@ "inactive": false, "legacyFolderID": "F25ab4b290aec401544c14c2d7f497d70dfee30e8b117096c40db5c8047b1c803", "searchTerms": [], + "slug": "25-unilab", "title": "Level 25 (Unilab)", - "where": "chart.level = '25'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '25'" }, { "game": "popn", @@ -34886,9 +38462,10 @@ "inactive": false, "legacyFolderID": "F33a0bcb2427e76bebca8bb1dea59f69647f9a959979a05979a34b4877278624b", "searchTerms": [], + "slug": "26-kaimei", "title": "Level 26 (Kaimei Riddles)", - "where": "chart.level = '26'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '26'" }, { "game": "popn", @@ -34896,9 +38473,10 @@ "inactive": true, "legacyFolderID": "Fad8587ffb340090c69bb9a05c1c6dc035ec8755d7d317278e4efdf783ceb2b2f", "searchTerms": [], + "slug": "26-peace", "title": "Level 26 (peace)", - "where": "chart.level = '26'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '26'" }, { "game": "popn", @@ -34906,9 +38484,10 @@ "inactive": false, "legacyFolderID": "F06c969775c7def45e3ad7519ac5ad6fced7f58266e4bd31a396d7db0c9ea7af6", "searchTerms": [], + "slug": "26-unilab", "title": "Level 26 (Unilab)", - "where": "chart.level = '26'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '26'" }, { "game": "popn", @@ -34916,9 +38495,10 @@ "inactive": false, "legacyFolderID": "F0d51a947cc3c4e03aece3bf2567d50c47fc549b4d7d670686e03dff46347cbf4", "searchTerms": [], + "slug": "27-kaimei", "title": "Level 27 (Kaimei Riddles)", - "where": "chart.level = '27'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '27'" }, { "game": "popn", @@ -34926,9 +38506,10 @@ "inactive": true, "legacyFolderID": "Faf6938b5c623ac0c68ff4fd99d8d6272f4192f3ac6eb1171e0d8895dc54b392f", "searchTerms": [], + "slug": "27-peace", "title": "Level 27 (peace)", - "where": "chart.level = '27'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '27'" }, { "game": "popn", @@ -34936,9 +38517,10 @@ "inactive": false, "legacyFolderID": "Feb46faa22291f0388420978e354e053b75d9fa43b62c275e49bef6cec4e043fb", "searchTerms": [], + "slug": "27-unilab", "title": "Level 27 (Unilab)", - "where": "chart.level = '27'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '27'" }, { "game": "popn", @@ -34946,9 +38528,10 @@ "inactive": false, "legacyFolderID": "F2a1685f9b7b0ab392ebb45f13aee9c6150d547f888c2804df25e6c4f810fd3f7", "searchTerms": [], + "slug": "28-kaimei", "title": "Level 28 (Kaimei Riddles)", - "where": "chart.level = '28'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '28'" }, { "game": "popn", @@ -34956,9 +38539,10 @@ "inactive": true, "legacyFolderID": "F0b6fb63a3c5ab92b2114e14a6b60ed9c03b457cdf5b70efd1fc1c7cc1ea7bbbc", "searchTerms": [], + "slug": "28-peace", "title": "Level 28 (peace)", - "where": "chart.level = '28'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '28'" }, { "game": "popn", @@ -34966,9 +38550,10 @@ "inactive": false, "legacyFolderID": "F3a7ccc7b59b6945ddaf408490e74342bde48a7599bbb5a0c21f24f02d1633d86", "searchTerms": [], + "slug": "28-unilab", "title": "Level 28 (Unilab)", - "where": "chart.level = '28'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '28'" }, { "game": "popn", @@ -34976,9 +38561,10 @@ "inactive": false, "legacyFolderID": "Fc98f1d18f832c8914e4c8c028cbbb3cf13622ccc645c2771dd6284af43364abb", "searchTerms": [], + "slug": "29-kaimei", "title": "Level 29 (Kaimei Riddles)", - "where": "chart.level = '29'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '29'" }, { "game": "popn", @@ -34986,9 +38572,10 @@ "inactive": true, "legacyFolderID": "F9fe70bcddde30176f6507dfaa301d4a0ad66ac02b4eea294c4b9ba4c39f85414", "searchTerms": [], + "slug": "29-peace", "title": "Level 29 (peace)", - "where": "chart.level = '29'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '29'" }, { "game": "popn", @@ -34996,9 +38583,10 @@ "inactive": false, "legacyFolderID": "F80c26535bc3f9efe9254f6adc2b55f601d1280d47d675b6695fd5e17e666a72b", "searchTerms": [], + "slug": "29-unilab", "title": "Level 29 (Unilab)", - "where": "chart.level = '29'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '29'" }, { "game": "popn", @@ -35006,9 +38594,10 @@ "inactive": false, "legacyFolderID": "F34cd25483db5dba518de573fdf6ab15cd064ca8951b0c0c78c0b8bd42ab45db0", "searchTerms": [], + "slug": "3-kaimei", "title": "Level 3 (Kaimei Riddles)", - "where": "chart.level = '3'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '3'" }, { "game": "popn", @@ -35016,9 +38605,10 @@ "inactive": true, "legacyFolderID": "F1b4d49075953abacfc57f0bf8c3f5ab616b508eaefe6d4da90ced5a5f281d17a", "searchTerms": [], + "slug": "3-peace", "title": "Level 3 (peace)", - "where": "chart.level = '3'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '3'" }, { "game": "popn", @@ -35026,9 +38616,10 @@ "inactive": false, "legacyFolderID": "F7274d96cc1d75a6133ffee349fcee08b99c14d562cf5ecb6f02f86089af6185d", "searchTerms": [], + "slug": "3-unilab", "title": "Level 3 (Unilab)", - "where": "chart.level = '3'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '3'" }, { "game": "popn", @@ -35036,9 +38627,10 @@ "inactive": false, "legacyFolderID": "F6486f668e7be8be24da029b029ce056502e87845b5e52cecdc733518998e266e", "searchTerms": [], + "slug": "30-kaimei", "title": "Level 30 (Kaimei Riddles)", - "where": "chart.level = '30'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '30'" }, { "game": "popn", @@ -35046,9 +38638,10 @@ "inactive": true, "legacyFolderID": "Ff054ed80f6a4aa50038ce84860f6c9a8108b9141a66f1065c868b8ad7d5411dc", "searchTerms": [], + "slug": "30-peace", "title": "Level 30 (peace)", - "where": "chart.level = '30'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '30'" }, { "game": "popn", @@ -35056,9 +38649,10 @@ "inactive": false, "legacyFolderID": "Ffc79257b4b5c0fa030e46289276524b72a34a5b0e69b9f7237e7c084a115cb0f", "searchTerms": [], + "slug": "30-unilab", "title": "Level 30 (Unilab)", - "where": "chart.level = '30'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '30'" }, { "game": "popn", @@ -35066,9 +38660,10 @@ "inactive": false, "legacyFolderID": "F2514591fb0509d98a530fbfe325a25bf408fccc739b4e47c30317cc848327c78", "searchTerms": [], + "slug": "31-kaimei", "title": "Level 31 (Kaimei Riddles)", - "where": "chart.level = '31'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '31'" }, { "game": "popn", @@ -35076,9 +38671,10 @@ "inactive": true, "legacyFolderID": "F3c5715f6595048ac14d54980b10ba9cb0ed43ca6c42e3b81c5ec7ad647309a9d", "searchTerms": [], + "slug": "31-peace", "title": "Level 31 (peace)", - "where": "chart.level = '31'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '31'" }, { "game": "popn", @@ -35086,9 +38682,10 @@ "inactive": false, "legacyFolderID": "F2fcd0312e82917e2d76d9494b653180c2b04ac2112d10b51c67c61f6cd10bfa1", "searchTerms": [], + "slug": "31-unilab", "title": "Level 31 (Unilab)", - "where": "chart.level = '31'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '31'" }, { "game": "popn", @@ -35096,9 +38693,10 @@ "inactive": false, "legacyFolderID": "F36fc553d7aad23757645205b9dba495b03b098aaf71bc1ec33a7d49ef56fed65", "searchTerms": [], + "slug": "32-kaimei", "title": "Level 32 (Kaimei Riddles)", - "where": "chart.level = '32'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '32'" }, { "game": "popn", @@ -35106,9 +38704,10 @@ "inactive": true, "legacyFolderID": "F66c687a6be070e65ca7bd4cec04d41a90cbda979b560fdd2384c3f5286d8b736", "searchTerms": [], + "slug": "32-peace", "title": "Level 32 (peace)", - "where": "chart.level = '32'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '32'" }, { "game": "popn", @@ -35116,9 +38715,10 @@ "inactive": false, "legacyFolderID": "Fcac66115d41f12c288ac34d609b55cb2e5e64922f3c11393ffe45f30902cc1d8", "searchTerms": [], + "slug": "32-unilab", "title": "Level 32 (Unilab)", - "where": "chart.level = '32'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '32'" }, { "game": "popn", @@ -35126,9 +38726,10 @@ "inactive": false, "legacyFolderID": "F118b4e283095209578e6e40e55ddd976d070961c3a5c4b9f28e7eb9ee8cfbc1c", "searchTerms": [], + "slug": "33-kaimei", "title": "Level 33 (Kaimei Riddles)", - "where": "chart.level = '33'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '33'" }, { "game": "popn", @@ -35136,9 +38737,10 @@ "inactive": true, "legacyFolderID": "Fdf1271c9394a91419295534351babe28aaf609dfc98521511848df6271412d9a", "searchTerms": [], + "slug": "33-peace", "title": "Level 33 (peace)", - "where": "chart.level = '33'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '33'" }, { "game": "popn", @@ -35146,9 +38748,10 @@ "inactive": false, "legacyFolderID": "F706fdfee9b49ac263f4d5ce760f5176d2ad97f16fd753130c7341a7e226422aa", "searchTerms": [], + "slug": "33-unilab", "title": "Level 33 (Unilab)", - "where": "chart.level = '33'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '33'" }, { "game": "popn", @@ -35156,9 +38759,10 @@ "inactive": false, "legacyFolderID": "F21e5707dc75c21b07d9d674d24fa5325717474a9d11489bcd55402a87fa8e4b8", "searchTerms": [], + "slug": "34-kaimei", "title": "Level 34 (Kaimei Riddles)", - "where": "chart.level = '34'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '34'" }, { "game": "popn", @@ -35166,9 +38770,10 @@ "inactive": true, "legacyFolderID": "F5ece339abb88e1feef86d2335c13a2d1e0e760076fac93e67e0a8f78c31c1a14", "searchTerms": [], + "slug": "34-peace", "title": "Level 34 (peace)", - "where": "chart.level = '34'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '34'" }, { "game": "popn", @@ -35176,9 +38781,10 @@ "inactive": false, "legacyFolderID": "F57a4649078946be4e71b08ad8cd27c1aa7cdfb61e1648aa9745b995cf643e92b", "searchTerms": [], + "slug": "34-unilab", "title": "Level 34 (Unilab)", - "where": "chart.level = '34'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '34'" }, { "game": "popn", @@ -35186,9 +38792,10 @@ "inactive": false, "legacyFolderID": "Fd6af4e9beeb8235fb2e60c847e622d396ca4425021baed045bebf290ba92d51f", "searchTerms": [], + "slug": "35-kaimei", "title": "Level 35 (Kaimei Riddles)", - "where": "chart.level = '35'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '35'" }, { "game": "popn", @@ -35196,9 +38803,10 @@ "inactive": true, "legacyFolderID": "F106daa09f613a739e2f053b65080461c8994dfca9edeb1c4427253a515fa0a62", "searchTerms": [], + "slug": "35-peace", "title": "Level 35 (peace)", - "where": "chart.level = '35'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '35'" }, { "game": "popn", @@ -35206,9 +38814,10 @@ "inactive": false, "legacyFolderID": "F5569ae60b72e068ee393278ae51415e0452f52f1454643253f79ddb5d9f57fa4", "searchTerms": [], + "slug": "35-unilab", "title": "Level 35 (Unilab)", - "where": "chart.level = '35'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '35'" }, { "game": "popn", @@ -35216,9 +38825,10 @@ "inactive": false, "legacyFolderID": "Fb09463053139d58ec87d5b7df565d97531368f22a638f38be13bf90e8a9fa306", "searchTerms": [], + "slug": "36-kaimei", "title": "Level 36 (Kaimei Riddles)", - "where": "chart.level = '36'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '36'" }, { "game": "popn", @@ -35226,9 +38836,10 @@ "inactive": true, "legacyFolderID": "F3aa27d02d795d603db53d915705340a181923bc02d7d6e2c4b283d7b5cd19702", "searchTerms": [], + "slug": "36-peace", "title": "Level 36 (peace)", - "where": "chart.level = '36'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '36'" }, { "game": "popn", @@ -35236,9 +38847,10 @@ "inactive": false, "legacyFolderID": "F2a145eb30b866e48c2f58c4f5b0079bd660897241d4d47a883edf30428e1ea75", "searchTerms": [], + "slug": "36-unilab", "title": "Level 36 (Unilab)", - "where": "chart.level = '36'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '36'" }, { "game": "popn", @@ -35246,9 +38858,10 @@ "inactive": false, "legacyFolderID": "F5bc62603d58922834bbdb69a4b0ee14fd9b23a72d58c3db203c78d3064e28442", "searchTerms": [], + "slug": "37-kaimei", "title": "Level 37 (Kaimei Riddles)", - "where": "chart.level = '37'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '37'" }, { "game": "popn", @@ -35256,9 +38869,10 @@ "inactive": true, "legacyFolderID": "F3b349c5de7e23f57690726fbbc6f287a0db7d39cc687356bee5e413c3d7b9a70", "searchTerms": [], + "slug": "37-peace", "title": "Level 37 (peace)", - "where": "chart.level = '37'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '37'" }, { "game": "popn", @@ -35266,9 +38880,10 @@ "inactive": false, "legacyFolderID": "F73220404ce13eb5bd14dbee365cf172fcd0d173957e696ef8a0dbd8b34a38005", "searchTerms": [], + "slug": "37-unilab", "title": "Level 37 (Unilab)", - "where": "chart.level = '37'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '37'" }, { "game": "popn", @@ -35276,9 +38891,10 @@ "inactive": false, "legacyFolderID": "F1157602c6e4c9fe94e3be01d92ecb80a71fa5b8bd2a98ab35bdf0726a497176a", "searchTerms": [], + "slug": "38-kaimei", "title": "Level 38 (Kaimei Riddles)", - "where": "chart.level = '38'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '38'" }, { "game": "popn", @@ -35286,9 +38902,10 @@ "inactive": true, "legacyFolderID": "Fa5e70d72dadbdafbdd777a64940b3a0acce4edb315bd94ffbe7a87834099da42", "searchTerms": [], + "slug": "38-peace", "title": "Level 38 (peace)", - "where": "chart.level = '38'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '38'" }, { "game": "popn", @@ -35296,9 +38913,10 @@ "inactive": false, "legacyFolderID": "F780ed319938778a4c8cb3e3364758e31f5036453594e2c2a9b76334ca82b2740", "searchTerms": [], + "slug": "38-unilab", "title": "Level 38 (Unilab)", - "where": "chart.level = '38'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '38'" }, { "game": "popn", @@ -35306,9 +38924,10 @@ "inactive": false, "legacyFolderID": "F8baa07f50f06733c8dc67dc9561ee8b18f60f4955bde8f7bfbb0df4658a9f112", "searchTerms": [], + "slug": "39-kaimei", "title": "Level 39 (Kaimei Riddles)", - "where": "chart.level = '39'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '39'" }, { "game": "popn", @@ -35316,9 +38935,10 @@ "inactive": true, "legacyFolderID": "Fa9ca654afb17cbf284da912a5065aec0bf865164841d57c10475c92ee8e7fe03", "searchTerms": [], + "slug": "39-peace", "title": "Level 39 (peace)", - "where": "chart.level = '39'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '39'" }, { "game": "popn", @@ -35326,9 +38946,10 @@ "inactive": false, "legacyFolderID": "F8d2662aeb238fd62461dd74ddd39a6d70e95fcf1cea7a4e7e5786d140dfa6283", "searchTerms": [], + "slug": "39-unilab", "title": "Level 39 (Unilab)", - "where": "chart.level = '39'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '39'" }, { "game": "popn", @@ -35336,9 +38957,10 @@ "inactive": false, "legacyFolderID": "Fcc3acdd57f1921b9fa14fa67a7a2f3ad9aca1899dd22c3d5970f4216adcb2f33", "searchTerms": [], + "slug": "4-kaimei", "title": "Level 4 (Kaimei Riddles)", - "where": "chart.level = '4'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '4'" }, { "game": "popn", @@ -35346,9 +38968,10 @@ "inactive": true, "legacyFolderID": "F262241963dc418765e97cd1c86cae7934b2e7c8483f877f22d659092db226903", "searchTerms": [], + "slug": "4-peace", "title": "Level 4 (peace)", - "where": "chart.level = '4'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '4'" }, { "game": "popn", @@ -35356,9 +38979,10 @@ "inactive": false, "legacyFolderID": "F919477b4cc85b3762047d2601185c5e388cadc71c50a4fac0fc69d015d6e3bc3", "searchTerms": [], + "slug": "4-unilab", "title": "Level 4 (Unilab)", - "where": "chart.level = '4'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '4'" }, { "game": "popn", @@ -35366,9 +38990,10 @@ "inactive": false, "legacyFolderID": "F75161794feb0a757b35318559f2d940481a768897aeacd70cd53813a5a6f735c", "searchTerms": [], + "slug": "40-kaimei", "title": "Level 40 (Kaimei Riddles)", - "where": "chart.level = '40'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '40'" }, { "game": "popn", @@ -35376,9 +39001,10 @@ "inactive": true, "legacyFolderID": "F6e40c1facb9fc3f688dcaadd641412e941230c641389fdf61ec19311da08abe5", "searchTerms": [], + "slug": "40-peace", "title": "Level 40 (peace)", - "where": "chart.level = '40'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '40'" }, { "game": "popn", @@ -35386,9 +39012,10 @@ "inactive": false, "legacyFolderID": "F59dabb6f9f17396cdf22023f9fdb0b198a7aec198dd35e649201d6b28b686bfc", "searchTerms": [], + "slug": "40-unilab", "title": "Level 40 (Unilab)", - "where": "chart.level = '40'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '40'" }, { "game": "popn", @@ -35396,9 +39023,10 @@ "inactive": false, "legacyFolderID": "Fcaa2b6198bc080a99ad618c6d98c41f22482582850b9d6d9205590bcccad3527", "searchTerms": [], + "slug": "41-kaimei", "title": "Level 41 (Kaimei Riddles)", - "where": "chart.level = '41'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '41'" }, { "game": "popn", @@ -35406,9 +39034,10 @@ "inactive": true, "legacyFolderID": "F80fbfe04d6a681fa112e744df46c203a07e48d4189e6fbf1d97bf4e7a24aff3b", "searchTerms": [], + "slug": "41-peace", "title": "Level 41 (peace)", - "where": "chart.level = '41'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '41'" }, { "game": "popn", @@ -35416,9 +39045,10 @@ "inactive": false, "legacyFolderID": "Fd1eca971ea91ccd67d642f570b35894ab562706e9856d612a87b1cec826f09a9", "searchTerms": [], + "slug": "41-unilab", "title": "Level 41 (Unilab)", - "where": "chart.level = '41'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '41'" }, { "game": "popn", @@ -35426,9 +39056,10 @@ "inactive": false, "legacyFolderID": "Fc3e2a570bc13e934f3d4e5f2ff81876b73bc7eb8913ab30afa01571bd89ead11", "searchTerms": [], + "slug": "42-kaimei", "title": "Level 42 (Kaimei Riddles)", - "where": "chart.level = '42'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '42'" }, { "game": "popn", @@ -35436,9 +39067,10 @@ "inactive": true, "legacyFolderID": "Fcbd37a843ffc91c84fba1d1bda4362202bc48fe0beaf8af37f6424e3ad4da41f", "searchTerms": [], + "slug": "42-peace", "title": "Level 42 (peace)", - "where": "chart.level = '42'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '42'" }, { "game": "popn", @@ -35446,9 +39078,10 @@ "inactive": false, "legacyFolderID": "F6a0c2280b9d0c813043f1925e7ae66ed9471147a2ec33a51ad2ed332a5fab39d", "searchTerms": [], + "slug": "42-unilab", "title": "Level 42 (Unilab)", - "where": "chart.level = '42'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '42'" }, { "game": "popn", @@ -35456,9 +39089,10 @@ "inactive": false, "legacyFolderID": "Fa1a38915d082711fba913f58eeb80257f28d773d958a0e02982dd9c4afce0d2d", "searchTerms": [], + "slug": "43-kaimei", "title": "Level 43 (Kaimei Riddles)", - "where": "chart.level = '43'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '43'" }, { "game": "popn", @@ -35466,9 +39100,10 @@ "inactive": true, "legacyFolderID": "F1d6eda0412263de5857a462376a22fe5dba4e38eda0f0750ef8f2def13df65d4", "searchTerms": [], + "slug": "43-peace", "title": "Level 43 (peace)", - "where": "chart.level = '43'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '43'" }, { "game": "popn", @@ -35476,9 +39111,10 @@ "inactive": false, "legacyFolderID": "F5913ba6cb0923f40128aa1556ce3c8149eba9a685492695f74275bd8e8efaab8", "searchTerms": [], + "slug": "43-unilab", "title": "Level 43 (Unilab)", - "where": "chart.level = '43'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '43'" }, { "game": "popn", @@ -35486,9 +39122,10 @@ "inactive": false, "legacyFolderID": "Fe76c26fee4bbce9ac332473a2ef35852351ab208f4803ee6783894470e3a54a7", "searchTerms": [], + "slug": "44-kaimei", "title": "Level 44 (Kaimei Riddles)", - "where": "chart.level = '44'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '44'" }, { "game": "popn", @@ -35496,9 +39133,10 @@ "inactive": true, "legacyFolderID": "Fffa1733023be51fd55b2bc03776a9c841de81691fa0ac82906d2c6c406afb6fc", "searchTerms": [], + "slug": "44-peace", "title": "Level 44 (peace)", - "where": "chart.level = '44'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '44'" }, { "game": "popn", @@ -35506,9 +39144,10 @@ "inactive": false, "legacyFolderID": "Fc6c828a87fbbebd63ba80de259d54b5b58df9fb06e8ac1c363ffc0497bcdf3d3", "searchTerms": [], + "slug": "44-unilab", "title": "Level 44 (Unilab)", - "where": "chart.level = '44'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '44'" }, { "game": "popn", @@ -35516,9 +39155,10 @@ "inactive": false, "legacyFolderID": "F91067d7ae22988498783332ea93885f7e982887a361d85207b5579075bc8597f", "searchTerms": [], + "slug": "45-kaimei", "title": "Level 45 (Kaimei Riddles)", - "where": "chart.level = '45'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '45'" }, { "game": "popn", @@ -35526,9 +39166,10 @@ "inactive": true, "legacyFolderID": "F1382b63edbe407cd77b2af46869759310606a8a5e2fa88350554c4479f9dd4be", "searchTerms": [], + "slug": "45-peace", "title": "Level 45 (peace)", - "where": "chart.level = '45'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '45'" }, { "game": "popn", @@ -35536,9 +39177,10 @@ "inactive": false, "legacyFolderID": "F2eb5c86a59d01b81713f01a832bd8daf1fac51b32bc2059a5a4bb7d9ab0749be", "searchTerms": [], + "slug": "45-unilab", "title": "Level 45 (Unilab)", - "where": "chart.level = '45'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '45'" }, { "game": "popn", @@ -35546,9 +39188,10 @@ "inactive": false, "legacyFolderID": "F914e9aa1e871d5b2649b8fca3ee2d42a809d098848571203fc2e72b72e05626f", "searchTerms": [], + "slug": "46-kaimei", "title": "Level 46 (Kaimei Riddles)", - "where": "chart.level = '46'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '46'" }, { "game": "popn", @@ -35556,9 +39199,10 @@ "inactive": true, "legacyFolderID": "F151327850c76e26eeb96349723cde9eadec6eb72f7ea294c886c0623ed44b489", "searchTerms": [], + "slug": "46-peace", "title": "Level 46 (peace)", - "where": "chart.level = '46'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '46'" }, { "game": "popn", @@ -35566,9 +39210,10 @@ "inactive": false, "legacyFolderID": "Fec2b66031fca71f4999684492fd4f5736f5a867b589d31259a18809692ca0e10", "searchTerms": [], + "slug": "46-unilab", "title": "Level 46 (Unilab)", - "where": "chart.level = '46'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '46'" }, { "game": "popn", @@ -35576,9 +39221,10 @@ "inactive": false, "legacyFolderID": "Fb397b4b6ce019e9504c48115d5dc6946bd994f0eb7ad86641e05d43b5c955667", "searchTerms": [], + "slug": "47-kaimei", "title": "Level 47 (Kaimei Riddles)", - "where": "chart.level = '47'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '47'" }, { "game": "popn", @@ -35586,9 +39232,10 @@ "inactive": true, "legacyFolderID": "F1bd1dd880f7fcae2200fbe94b4b20958b6330d13b283c6253f2021b426e5c069", "searchTerms": [], + "slug": "47-peace", "title": "Level 47 (peace)", - "where": "chart.level = '47'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '47'" }, { "game": "popn", @@ -35596,9 +39243,10 @@ "inactive": false, "legacyFolderID": "F346ab8a5e2825f59145fcba95561f3f959566ffcd96e192824b6a908d053b392", "searchTerms": [], + "slug": "47-unilab", "title": "Level 47 (Unilab)", - "where": "chart.level = '47'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '47'" }, { "game": "popn", @@ -35606,9 +39254,10 @@ "inactive": false, "legacyFolderID": "F2cd5434e77082a78859ead41e0b5bae247e2bcfb207fb11a877a9d0d34f1bd48", "searchTerms": [], + "slug": "48-kaimei", "title": "Level 48 (Kaimei Riddles)", - "where": "chart.level = '48'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '48'" }, { "game": "popn", @@ -35616,9 +39265,10 @@ "inactive": true, "legacyFolderID": "F3bc9b6a0591d95e027ef817fbec74c671bd757ff1d706da5060a8c2c50a2e4af", "searchTerms": [], + "slug": "48-peace", "title": "Level 48 (peace)", - "where": "chart.level = '48'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '48'" }, { "game": "popn", @@ -35626,9 +39276,10 @@ "inactive": false, "legacyFolderID": "F33f7883915460f55cc003214fa949b1662b54de7ba850d8e6d5d19d8ced91654", "searchTerms": [], + "slug": "48-unilab", "title": "Level 48 (Unilab)", - "where": "chart.level = '48'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '48'" }, { "game": "popn", @@ -35636,9 +39287,10 @@ "inactive": false, "legacyFolderID": "F42456cc1261922631b71ef772314c33e6e46f7ef0a1945f754087170bc3fd0f1", "searchTerms": [], + "slug": "49-kaimei", "title": "Level 49 (Kaimei Riddles)", - "where": "chart.level = '49'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '49'" }, { "game": "popn", @@ -35646,9 +39298,10 @@ "inactive": true, "legacyFolderID": "Fcd555f97838782049c4eb1969d899f4eeb49c28c49a84accd653313a05498211", "searchTerms": [], + "slug": "49-peace", "title": "Level 49 (peace)", - "where": "chart.level = '49'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '49'" }, { "game": "popn", @@ -35656,9 +39309,10 @@ "inactive": false, "legacyFolderID": "Fef381544ff909f7a1c2fe188bed5ded71f42bc876176754c76e9dc5bc6e981cf", "searchTerms": [], + "slug": "49-unilab", "title": "Level 49 (Unilab)", - "where": "chart.level = '49'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '49'" }, { "game": "popn", @@ -35666,9 +39320,10 @@ "inactive": false, "legacyFolderID": "F4d1b479322f42642b32aedf9766ef6d58f4bf7df9504b2b3746704a7a72d8243", "searchTerms": [], + "slug": "5-kaimei", "title": "Level 5 (Kaimei Riddles)", - "where": "chart.level = '5'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '5'" }, { "game": "popn", @@ -35676,9 +39331,10 @@ "inactive": true, "legacyFolderID": "F918324990b7beac6bdeea309c3b4df510c6a91d166a7cc746c69697e41469652", "searchTerms": [], + "slug": "5-peace", "title": "Level 5 (peace)", - "where": "chart.level = '5'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '5'" }, { "game": "popn", @@ -35686,9 +39342,10 @@ "inactive": false, "legacyFolderID": "F312b52c5605a49fdd03c93ffac75da00b59a3a68a5a187dd8819ed21537cbb3a", "searchTerms": [], + "slug": "5-unilab", "title": "Level 5 (Unilab)", - "where": "chart.level = '5'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '5'" }, { "game": "popn", @@ -35696,9 +39353,10 @@ "inactive": false, "legacyFolderID": "Fa705e7e3062ccbf291f63b77cdcc478a1f86646152662aa262cd660ffa787c46", "searchTerms": [], + "slug": "50-kaimei", "title": "Level 50 (Kaimei Riddles)", - "where": "chart.level = '50'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '50'" }, { "game": "popn", @@ -35706,9 +39364,10 @@ "inactive": true, "legacyFolderID": "Fa04fbc9d9954d518c11a2c72ce96cd514f2aae4fcff817193ad358c634dae58f", "searchTerms": [], + "slug": "50-peace", "title": "Level 50 (peace)", - "where": "chart.level = '50'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '50'" }, { "game": "popn", @@ -35716,9 +39375,10 @@ "inactive": false, "legacyFolderID": "F9a91dc3605c53e940d0ffc893c4c12dff6bc07d7e9ea13b1cf6b2b52917c5da4", "searchTerms": [], + "slug": "50-unilab", "title": "Level 50 (Unilab)", - "where": "chart.level = '50'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '50'" }, { "game": "popn", @@ -35726,9 +39386,10 @@ "inactive": false, "legacyFolderID": "F53e178e5b50a115669c8abf704b63a81bc512a71723adb33f04443a5664089ac", "searchTerms": [], + "slug": "6-kaimei", "title": "Level 6 (Kaimei Riddles)", - "where": "chart.level = '6'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '6'" }, { "game": "popn", @@ -35736,9 +39397,10 @@ "inactive": true, "legacyFolderID": "F1fe781f59fdd4aa97f6b522a39fbdc419aaba2cc3d18bf2ceff94b9c13cf40ae", "searchTerms": [], + "slug": "6-peace", "title": "Level 6 (peace)", - "where": "chart.level = '6'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '6'" }, { "game": "popn", @@ -35746,9 +39408,10 @@ "inactive": false, "legacyFolderID": "F25d5dc7f32117f7bb41e14f057fbd5fc6113581eb6401bc0e32fd039bd5201d7", "searchTerms": [], + "slug": "6-unilab", "title": "Level 6 (Unilab)", - "where": "chart.level = '6'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '6'" }, { "game": "popn", @@ -35756,9 +39419,10 @@ "inactive": false, "legacyFolderID": "F30ed1f9b36aac7140a64ee678bfd437f851e49302ea21eae114dd32c356094b9", "searchTerms": [], + "slug": "7-kaimei", "title": "Level 7 (Kaimei Riddles)", - "where": "chart.level = '7'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '7'" }, { "game": "popn", @@ -35766,9 +39430,10 @@ "inactive": true, "legacyFolderID": "F16e85119ac3ba6e1b1bd022bfdeff42bbbef96e4ef08d4b49fe9ad546fc6defc", "searchTerms": [], + "slug": "7-peace", "title": "Level 7 (peace)", - "where": "chart.level = '7'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '7'" }, { "game": "popn", @@ -35776,9 +39441,10 @@ "inactive": false, "legacyFolderID": "F0957ad19cfa9f391beacc97f546376d4383696f4c022d8934d83ea738d9d5a92", "searchTerms": [], + "slug": "7-unilab", "title": "Level 7 (Unilab)", - "where": "chart.level = '7'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '7'" }, { "game": "popn", @@ -35786,9 +39452,10 @@ "inactive": false, "legacyFolderID": "F6fc6baff173adb843554e8aa9b17124e2b6f140ba61dbe0fd80ce5ec40babb99", "searchTerms": [], + "slug": "8-kaimei", "title": "Level 8 (Kaimei Riddles)", - "where": "chart.level = '8'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '8'" }, { "game": "popn", @@ -35796,9 +39463,10 @@ "inactive": true, "legacyFolderID": "F996675095c33dbccaac376d44b3c6a3556e5fc5c8bd6dc2a18aff6b5b168d4a3", "searchTerms": [], + "slug": "8-peace", "title": "Level 8 (peace)", - "where": "chart.level = '8'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '8'" }, { "game": "popn", @@ -35806,9 +39474,10 @@ "inactive": false, "legacyFolderID": "Fd3b7141270ce0faf99499e5e3094fe15b6de553a4d03f336e44fd0c22c33b867", "searchTerms": [], + "slug": "8-unilab", "title": "Level 8 (Unilab)", - "where": "chart.level = '8'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '8'" }, { "game": "popn", @@ -35816,9 +39485,10 @@ "inactive": false, "legacyFolderID": "Ff2f3dfcf3c0c438b10823e7fce90f7da7b41215aec21f7143140a247cb916883", "searchTerms": [], + "slug": "9-kaimei", "title": "Level 9 (Kaimei Riddles)", - "where": "chart.level = '9'", - "versionFilter": ["kaimei"] + "versionFilter": ["kaimei"], + "where": "chart.level = '9'" }, { "game": "popn", @@ -35826,9 +39496,10 @@ "inactive": true, "legacyFolderID": "Fcc210c49272086344051c1a196ae74704e67f63279b0509c09225d0436ee6d3b", "searchTerms": [], + "slug": "9-peace", "title": "Level 9 (peace)", - "where": "chart.level = '9'", - "versionFilter": ["peace"] + "versionFilter": ["peace"], + "where": "chart.level = '9'" }, { "game": "popn", @@ -35836,9 +39507,10 @@ "inactive": false, "legacyFolderID": "Fcd89aeb62fdf0be581e01b045a6c8b690c40a864d9d5b1bb343f7dd340c3eb23", "searchTerms": [], + "slug": "9-unilab", "title": "Level 9 (Unilab)", - "where": "chart.level = '9'", - "versionFilter": ["unilab"] + "versionFilter": ["unilab"], + "where": "chart.level = '9'" }, { "game": "sdvx", @@ -35846,9 +39518,10 @@ "inactive": false, "legacyFolderID": "Fc87850208f0703651560980d04bc5e0c4f326c2f5dbe688d51029a63abdd4c5f", "searchTerms": [], + "slug": "1-exceed", "title": "Level 1 (EXCEED GEAR)", - "where": "chart.level = '1'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '1'" }, { "game": "sdvx", @@ -35856,9 +39529,10 @@ "inactive": true, "legacyFolderID": "F5c71548c82d436d6c3a811f1f6e6f267cecdc0ed40736aff2d10c43895e164bd", "searchTerms": [], + "slug": "1-heaven", "title": "Level 1 (HEAVENLY HAVEN)", - "where": "chart.level = '1'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '1'" }, { "game": "sdvx", @@ -35866,9 +39540,10 @@ "inactive": false, "legacyFolderID": "Fa8d297fd062c7de085f629559f18572ad0549413f325e28d598cbb533c68f7f1", "searchTerms": [], + "slug": "1-konaste", "title": "Level 1 (Konaste)", - "where": "chart.level = '1'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '1'" }, { "game": "sdvx", @@ -35876,9 +39551,10 @@ "inactive": false, "legacyFolderID": "F6f759af83ab316bde12f923433945a025c0782a77ed0d2c27aedfa4ba6db9a43", "searchTerms": [], + "slug": "1-vivid", "title": "Level 1 (VIVID WAVE)", - "where": "chart.level = '1'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '1'" }, { "game": "sdvx", @@ -35886,9 +39562,10 @@ "inactive": false, "legacyFolderID": "F371094048c01518a4610810d9bb5d2104e998ec2761c40cb95d889f8fc1fb191", "searchTerms": [], + "slug": "10-exceed", "title": "Level 10 (EXCEED GEAR)", - "where": "chart.level = '10'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '10'" }, { "game": "sdvx", @@ -35896,9 +39573,10 @@ "inactive": true, "legacyFolderID": "F1c51a6095dffd6f8fcc24c413c8913d46fa671cc0061c0fd209b336d1d176f38", "searchTerms": [], + "slug": "10-heaven", "title": "Level 10 (HEAVENLY HAVEN)", - "where": "chart.level = '10'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '10'" }, { "game": "sdvx", @@ -35906,9 +39584,10 @@ "inactive": false, "legacyFolderID": "Fee30d6a607f173e637321fe276d4f5c82405b6c70d324345c0c8d459a5904315", "searchTerms": [], + "slug": "10-konaste", "title": "Level 10 (Konaste)", - "where": "chart.level = '10'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '10'" }, { "game": "sdvx", @@ -35916,9 +39595,10 @@ "inactive": false, "legacyFolderID": "F1cb478eb9514f69d7707b67e2c322cb7555b5e1303f8f02d85a6389bc7aed58d", "searchTerms": [], + "slug": "10-vivid", "title": "Level 10 (VIVID WAVE)", - "where": "chart.level = '10'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '10'" }, { "game": "sdvx", @@ -35926,9 +39606,10 @@ "inactive": false, "legacyFolderID": "F6676706e63d95add349e57d6fc65a81bb4f36495198b762f12edae5db167f9bc", "searchTerms": [], + "slug": "11-exceed", "title": "Level 11 (EXCEED GEAR)", - "where": "chart.level = '11'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '11'" }, { "game": "sdvx", @@ -35936,9 +39617,10 @@ "inactive": true, "legacyFolderID": "F34067f80c385c9b8f2c0879af84a821b9058c952b6fc72ef651647726a79f5eb", "searchTerms": [], + "slug": "11-heaven", "title": "Level 11 (HEAVENLY HAVEN)", - "where": "chart.level = '11'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '11'" }, { "game": "sdvx", @@ -35946,9 +39628,10 @@ "inactive": false, "legacyFolderID": "Fdf8afce7b688357104cabb89301146d56c9ff2b42fb1e9437970a20adf736602", "searchTerms": [], + "slug": "11-konaste", "title": "Level 11 (Konaste)", - "where": "chart.level = '11'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '11'" }, { "game": "sdvx", @@ -35956,9 +39639,10 @@ "inactive": false, "legacyFolderID": "F42b02cf8abf2035069cf694b1815e952f12b5248c807f747263ce1094b742302", "searchTerms": [], + "slug": "11-vivid", "title": "Level 11 (VIVID WAVE)", - "where": "chart.level = '11'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '11'" }, { "game": "sdvx", @@ -35966,9 +39650,10 @@ "inactive": false, "legacyFolderID": "F2960665f35094664fac9d61b030c51c0e22eaa83efb3461bf44ab7baf5a8fd41", "searchTerms": [], + "slug": "12-exceed", "title": "Level 12 (EXCEED GEAR)", - "where": "chart.level = '12'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '12'" }, { "game": "sdvx", @@ -35976,9 +39661,10 @@ "inactive": true, "legacyFolderID": "F96f81e8a364a1d0becc19523f9f9a55257913c2ec9f560fdbc2290ef3852c53c", "searchTerms": [], + "slug": "12-heaven", "title": "Level 12 (HEAVENLY HAVEN)", - "where": "chart.level = '12'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '12'" }, { "game": "sdvx", @@ -35986,9 +39672,10 @@ "inactive": false, "legacyFolderID": "F68eda5d1e0a970b696e71c20d7def31af635134c0adfd394d22796f7a85ae500", "searchTerms": [], + "slug": "12-konaste", "title": "Level 12 (Konaste)", - "where": "chart.level = '12'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '12'" }, { "game": "sdvx", @@ -35996,9 +39683,10 @@ "inactive": false, "legacyFolderID": "F5df2710e8557ec55844526ee4fb61926eaf5fb52c4251a6635c4045305368f60", "searchTerms": [], + "slug": "12-vivid", "title": "Level 12 (VIVID WAVE)", - "where": "chart.level = '12'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '12'" }, { "game": "sdvx", @@ -36006,9 +39694,10 @@ "inactive": false, "legacyFolderID": "F366713bbe56a09b513e571c86c97dcb71455c770f700611099e5d69974b04a01", "searchTerms": [], + "slug": "13-exceed", "title": "Level 13 (EXCEED GEAR)", - "where": "chart.level = '13'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '13'" }, { "game": "sdvx", @@ -36016,9 +39705,10 @@ "inactive": true, "legacyFolderID": "Ff8c031ec190ea38cfaa2a996f5e2d2c0eb7a6b9af8895600ee9cac60fcc65161", "searchTerms": [], + "slug": "13-heaven", "title": "Level 13 (HEAVENLY HAVEN)", - "where": "chart.level = '13'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '13'" }, { "game": "sdvx", @@ -36026,9 +39716,10 @@ "inactive": false, "legacyFolderID": "F88351f92f3ddabc21b21dc299e6c89be4f753b6db06f6a56d43131075a440e7e", "searchTerms": [], + "slug": "13-konaste", "title": "Level 13 (Konaste)", - "where": "chart.level = '13'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '13'" }, { "game": "sdvx", @@ -36036,9 +39727,10 @@ "inactive": false, "legacyFolderID": "F6c4274e5d4b6c38fac04cb8e4930598fb7588cc0188d66d807cb5d820d2fe2cd", "searchTerms": [], + "slug": "13-vivid", "title": "Level 13 (VIVID WAVE)", - "where": "chart.level = '13'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '13'" }, { "game": "sdvx", @@ -36046,9 +39738,10 @@ "inactive": false, "legacyFolderID": "F21e35fbe0718c2db51ab633ae43d53945012fb86f740ef81a2d4ba75b425d384", "searchTerms": [], + "slug": "14-exceed", "title": "Level 14 (EXCEED GEAR)", - "where": "chart.level = '14'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '14'" }, { "game": "sdvx", @@ -36056,9 +39749,10 @@ "inactive": true, "legacyFolderID": "F8619c537e1f62940e86db88dbaf9e7348ab86d5465332adc8d7e55cdccba3529", "searchTerms": [], + "slug": "14-heaven", "title": "Level 14 (HEAVENLY HAVEN)", - "where": "chart.level = '14'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '14'" }, { "game": "sdvx", @@ -36066,9 +39760,10 @@ "inactive": false, "legacyFolderID": "F0e3ed0599656a4c5b537a99287d975abf9ae2b227dd988fc938ebc36310bb4ab", "searchTerms": [], + "slug": "14-konaste", "title": "Level 14 (Konaste)", - "where": "chart.level = '14'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '14'" }, { "game": "sdvx", @@ -36076,9 +39771,10 @@ "inactive": false, "legacyFolderID": "F81ac7f10f4fe478b25ba9c8c02753fa59027015fafbe7656058a80ef18a72e87", "searchTerms": [], + "slug": "14-vivid", "title": "Level 14 (VIVID WAVE)", - "where": "chart.level = '14'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '14'" }, { "game": "sdvx", @@ -36086,9 +39782,10 @@ "inactive": false, "legacyFolderID": "Fb6d41e93c52dd1cf72b05a8fbfa979ecb88fe9c6675180e2d8cbd2d5a6e9476c", "searchTerms": [], + "slug": "15-exceed", "title": "Level 15 (EXCEED GEAR)", - "where": "chart.level = '15'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '15'" }, { "game": "sdvx", @@ -36096,9 +39793,10 @@ "inactive": true, "legacyFolderID": "F9dafe16e20c292ac1ab61d751e193b854e2fb707bc082aa0e07688a4a10c31db", "searchTerms": [], + "slug": "15-heaven", "title": "Level 15 (HEAVENLY HAVEN)", - "where": "chart.level = '15'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '15'" }, { "game": "sdvx", @@ -36106,9 +39804,10 @@ "inactive": false, "legacyFolderID": "Fc26d6e93dc362926a5f5953f17bd0428bc5c4e26fd49e1b563d4f43ffbdeb2d5", "searchTerms": [], + "slug": "15-konaste", "title": "Level 15 (Konaste)", - "where": "chart.level = '15'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '15'" }, { "game": "sdvx", @@ -36116,9 +39815,10 @@ "inactive": false, "legacyFolderID": "Ff3e2aecb831550de238499974be45657fe60d67f476f13677b30229daca7418f", "searchTerms": [], + "slug": "15-vivid", "title": "Level 15 (VIVID WAVE)", - "where": "chart.level = '15'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '15'" }, { "game": "sdvx", @@ -36126,9 +39826,10 @@ "inactive": false, "legacyFolderID": "Fbfa65f45a9f5436a5dbc55a7f90d116b50ac748d5e881b56451ed4b40531a53a", "searchTerms": [], + "slug": "16-exceed", "title": "Level 16 (EXCEED GEAR)", - "where": "chart.level = '16'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '16'" }, { "game": "sdvx", @@ -36136,9 +39837,10 @@ "inactive": true, "legacyFolderID": "Ff39d5d34fe62955057df28678bd11a9ed382293d82eb42d8a5f498ede6f880ae", "searchTerms": [], + "slug": "16-heaven", "title": "Level 16 (HEAVENLY HAVEN)", - "where": "chart.level = '16'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '16'" }, { "game": "sdvx", @@ -36146,9 +39848,10 @@ "inactive": false, "legacyFolderID": "F8675a2672bb58eb851146bd0fee6fd149a6ad155d44b4bedb2cb54a5d3cf5137", "searchTerms": [], + "slug": "16-konaste", "title": "Level 16 (Konaste)", - "where": "chart.level = '16'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '16'" }, { "game": "sdvx", @@ -36156,9 +39859,10 @@ "inactive": false, "legacyFolderID": "Fa8840e871649ee764bd9737fe36cc8f1dd40b21e743c32b99bcc46bf383fb8a8", "searchTerms": [], + "slug": "16-vivid", "title": "Level 16 (VIVID WAVE)", - "where": "chart.level = '16'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '16'" }, { "game": "sdvx", @@ -36166,9 +39870,10 @@ "inactive": false, "legacyFolderID": "F1d4d6156ad27065d590bb0efdce48df95e9cf088157a97e391cf89032653c799", "searchTerms": [], + "slug": "17-exceed", "title": "Level 17 (EXCEED GEAR)", - "where": "chart.level = '17'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '17'" }, { "game": "sdvx", @@ -36176,9 +39881,10 @@ "inactive": true, "legacyFolderID": "Fde73d0abc658483058f0b9220e7379e86c9ae08c0e7508883afb824469994ee3", "searchTerms": [], + "slug": "17-heaven", "title": "Level 17 (HEAVENLY HAVEN)", - "where": "chart.level = '17'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '17'" }, { "game": "sdvx", @@ -36186,9 +39892,10 @@ "inactive": false, "legacyFolderID": "F5ca4ed6a276e132bdc24f4f1e1f5b2c71fbf83445a959a9a0b6892b039a0df98", "searchTerms": [], + "slug": "17-konaste", "title": "Level 17 (Konaste)", - "where": "chart.level = '17'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '17'" }, { "game": "sdvx", @@ -36196,9 +39903,10 @@ "inactive": false, "legacyFolderID": "F387be776bf1334ca50f4a1943b825ffd68b95fe534f1ef7d42b79e4d09d0dd5c", "searchTerms": [], + "slug": "17-vivid", "title": "Level 17 (VIVID WAVE)", - "where": "chart.level = '17'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '17'" }, { "game": "sdvx", @@ -36206,9 +39914,10 @@ "inactive": false, "legacyFolderID": "F32e3ab1ab5258d702efa6bb7690194d6abc9cad66e21daeceaa5c58a7fa4c41c", "searchTerms": [], + "slug": "18-exceed", "title": "Level 18 (EXCEED GEAR)", - "where": "chart.level = '18'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '18'" }, { "game": "sdvx", @@ -36216,9 +39925,10 @@ "inactive": true, "legacyFolderID": "F6510cfbb5b5efa66cc034c84fc3874aaf8f45fc886a3cb7f45fd10f367930d64", "searchTerms": [], + "slug": "18-heaven", "title": "Level 18 (HEAVENLY HAVEN)", - "where": "chart.level = '18'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '18'" }, { "game": "sdvx", @@ -36226,9 +39936,10 @@ "inactive": false, "legacyFolderID": "F66f2837b8666be2b28c043e98bd8aff73792218302d7ae9cd8468e5e266ed744", "searchTerms": [], + "slug": "18-konaste", "title": "Level 18 (Konaste)", - "where": "chart.level = '18'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '18'" }, { "game": "sdvx", @@ -36236,9 +39947,10 @@ "inactive": false, "legacyFolderID": "F57b070e5e97e3de274b6149e15dec896e57a2693c312aa674f48ec263a4c2bb3", "searchTerms": [], + "slug": "18-vivid", "title": "Level 18 (VIVID WAVE)", - "where": "chart.level = '18'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '18'" }, { "game": "sdvx", @@ -36246,9 +39958,10 @@ "inactive": false, "legacyFolderID": "Ff75e405b41ca512993548d6cfca8b6ee2310234b2a7c5fe9f93638abc3e49fb1", "searchTerms": [], + "slug": "19-exceed", "title": "Level 19 (EXCEED GEAR)", - "where": "chart.level = '19'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '19'" }, { "game": "sdvx", @@ -36256,9 +39969,10 @@ "inactive": true, "legacyFolderID": "Fec43b45132a4fa6c2855e2da60d2e710fd16bc6e6f11cf260a887d005f682de6", "searchTerms": [], + "slug": "19-heaven", "title": "Level 19 (HEAVENLY HAVEN)", - "where": "chart.level = '19'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '19'" }, { "game": "sdvx", @@ -36266,9 +39980,10 @@ "inactive": false, "legacyFolderID": "Fbee840240ba3594c606193fccb56a62602992073da1809a348a29bae56569481", "searchTerms": [], + "slug": "19-konaste", "title": "Level 19 (Konaste)", - "where": "chart.level = '19'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '19'" }, { "game": "sdvx", @@ -36276,9 +39991,10 @@ "inactive": false, "legacyFolderID": "Ff0d260c38667aaedf122d1498f2c1e9b139a5227d2f77dbfe34f1dab0df0dd45", "searchTerms": [], + "slug": "19-vivid", "title": "Level 19 (VIVID WAVE)", - "where": "chart.level = '19'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '19'" }, { "game": "sdvx", @@ -36286,9 +40002,10 @@ "inactive": false, "legacyFolderID": "F097d8f1d7740b405b8ba55b9ceba88b5acb04f069158f194d47d0873d7641a6f", "searchTerms": [], + "slug": "2-exceed", "title": "Level 2 (EXCEED GEAR)", - "where": "chart.level = '2'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '2'" }, { "game": "sdvx", @@ -36296,9 +40013,10 @@ "inactive": true, "legacyFolderID": "F4c1dd44e6db251bd185724295a5b29f30021ab94f4acbe12f670e3cd34651f18", "searchTerms": [], + "slug": "2-heaven", "title": "Level 2 (HEAVENLY HAVEN)", - "where": "chart.level = '2'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '2'" }, { "game": "sdvx", @@ -36306,9 +40024,10 @@ "inactive": false, "legacyFolderID": "F112030d5e9478707c5adfb53dd03298d8b30d81fe4e81b7a7c341b417ff5b507", "searchTerms": [], + "slug": "2-konaste", "title": "Level 2 (Konaste)", - "where": "chart.level = '2'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '2'" }, { "game": "sdvx", @@ -36316,9 +40035,10 @@ "inactive": false, "legacyFolderID": "F848a99e733cddf868e9b633f5c9e3427e5bbe72526ead6fccc15adab6b59ccaf", "searchTerms": [], + "slug": "2-vivid", "title": "Level 2 (VIVID WAVE)", - "where": "chart.level = '2'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '2'" }, { "game": "sdvx", @@ -36326,9 +40046,10 @@ "inactive": false, "legacyFolderID": "F587912aa1af1c26daf085428f3f3ae420cbe905775782f9e2506108d3ff03a5a", "searchTerms": [], + "slug": "20-exceed", "title": "Level 20 (EXCEED GEAR)", - "where": "chart.level = '20'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '20'" }, { "game": "sdvx", @@ -36336,9 +40057,10 @@ "inactive": true, "legacyFolderID": "Fbcc1a781be79e59eb157bfbe2f0586dfc6440170d25a2b6403ae20ad54f92dce", "searchTerms": [], + "slug": "20-heaven", "title": "Level 20 (HEAVENLY HAVEN)", - "where": "chart.level = '20'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '20'" }, { "game": "sdvx", @@ -36346,9 +40068,10 @@ "inactive": false, "legacyFolderID": "Fa8910e1da2f7a11e7a4cd6a8dda9071ca8158174c8494f52c6efdc85ae43c6bd", "searchTerms": [], + "slug": "20-konaste", "title": "Level 20 (Konaste)", - "where": "chart.level = '20'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '20'" }, { "game": "sdvx", @@ -36356,9 +40079,10 @@ "inactive": false, "legacyFolderID": "F682c90a02ac2dfc8410eb72b8f1bea85fd5ddeec7604769b0cbe545e6a3535ee", "searchTerms": [], + "slug": "20-vivid", "title": "Level 20 (VIVID WAVE)", - "where": "chart.level = '20'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '20'" }, { "game": "sdvx", @@ -36366,9 +40090,10 @@ "inactive": false, "legacyFolderID": "Ff698a4dec5a41b3aba3a8a91e26d0abfbf0242ae7d0c5d3a28062b53df7335d1", "searchTerms": [], + "slug": "3-exceed", "title": "Level 3 (EXCEED GEAR)", - "where": "chart.level = '3'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '3'" }, { "game": "sdvx", @@ -36376,9 +40101,10 @@ "inactive": true, "legacyFolderID": "F4e94d05ea70d98a5ed6f60924d92b9e5692868addb64db479dd46460f18e5faa", "searchTerms": [], + "slug": "3-heaven", "title": "Level 3 (HEAVENLY HAVEN)", - "where": "chart.level = '3'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '3'" }, { "game": "sdvx", @@ -36386,9 +40112,10 @@ "inactive": false, "legacyFolderID": "Fd5682c7ee76404985d87c37b5d2b0086dad0c2c8689ba4acf1fda24c5cb135e1", "searchTerms": [], + "slug": "3-konaste", "title": "Level 3 (Konaste)", - "where": "chart.level = '3'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '3'" }, { "game": "sdvx", @@ -36396,9 +40123,10 @@ "inactive": false, "legacyFolderID": "Fb24b427cadc64f7f8322842d9e87f5dfe19e683b73d95ed9db7c0af2615f2ebf", "searchTerms": [], + "slug": "3-vivid", "title": "Level 3 (VIVID WAVE)", - "where": "chart.level = '3'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '3'" }, { "game": "sdvx", @@ -36406,9 +40134,10 @@ "inactive": false, "legacyFolderID": "F87a288a234fb85918b99ef9e0bbe44489fd014fdf6e80977bdb0c7a6767f9c29", "searchTerms": [], + "slug": "4-exceed", "title": "Level 4 (EXCEED GEAR)", - "where": "chart.level = '4'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '4'" }, { "game": "sdvx", @@ -36416,9 +40145,10 @@ "inactive": true, "legacyFolderID": "Fcb21329c3097db4fe64452e0a5bdfee80898fa653bff84cb3e00c473a4c90f4d", "searchTerms": [], + "slug": "4-heaven", "title": "Level 4 (HEAVENLY HAVEN)", - "where": "chart.level = '4'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '4'" }, { "game": "sdvx", @@ -36426,9 +40156,10 @@ "inactive": false, "legacyFolderID": "F557dc9cecadfbcb3a9797d327d538d5d7cee84768e47e3bf855491f4109c2ab1", "searchTerms": [], + "slug": "4-konaste", "title": "Level 4 (Konaste)", - "where": "chart.level = '4'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '4'" }, { "game": "sdvx", @@ -36436,9 +40167,10 @@ "inactive": false, "legacyFolderID": "Fac0914bf4354bdb6f5363931193145f85195767052c3bbea5bff56391c8a820e", "searchTerms": [], + "slug": "4-vivid", "title": "Level 4 (VIVID WAVE)", - "where": "chart.level = '4'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '4'" }, { "game": "sdvx", @@ -36446,9 +40178,10 @@ "inactive": false, "legacyFolderID": "F1afb5a5e1ef7cbed1b1369773f2b265959c38dcf96d159936bdd5db0bd4c609e", "searchTerms": [], + "slug": "5-exceed", "title": "Level 5 (EXCEED GEAR)", - "where": "chart.level = '5'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '5'" }, { "game": "sdvx", @@ -36456,9 +40189,10 @@ "inactive": true, "legacyFolderID": "Fe897de5f73b11bb9b7ac1460f9987bd329470b12bf147d4e70f18191594d18e0", "searchTerms": [], + "slug": "5-heaven", "title": "Level 5 (HEAVENLY HAVEN)", - "where": "chart.level = '5'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '5'" }, { "game": "sdvx", @@ -36466,9 +40200,10 @@ "inactive": false, "legacyFolderID": "F22aa46c768fd1ad2199ac7c7c4a90dd9ebe00d5e051828e512730ae8256ebe3b", "searchTerms": [], + "slug": "5-konaste", "title": "Level 5 (Konaste)", - "where": "chart.level = '5'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '5'" }, { "game": "sdvx", @@ -36476,9 +40211,10 @@ "inactive": false, "legacyFolderID": "F744318625ac1d290341e44bc1fbb37d26cebb1ecf3dc3dd939a8755c1d35f420", "searchTerms": [], + "slug": "5-vivid", "title": "Level 5 (VIVID WAVE)", - "where": "chart.level = '5'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '5'" }, { "game": "sdvx", @@ -36486,9 +40222,10 @@ "inactive": false, "legacyFolderID": "F9cb89bb280d284b273802a7567092707a4eea776f5fd2e59bea60ae66103b944", "searchTerms": [], + "slug": "6-exceed", "title": "Level 6 (EXCEED GEAR)", - "where": "chart.level = '6'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '6'" }, { "game": "sdvx", @@ -36496,9 +40233,10 @@ "inactive": true, "legacyFolderID": "Fe082f0e4c7c8c5577057de31310a66def2b960cfb6fd317ff43902f928b63ece", "searchTerms": [], + "slug": "6-heaven", "title": "Level 6 (HEAVENLY HAVEN)", - "where": "chart.level = '6'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '6'" }, { "game": "sdvx", @@ -36506,9 +40244,10 @@ "inactive": false, "legacyFolderID": "F6b7ad87de041913351f7e372f0885818de4c1aeeb9d81298992d1dd3e49e481b", "searchTerms": [], + "slug": "6-konaste", "title": "Level 6 (Konaste)", - "where": "chart.level = '6'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '6'" }, { "game": "sdvx", @@ -36516,9 +40255,10 @@ "inactive": false, "legacyFolderID": "F9d0f77e317f93b0d7fdd4f8b7deb15569a2088a22a4d1a764e95692d92b794fc", "searchTerms": [], + "slug": "6-vivid", "title": "Level 6 (VIVID WAVE)", - "where": "chart.level = '6'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '6'" }, { "game": "sdvx", @@ -36526,9 +40266,10 @@ "inactive": false, "legacyFolderID": "F148e2bf0bc39bdb715fa8e5f55c0047b991ca454e778cd70e86b01fdc5fa8369", "searchTerms": [], + "slug": "7-exceed", "title": "Level 7 (EXCEED GEAR)", - "where": "chart.level = '7'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '7'" }, { "game": "sdvx", @@ -36536,9 +40277,10 @@ "inactive": true, "legacyFolderID": "F56f5f56487739c972c9536d89575e3508af235b3bdae5caf7e89f0bf663d1e17", "searchTerms": [], + "slug": "7-heaven", "title": "Level 7 (HEAVENLY HAVEN)", - "where": "chart.level = '7'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '7'" }, { "game": "sdvx", @@ -36546,9 +40288,10 @@ "inactive": false, "legacyFolderID": "Ffc6495fb7e8789dcca49afca5191aa8fb48d54b0c9db36fc1f3aa3b07ad40b13", "searchTerms": [], + "slug": "7-konaste", "title": "Level 7 (Konaste)", - "where": "chart.level = '7'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '7'" }, { "game": "sdvx", @@ -36556,9 +40299,10 @@ "inactive": false, "legacyFolderID": "F4534cc8df28e9b653f149d2ae67c38609cb882996d51b78cb4642040a3a34918", "searchTerms": [], + "slug": "7-vivid", "title": "Level 7 (VIVID WAVE)", - "where": "chart.level = '7'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '7'" }, { "game": "sdvx", @@ -36566,9 +40310,10 @@ "inactive": false, "legacyFolderID": "Fe7eaa5c7b32ffd3f14a4cf1af5e03fa7a87d1cc6fafd4623b970ede656498c5f", "searchTerms": [], + "slug": "8-exceed", "title": "Level 8 (EXCEED GEAR)", - "where": "chart.level = '8'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '8'" }, { "game": "sdvx", @@ -36576,9 +40321,10 @@ "inactive": true, "legacyFolderID": "F374027b23a18f0321d1a90796bf9f1719a3541ea5fcc703282f81041d8652dc6", "searchTerms": [], + "slug": "8-heaven", "title": "Level 8 (HEAVENLY HAVEN)", - "where": "chart.level = '8'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '8'" }, { "game": "sdvx", @@ -36586,9 +40332,10 @@ "inactive": false, "legacyFolderID": "Fc4d63859cc77e7b082e48e6f967ba52829140a8d483361f3e1f5a08296a18704", "searchTerms": [], + "slug": "8-konaste", "title": "Level 8 (Konaste)", - "where": "chart.level = '8'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '8'" }, { "game": "sdvx", @@ -36596,9 +40343,10 @@ "inactive": false, "legacyFolderID": "F9213a97736af2597fa7184acdd81ae911725ce463c4097b37d5c42c11a358352", "searchTerms": [], + "slug": "8-vivid", "title": "Level 8 (VIVID WAVE)", - "where": "chart.level = '8'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '8'" }, { "game": "sdvx", @@ -36606,9 +40354,10 @@ "inactive": false, "legacyFolderID": "F3e5fe3ee9dd61f50fd89926be57f26b60ab4b6d5f6c92044bea460dc18dce225", "searchTerms": [], + "slug": "9-exceed", "title": "Level 9 (EXCEED GEAR)", - "where": "chart.level = '9'", - "versionFilter": ["exceed"] + "versionFilter": ["exceed"], + "where": "chart.level = '9'" }, { "game": "sdvx", @@ -36616,9 +40365,10 @@ "inactive": true, "legacyFolderID": "F1dfe64f56c5bce16e5f1e7093e137c67ac69511c0b1980c08ed5b8ac0a1c479d", "searchTerms": [], + "slug": "9-heaven", "title": "Level 9 (HEAVENLY HAVEN)", - "where": "chart.level = '9'", - "versionFilter": ["heaven"] + "versionFilter": ["heaven"], + "where": "chart.level = '9'" }, { "game": "sdvx", @@ -36626,9 +40376,10 @@ "inactive": false, "legacyFolderID": "F6aeb739080424e4d99cf16381c43d70221f0c6e345027e77d944c844009fb1ba", "searchTerms": [], + "slug": "9-konaste", "title": "Level 9 (Konaste)", - "where": "chart.level = '9'", - "versionFilter": ["konaste"] + "versionFilter": ["konaste"], + "where": "chart.level = '9'" }, { "game": "sdvx", @@ -36636,9 +40387,10 @@ "inactive": false, "legacyFolderID": "Fae3a8a87140cc25ac69a64992a5c0a53e09f733a59d1fe27cf961b228d0fab3a", "searchTerms": [], + "slug": "9-vivid", "title": "Level 9 (VIVID WAVE)", - "where": "chart.level = '9'", - "versionFilter": ["vivid"] + "versionFilter": ["vivid"], + "where": "chart.level = '9'" }, { "game": "usc-controller", @@ -36646,6 +40398,7 @@ "inactive": false, "legacyFolderID": "Fbd6491268e58d77f7235cf77e1ff8b3c12434598045d63ee12444dbb0f24003c", "searchTerms": [], + "slug": "official-1", "title": "Level 1", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '1'" }, @@ -36655,6 +40408,7 @@ "inactive": false, "legacyFolderID": "Fe6a999843d143bb3830ab741b777405973cf5db624e6132fa9c14d1f09115d8d", "searchTerms": [], + "slug": "official-10", "title": "Level 10", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '10'" }, @@ -36664,6 +40418,7 @@ "inactive": false, "legacyFolderID": "F331c86ba18dc2eecdc9a828909e5739cbd7ac107e3a54136b6bd5b9f629f7936", "searchTerms": [], + "slug": "official-11", "title": "Level 11", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '11'" }, @@ -36673,6 +40428,7 @@ "inactive": false, "legacyFolderID": "Fa848191eed384af0c417c7257ccb23cecf71b3aa64f09fcdd99595600e6d23f1", "searchTerms": [], + "slug": "official-12", "title": "Level 12", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '12'" }, @@ -36682,6 +40438,7 @@ "inactive": false, "legacyFolderID": "F5a779e648e0d7f6404270bcb686b2ad511f8348d9bd75103f82fce49f83c4775", "searchTerms": [], + "slug": "official-13", "title": "Level 13", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '13'" }, @@ -36691,6 +40448,7 @@ "inactive": false, "legacyFolderID": "F4f0da0e567c2f1f7c7c139266fa268c353faf52cf2622a15a708f42e2ca104c7", "searchTerms": [], + "slug": "official-14", "title": "Level 14", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '14'" }, @@ -36700,6 +40458,7 @@ "inactive": false, "legacyFolderID": "F29f53250a2d6fca6a670164c2f84e3b0e516396a8233fb09860f990dd39ecaf2", "searchTerms": [], + "slug": "official-15", "title": "Level 15", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '15'" }, @@ -36709,6 +40468,7 @@ "inactive": false, "legacyFolderID": "Ff8e36afd2c27534ad3ec4510d2d6de9e08375957b476dd5cbeccceee67c1a4ed", "searchTerms": [], + "slug": "official-16", "title": "Level 16", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '16'" }, @@ -36718,6 +40478,7 @@ "inactive": false, "legacyFolderID": "Fc6305d2287548963d114d12ed63b2742b12dd2163cb9381163204ff167246a23", "searchTerms": [], + "slug": "official-17", "title": "Level 17", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '17'" }, @@ -36727,6 +40488,7 @@ "inactive": false, "legacyFolderID": "F3c6fb136b72a43a67cdecc8e838f15993055554d6f42578a715f9fb8261f9d8f", "searchTerms": [], + "slug": "official-18", "title": "Level 18", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '18'" }, @@ -36736,6 +40498,7 @@ "inactive": false, "legacyFolderID": "Fb832d428d6372a4dd48b8eee5ea5ce57552aa7127a9c5d738cf6f99f72b0789d", "searchTerms": [], + "slug": "official-19", "title": "Level 19", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '19'" }, @@ -36745,6 +40508,7 @@ "inactive": false, "legacyFolderID": "Fb206351cde586376361e32bdea80eb33e3b29baa3cbb12c2b8bbbff161b75cec", "searchTerms": [], + "slug": "official-2", "title": "Level 2", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '2'" }, @@ -36754,6 +40518,7 @@ "inactive": false, "legacyFolderID": "F3146211510f51936b34192372b384f2a64e1ee9ee9c81725ab6c54f698a1c356", "searchTerms": [], + "slug": "official-20", "title": "Level 20", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '20'" }, @@ -36763,6 +40528,7 @@ "inactive": false, "legacyFolderID": "F1463f86b51d6835dad7cf00404426631d07ec4df53b8bdf7ae1d2229247be687", "searchTerms": [], + "slug": "official-3", "title": "Level 3", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '3'" }, @@ -36772,6 +40538,7 @@ "inactive": false, "legacyFolderID": "F4a5f7c6ab2d8e17ac74cfc006cca075d5d26648708db7853bb2f84e350072818", "searchTerms": [], + "slug": "official-4", "title": "Level 4", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '4'" }, @@ -36781,6 +40548,7 @@ "inactive": false, "legacyFolderID": "F93994af6d1dafd1a0df78be50402e5c8aa7a147f43e2787354659cb6bc7ede5e", "searchTerms": [], + "slug": "official-5", "title": "Level 5", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '5'" }, @@ -36790,6 +40558,7 @@ "inactive": false, "legacyFolderID": "F33e1df1311d878c9ddd228d2cd0bebaf95528041611695079ebf0dfb49d509f8", "searchTerms": [], + "slug": "official-6", "title": "Level 6", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '6'" }, @@ -36799,6 +40568,7 @@ "inactive": false, "legacyFolderID": "F167dce8cc0e2b7f8c4c9f2d13791f807d0f6518e8bd77c79e67f5098f1c3f986", "searchTerms": [], + "slug": "official-7", "title": "Level 7", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '7'" }, @@ -36808,6 +40578,7 @@ "inactive": false, "legacyFolderID": "Fffcae2333ff1ee72a60aafaa0397e354110a0ea314b220d5ce198cacd9b0f7fb", "searchTerms": [], + "slug": "official-8", "title": "Level 8", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '8'" }, @@ -36817,6 +40588,7 @@ "inactive": false, "legacyFolderID": "F347bab97a97fbb3ef423669dde231d471e18aca60f796aaf32b93564be93ad11", "searchTerms": [], + "slug": "official-9", "title": "Level 9", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '9'" }, @@ -36826,6 +40598,7 @@ "inactive": false, "legacyFolderID": "F097bdef47a3a0a2d7a5704dbbd355db9801b9b6c89955619097706c0afd90306", "searchTerms": [], + "slug": "os0", "title": "os0", "where": "(chart.data->'tableFolders'->>'os') IN ('0', '0*')" }, @@ -36835,6 +40608,7 @@ "inactive": false, "legacyFolderID": "Fab20b9c40310ae238bfadba48e84803702f869f6238e89d9dc0127b488b1d9e1", "searchTerms": [], + "slug": "os1", "title": "os1", "where": "(chart.data->'tableFolders'->>'os') IN ('1', '1*')" }, @@ -36844,6 +40618,7 @@ "inactive": false, "legacyFolderID": "F2e3fecb1fc3d6907a88adedb72a249d0005693afae23d581979c9a18a875d4fe", "searchTerms": [], + "slug": "os10", "title": "os10", "where": "(chart.data->'tableFolders'->>'os') IN ('10', '10*')" }, @@ -36853,6 +40628,7 @@ "inactive": false, "legacyFolderID": "F276f8847e6ea5f256b9ec9b81c8e9a98b833ae6420aa3c4dc10dce8ea711123e", "searchTerms": [], + "slug": "os11", "title": "os11", "where": "(chart.data->'tableFolders'->>'os') IN ('11', '11*')" }, @@ -36862,6 +40638,7 @@ "inactive": false, "legacyFolderID": "F8ffe4bc2b4eecd8953d883993099846431f12677bd8a533a214e424246a27eac", "searchTerms": [], + "slug": "os12", "title": "os12", "where": "(chart.data->'tableFolders'->>'os') IN ('12', '12*')" }, @@ -36871,6 +40648,7 @@ "inactive": false, "legacyFolderID": "F9be9d53e88719aad19d3a7f79039b0dbcd631f8960cbde016ee864c136b37b41", "searchTerms": [], + "slug": "os13", "title": "os13", "where": "(chart.data->'tableFolders'->>'os') IN ('13', '13*')" }, @@ -36880,6 +40658,7 @@ "inactive": false, "legacyFolderID": "Ff5cd23185d2145945215839ce43812452a6c13be7f050b8ae2d108ace095a31b", "searchTerms": [], + "slug": "os16", "title": "os16", "where": "(chart.data->'tableFolders'->>'os') IN ('16', '16*')" }, @@ -36889,6 +40668,7 @@ "inactive": false, "legacyFolderID": "F916899cd11e9298ee2a938f9b5a894224ac4ecae239333e0608aa322e3493d1c", "searchTerms": [], + "slug": "os2", "title": "os2", "where": "(chart.data->'tableFolders'->>'os') IN ('2', '2*')" }, @@ -36898,6 +40678,7 @@ "inactive": false, "legacyFolderID": "F0d21c2895351d97e1863e2915002a83aa92f077c0a8bd281b530de5ea2bbcc58", "searchTerms": [], + "slug": "os3", "title": "os3", "where": "(chart.data->'tableFolders'->>'os') IN ('3', '3*')" }, @@ -36907,6 +40688,7 @@ "inactive": false, "legacyFolderID": "F48d6ba3f1f2156ca630165945be877c77115beca9f7486dda740abd7a57a3b7e", "searchTerms": [], + "slug": "os4", "title": "os4", "where": "(chart.data->'tableFolders'->>'os') IN ('4', '4*')" }, @@ -36916,6 +40698,7 @@ "inactive": false, "legacyFolderID": "F6109291fab8721632f6d536b3afcea8fe776a3494fe14e2af1b0fd9549d255fc", "searchTerms": [], + "slug": "os5", "title": "os5", "where": "(chart.data->'tableFolders'->>'os') IN ('5', '5*')" }, @@ -36925,6 +40708,7 @@ "inactive": false, "legacyFolderID": "F1a0f8561dbcd646f0edbf295f8c8e904fe45cce82a1e82743afb92b0951f874e", "searchTerms": [], + "slug": "os6", "title": "os6", "where": "(chart.data->'tableFolders'->>'os') IN ('6', '6*')" }, @@ -36934,6 +40718,7 @@ "inactive": false, "legacyFolderID": "F4e656435042cec23f8e8af1464b1630e8ca6abe89677f8fd7a6f2659f75ee653", "searchTerms": [], + "slug": "os7", "title": "os7", "where": "(chart.data->'tableFolders'->>'os') IN ('7', '7*')" }, @@ -36943,6 +40728,7 @@ "inactive": false, "legacyFolderID": "F054baae5749ba38208d294fc0fac9cb7f1c3590d92afb8a5991d322d938ef96b", "searchTerms": [], + "slug": "os8", "title": "os8", "where": "(chart.data->'tableFolders'->>'os') IN ('8', '8*')" }, @@ -36952,6 +40738,7 @@ "inactive": false, "legacyFolderID": "F4b15814e427ef7b3b33e7faef50cebf8fab317ef05334b03fbe16981e2e47e89", "searchTerms": [], + "slug": "os9", "title": "os9", "where": "(chart.data->'tableFolders'->>'os') IN ('9', '9*')" }, @@ -36961,6 +40748,7 @@ "inactive": false, "legacyFolderID": "F1fca8f47b3cf9aec897f3c0d6e461d739121a796f132a239d6cfe4e860d3da65", "searchTerms": [], + "slug": "us0s", "title": "us0*", "where": "(chart.data->'tableFolders'->>'us') = '0*'" }, @@ -36970,6 +40758,7 @@ "inactive": false, "legacyFolderID": "Fa84fa6d42217dacbbec98ee9c038f0905ea636d89cca27e1b0491713bbd79603", "searchTerms": [], + "slug": "us1", "title": "us1", "where": "(chart.data->'tableFolders'->>'us') IN ('1', '1*')" }, @@ -36979,6 +40768,7 @@ "inactive": false, "legacyFolderID": "Fd75d6a2dd1a59ca49f29b085119eb954c34d7e08d9f5443b657346c0e5d5939b", "searchTerms": [], + "slug": "us10", "title": "us10", "where": "(chart.data->'tableFolders'->>'us') IN ('10', '10*')" }, @@ -36988,6 +40778,7 @@ "inactive": false, "legacyFolderID": "Ff99304779cd8bfe66eb12dad9702d801fe8a1cbf248bb75e4697da4cb8ad983f", "searchTerms": [], + "slug": "us11", "title": "us11", "where": "(chart.data->'tableFolders'->>'us') IN ('11', '11*')" }, @@ -36997,6 +40788,7 @@ "inactive": false, "legacyFolderID": "Fb8380fd4133b5deeefe5bacedf23590ce9a512c662a28661ca7d442faa4041f5", "searchTerms": [], + "slug": "us12", "title": "us12", "where": "(chart.data->'tableFolders'->>'us') IN ('12', '12*')" }, @@ -37006,6 +40798,7 @@ "inactive": false, "legacyFolderID": "F8e33babef5871d1d37267a43f9dbebfcf882c539d31eaabda49e17f5d41e5d92", "searchTerms": [], + "slug": "us13", "title": "us13", "where": "(chart.data->'tableFolders'->>'us') IN ('13', '13*')" }, @@ -37015,6 +40808,7 @@ "inactive": false, "legacyFolderID": "Fe734131d4cdf868f4f758a99f100aa00e834dc3dda4a131e93b75cfca2848efc", "searchTerms": [], + "slug": "us14", "title": "us14", "where": "(chart.data->'tableFolders'->>'us') IN ('14', '14*')" }, @@ -37024,6 +40818,7 @@ "inactive": false, "legacyFolderID": "F07a3d5e00da9b6f600ef6ac65dcd03a27009a4a2d5fadc5deaeb523476a610b4", "searchTerms": [], + "slug": "us15", "title": "us15", "where": "(chart.data->'tableFolders'->>'us') IN ('15', '15*')" }, @@ -37033,6 +40828,7 @@ "inactive": false, "legacyFolderID": "F7c4be3ed7d2dd6d1f1bfed9d05aaf0395c425d3bc7f4de7b822d77ccc32ee059", "searchTerms": [], + "slug": "us16", "title": "us16", "where": "(chart.data->'tableFolders'->>'us') IN ('16', '16*')" }, @@ -37042,6 +40838,7 @@ "inactive": false, "legacyFolderID": "F11768b3b611b84dc7f69e6f59185dd6a6cd654543bce47c079e9d3f994e4a72b", "searchTerms": [], + "slug": "us17", "title": "us17", "where": "(chart.data->'tableFolders'->>'us') IN ('17', '17*')" }, @@ -37051,6 +40848,7 @@ "inactive": false, "legacyFolderID": "Fb77cbc535a2a4f0204d20df78fea06f6779f21c996a2e1e44c3350afee19f98c", "searchTerms": [], + "slug": "us17p", "title": "us17+", "where": "(chart.data->'tableFolders'->>'us') IN ('17+', '17+*')" }, @@ -37060,6 +40858,7 @@ "inactive": false, "legacyFolderID": "F5f740d4d61b2d6bfc06b0bd16ef83bc22fbb513de7be669d3c5914f3e4ac551a", "searchTerms": [], + "slug": "us18", "title": "us18", "where": "(chart.data->'tableFolders'->>'us') IN ('18', '18*')" }, @@ -37069,6 +40868,7 @@ "inactive": false, "legacyFolderID": "F219c989840b1e0f52267e44a995729ed243f7a0959b266df79e62cd43946a784", "searchTerms": [], + "slug": "us18p", "title": "us18+", "where": "(chart.data->'tableFolders'->>'us') IN ('18+', '18+*')" }, @@ -37078,6 +40878,7 @@ "inactive": false, "legacyFolderID": "Ff98c69cb7b136450be2ea58156489add8b5bdde2c7ab14c7171d5c27453512e2", "searchTerms": [], + "slug": "us2", "title": "us2", "where": "(chart.data->'tableFolders'->>'us') IN ('2', '2*')" }, @@ -37087,6 +40888,7 @@ "inactive": false, "legacyFolderID": "F9c9186eabbf33785fa9c36fb0e0949b830dc6ddd247d276aaf2b983d9e0bfb80", "searchTerms": [], + "slug": "us3", "title": "us3", "where": "(chart.data->'tableFolders'->>'us') IN ('3', '3*')" }, @@ -37096,6 +40898,7 @@ "inactive": false, "legacyFolderID": "F3bed69cd80b42f9d863f06bcb7b05d52377c3c6d39264538976c931b5879842c", "searchTerms": [], + "slug": "us4", "title": "us4", "where": "(chart.data->'tableFolders'->>'us') IN ('4', '4*')" }, @@ -37105,6 +40908,7 @@ "inactive": false, "legacyFolderID": "F461058117d55163632029e3b21c135f49252128e352bd1329065f86268883d0d", "searchTerms": [], + "slug": "us5", "title": "us5", "where": "(chart.data->'tableFolders'->>'us') IN ('5', '5*')" }, @@ -37114,6 +40918,7 @@ "inactive": false, "legacyFolderID": "F5602910e262e50fe26a8f488863d3d4c0293021065dd116d9d30bffe9215e403", "searchTerms": [], + "slug": "us6", "title": "us6", "where": "(chart.data->'tableFolders'->>'us') IN ('6', '6*')" }, @@ -37123,6 +40928,7 @@ "inactive": false, "legacyFolderID": "F63fb2048b7c0e258a90b6a96e8462e623a201cf0e4c301501ef87dd57af16480", "searchTerms": [], + "slug": "us7", "title": "us7", "where": "(chart.data->'tableFolders'->>'us') IN ('7', '7*')" }, @@ -37132,6 +40938,7 @@ "inactive": false, "legacyFolderID": "F663b1a497fe20e7132c1d2337d2358f4f2b4b69476a7e0a15d5e975397fab5f9", "searchTerms": [], + "slug": "us8", "title": "us8", "where": "(chart.data->'tableFolders'->>'us') IN ('8', '8*')" }, @@ -37141,6 +40948,7 @@ "inactive": false, "legacyFolderID": "F542afb14f20282ab7eff792bb36059d645481530bd2008a4ac7e1868e2c3ef8c", "searchTerms": [], + "slug": "us9", "title": "us9", "where": "(chart.data->'tableFolders'->>'us') IN ('9', '9*')" }, @@ -37150,6 +40958,7 @@ "inactive": false, "legacyFolderID": "F4cd619d9311f962c627f48a69b0459de6c77e142e22f44a3c04fbf1ee9007b69", "searchTerms": [], + "slug": "official-1", "title": "Level 1", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '1'" }, @@ -37159,6 +40968,7 @@ "inactive": false, "legacyFolderID": "F4165d4b81402d29611ca48fcdc5c355aa0546c20dd2b5aea71eb611f5ede3b1c", "searchTerms": [], + "slug": "official-10", "title": "Level 10", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '10'" }, @@ -37168,6 +40978,7 @@ "inactive": false, "legacyFolderID": "F58b84689e18a0fa65f478f5906f04073f2d0056ca28c51ebf943680045aa31d9", "searchTerms": [], + "slug": "official-11", "title": "Level 11", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '11'" }, @@ -37177,6 +40988,7 @@ "inactive": false, "legacyFolderID": "Faa70a6e3d680fdec5b79502d230dc91bfa9c87ae4a02c1b67fcafa11dff07b2b", "searchTerms": [], + "slug": "official-12", "title": "Level 12", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '12'" }, @@ -37186,6 +40998,7 @@ "inactive": false, "legacyFolderID": "F5281b2bc3751674d15080b8f62208f7cce0226f767c1db98a986951cb778c82b", "searchTerms": [], + "slug": "official-13", "title": "Level 13", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '13'" }, @@ -37195,6 +41008,7 @@ "inactive": false, "legacyFolderID": "F735b19db178eafa978bc6062a0331d238cb07958c5522620db9b242690a42f85", "searchTerms": [], + "slug": "official-14", "title": "Level 14", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '14'" }, @@ -37204,6 +41018,7 @@ "inactive": false, "legacyFolderID": "Fe83119cb9f568b2fd53c3b8918550709c4a769298a79569a46277db75798bdd3", "searchTerms": [], + "slug": "official-15", "title": "Level 15", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '15'" }, @@ -37213,6 +41028,7 @@ "inactive": false, "legacyFolderID": "F7a217927ca1b9690e6d9eaedab26f4f402e7419eb8313cd08d18039f2114d28e", "searchTerms": [], + "slug": "official-16", "title": "Level 16", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '16'" }, @@ -37222,6 +41038,7 @@ "inactive": false, "legacyFolderID": "Fc3d0bff86c193cabbd0eb821008ddc0dbe73d65697f7cca1b5e2ea26184a42bc", "searchTerms": [], + "slug": "official-17", "title": "Level 17", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '17'" }, @@ -37231,6 +41048,7 @@ "inactive": false, "legacyFolderID": "F86361bb66dc9b36dc12e29820186b50844ee28095c811742b3694ef5730164d8", "searchTerms": [], + "slug": "official-18", "title": "Level 18", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '18'" }, @@ -37240,6 +41058,7 @@ "inactive": false, "legacyFolderID": "Ffc1e07eef257d05a2991e66d2e623a78126110be67d4f67f9b9750c9513b191c", "searchTerms": [], + "slug": "official-19", "title": "Level 19", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '19'" }, @@ -37249,6 +41068,7 @@ "inactive": false, "legacyFolderID": "Ff38773faf782fbb9b4dc9b2cfc6d78887b9b6ccc18f33a457f4a6263aab8443b", "searchTerms": [], + "slug": "official-2", "title": "Level 2", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '2'" }, @@ -37258,6 +41078,7 @@ "inactive": false, "legacyFolderID": "F717e02cbcfb57bb2c00d9d967fa79404146d364ebaa91de500f9ff7a4741484c", "searchTerms": [], + "slug": "official-20", "title": "Level 20", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '20'" }, @@ -37267,6 +41088,7 @@ "inactive": false, "legacyFolderID": "F73b0a4a65c1ba3b09634f9659e7f6eeb8469a97519fa7efe19830163b4bc226d", "searchTerms": [], + "slug": "official-3", "title": "Level 3", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '3'" }, @@ -37276,6 +41098,7 @@ "inactive": false, "legacyFolderID": "F2ccebf9ffec59cd5ce7759fdc7db9fc2f4860c87ddb77b2fa955814c754b9c57", "searchTerms": [], + "slug": "official-4", "title": "Level 4", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '4'" }, @@ -37285,6 +41108,7 @@ "inactive": false, "legacyFolderID": "F75c7e23ecdc5ed342e9c14e7c299696dc50d7a063c5b0f0a3dbe1ac9030c0e07", "searchTerms": [], + "slug": "official-5", "title": "Level 5", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '5'" }, @@ -37294,6 +41118,7 @@ "inactive": false, "legacyFolderID": "F9d6e091bc8249fb5520643fc47bcb09773b1f784330929b1b6c2508df86f886b", "searchTerms": [], + "slug": "official-6", "title": "Level 6", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '6'" }, @@ -37303,6 +41128,7 @@ "inactive": false, "legacyFolderID": "F46c1399cfbffc9b41f32c84cf4dd16057885f3aa7b3d7a96fc801d571774d213", "searchTerms": [], + "slug": "official-7", "title": "Level 7", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '7'" }, @@ -37312,6 +41138,7 @@ "inactive": false, "legacyFolderID": "Fb4fdf71b17d1fd27dab427ef92584b1da8883defd230f01bbac92585641a8a1b", "searchTerms": [], + "slug": "official-8", "title": "Level 8", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '8'" }, @@ -37321,6 +41148,7 @@ "inactive": false, "legacyFolderID": "Ff342aba5c5980c13ff2f11302debb466f6397032b47b1cc42246b8091a7f1744", "searchTerms": [], + "slug": "official-9", "title": "Level 9", "where": "chart.data->>'isOfficial' = 'true' AND chart.level = '9'" }, @@ -37330,6 +41158,7 @@ "inactive": false, "legacyFolderID": "F35a8d42f8518b6a3f80decf68e505ede3cac425029125d594a7641f9a27bd570", "searchTerms": [], + "slug": "os0", "title": "os0", "where": "(chart.data->'tableFolders'->>'os') IN ('0', '0*')" }, @@ -37339,6 +41168,7 @@ "inactive": false, "legacyFolderID": "Fb26d5c322f6eeec3d64178c36b6909e8c10da95439274e1284f39a9e8e1e36d2", "searchTerms": [], + "slug": "os1", "title": "os1", "where": "(chart.data->'tableFolders'->>'os') IN ('1', '1*')" }, @@ -37348,6 +41178,7 @@ "inactive": false, "legacyFolderID": "F84ee2a3ae28477eb978241e546fc5a1a96f6e9f6d4dc677c7dac67e401a6a852", "searchTerms": [], + "slug": "os10", "title": "os10", "where": "(chart.data->'tableFolders'->>'os') IN ('10', '10*')" }, @@ -37357,6 +41188,7 @@ "inactive": false, "legacyFolderID": "Fbbce753830cbc8a036602f130c08a8a6ce515668ef732aa38d8d2cb485be8410", "searchTerms": [], + "slug": "os11", "title": "os11", "where": "(chart.data->'tableFolders'->>'os') IN ('11', '11*')" }, @@ -37366,6 +41198,7 @@ "inactive": false, "legacyFolderID": "Fbef5841c1537dd792189c29bbf92a99d0a28befd6f4c31d82fab5bb800832911", "searchTerms": [], + "slug": "os12", "title": "os12", "where": "(chart.data->'tableFolders'->>'os') IN ('12', '12*')" }, @@ -37375,6 +41208,7 @@ "inactive": false, "legacyFolderID": "F06cbf2b792d188f935757c8324b2a2ca47491c0dd85dae0761b2af3a44cabb4e", "searchTerms": [], + "slug": "os13", "title": "os13", "where": "(chart.data->'tableFolders'->>'os') IN ('13', '13*')" }, @@ -37384,6 +41218,7 @@ "inactive": false, "legacyFolderID": "Fafc3082171961d27bd78b803af18b8f1effd20bec7bcb0a65f86d9631873a7df", "searchTerms": [], + "slug": "os16", "title": "os16", "where": "(chart.data->'tableFolders'->>'os') IN ('16', '16*')" }, @@ -37393,6 +41228,7 @@ "inactive": false, "legacyFolderID": "Ff96e67bbcd3ca8e35a3446a5b66c7bf68cffea434cea91a08418d37eac4f5f85", "searchTerms": [], + "slug": "os2", "title": "os2", "where": "(chart.data->'tableFolders'->>'os') IN ('2', '2*')" }, @@ -37402,6 +41238,7 @@ "inactive": false, "legacyFolderID": "F22585dd4ff3bae43a1151b42f958548858dc68605cd44c2d959e9c40aa0c06ad", "searchTerms": [], + "slug": "os3", "title": "os3", "where": "(chart.data->'tableFolders'->>'os') IN ('3', '3*')" }, @@ -37411,6 +41248,7 @@ "inactive": false, "legacyFolderID": "F74a8c703246d40d5714753e13f1b751bb245ee702b43cec457fcb262f092d5e2", "searchTerms": [], + "slug": "os4", "title": "os4", "where": "(chart.data->'tableFolders'->>'os') IN ('4', '4*')" }, @@ -37420,6 +41258,7 @@ "inactive": false, "legacyFolderID": "F723df54e30cd3607e8b652982a604f62a454d5a0c2291b649657399dd0270ead", "searchTerms": [], + "slug": "os5", "title": "os5", "where": "(chart.data->'tableFolders'->>'os') IN ('5', '5*')" }, @@ -37429,6 +41268,7 @@ "inactive": false, "legacyFolderID": "F9b1ba6bbc65edbb816d8cd57d6c255efa2ba021fbf4e0390e1990347281e963b", "searchTerms": [], + "slug": "os6", "title": "os6", "where": "(chart.data->'tableFolders'->>'os') IN ('6', '6*')" }, @@ -37438,6 +41278,7 @@ "inactive": false, "legacyFolderID": "F3f4547a92bfe0b97c5b152d1cc3d4970009f6fde1c310e49ff3faf5f12e05c5b", "searchTerms": [], + "slug": "os7", "title": "os7", "where": "(chart.data->'tableFolders'->>'os') IN ('7', '7*')" }, @@ -37447,6 +41288,7 @@ "inactive": false, "legacyFolderID": "F05e67e8905d5ced6ab9ec089878aac379aa05e810626365c0b1bafb644ff6b70", "searchTerms": [], + "slug": "os8", "title": "os8", "where": "(chart.data->'tableFolders'->>'os') IN ('8', '8*')" }, @@ -37456,6 +41298,7 @@ "inactive": false, "legacyFolderID": "F4acb11b2f052319867c9c1dc77cfe4a9a4f93cbb68104a3af9683c16204e64f7", "searchTerms": [], + "slug": "os9", "title": "os9", "where": "(chart.data->'tableFolders'->>'os') IN ('9', '9*')" }, @@ -37465,6 +41308,7 @@ "inactive": false, "legacyFolderID": "F3e2d3b7738b3720d1b5f12409713cc429c5eee629f70d146b897527b8868540f", "searchTerms": [], + "slug": "us0s", "title": "us0*", "where": "(chart.data->'tableFolders'->>'us') = '0*'" }, @@ -37474,6 +41318,7 @@ "inactive": false, "legacyFolderID": "F1235e8aabdeed900475218363b708c780a87350a6f725c44d8754496e6480b8d", "searchTerms": [], + "slug": "us1", "title": "us1", "where": "(chart.data->'tableFolders'->>'us') IN ('1', '1*')" }, @@ -37483,6 +41328,7 @@ "inactive": false, "legacyFolderID": "F251514ed915bcbb51346cfd9c94d374bf16a4ab042c60c9bc0b78f147b49d36d", "searchTerms": [], + "slug": "us10", "title": "us10", "where": "(chart.data->'tableFolders'->>'us') IN ('10', '10*')" }, @@ -37492,6 +41338,7 @@ "inactive": false, "legacyFolderID": "Fbe7d153c2d7ecb63fdd22b4177ddcaeb11dab4c122e2c97b96dc730650ece048", "searchTerms": [], + "slug": "us11", "title": "us11", "where": "(chart.data->'tableFolders'->>'us') IN ('11', '11*')" }, @@ -37501,6 +41348,7 @@ "inactive": false, "legacyFolderID": "F613bd980a7766413cc03f22080b1dd0e43109aee3bfb31dc01682534edd0451c", "searchTerms": [], + "slug": "us12", "title": "us12", "where": "(chart.data->'tableFolders'->>'us') IN ('12', '12*')" }, @@ -37510,6 +41358,7 @@ "inactive": false, "legacyFolderID": "Fc84fb4e38b05cc68f3b4dbb4ff7a86874dd8a71d5ca88091daeaecf90db715a2", "searchTerms": [], + "slug": "us13", "title": "us13", "where": "(chart.data->'tableFolders'->>'us') IN ('13', '13*')" }, @@ -37519,6 +41368,7 @@ "inactive": false, "legacyFolderID": "F32dcd87b756a50384c72c942d117717a65a69d67633f7b28f12c7a8ba8441573", "searchTerms": [], + "slug": "us14", "title": "us14", "where": "(chart.data->'tableFolders'->>'us') IN ('14', '14*')" }, @@ -37528,6 +41378,7 @@ "inactive": false, "legacyFolderID": "Fe594eff2547df63b4323f1f16b86af417f63bd85fd72ffecb3f2061cec48bb1b", "searchTerms": [], + "slug": "us15", "title": "us15", "where": "(chart.data->'tableFolders'->>'us') IN ('15', '15*')" }, @@ -37537,6 +41388,7 @@ "inactive": false, "legacyFolderID": "F25d2db896295f62c4a41e76921f99a915e872f51667739e866993a3c2e59390b", "searchTerms": [], + "slug": "us16", "title": "us16", "where": "(chart.data->'tableFolders'->>'us') IN ('16', '16*')" }, @@ -37546,6 +41398,7 @@ "inactive": false, "legacyFolderID": "Fcc3f92c3435d012959578162cc79a50a5b6cfdb3b753df36ed38d210461d4202", "searchTerms": [], + "slug": "us17", "title": "us17", "where": "(chart.data->'tableFolders'->>'us') IN ('17', '17*')" }, @@ -37555,6 +41408,7 @@ "inactive": false, "legacyFolderID": "Fcd94e34f9b299d909865571c3534e52c37946dbc102babe882a869e977f5b6ee", "searchTerms": [], + "slug": "us17p", "title": "us17+", "where": "(chart.data->'tableFolders'->>'us') IN ('17+', '17+*')" }, @@ -37564,6 +41418,7 @@ "inactive": false, "legacyFolderID": "Febd2d0f46bfb653df5989b05c2212a568c16508579ba4cc2958dcf5d1031321a", "searchTerms": [], + "slug": "us18", "title": "us18", "where": "(chart.data->'tableFolders'->>'us') IN ('18', '18*')" }, @@ -37573,6 +41428,7 @@ "inactive": false, "legacyFolderID": "F84c4efd5d5eafeba91ce9550cbdbd27f74ca08449f9b0ad7fd08f084b7fedff6", "searchTerms": [], + "slug": "us18p", "title": "us18+", "where": "(chart.data->'tableFolders'->>'us') IN ('18+', '18+*')" }, @@ -37582,6 +41438,7 @@ "inactive": false, "legacyFolderID": "F03c6840c56049cbb6dae62db6b762f62ba0ee897083ea0e972348ed71348e4cc", "searchTerms": [], + "slug": "us2", "title": "us2", "where": "(chart.data->'tableFolders'->>'us') IN ('2', '2*')" }, @@ -37591,6 +41448,7 @@ "inactive": false, "legacyFolderID": "F93f2d028b2c7b39de080956bd376cfe3a6264173c9b16684662b9f697b51c48b", "searchTerms": [], + "slug": "us3", "title": "us3", "where": "(chart.data->'tableFolders'->>'us') IN ('3', '3*')" }, @@ -37600,6 +41458,7 @@ "inactive": false, "legacyFolderID": "F5fa444c55e99b89344f4da24d530eafd8f71b39bfe435475c7a093a204f279c7", "searchTerms": [], + "slug": "us4", "title": "us4", "where": "(chart.data->'tableFolders'->>'us') IN ('4', '4*')" }, @@ -37609,6 +41468,7 @@ "inactive": false, "legacyFolderID": "Fd78c132e1c080af094e4443d298aad26b605d685733bec1f7d9ead3029b20820", "searchTerms": [], + "slug": "us5", "title": "us5", "where": "(chart.data->'tableFolders'->>'us') IN ('5', '5*')" }, @@ -37618,6 +41478,7 @@ "inactive": false, "legacyFolderID": "F2ba65483eb0785bb2c39ea0cffd46d06e290d84f73fc7a94a3791b1fbb5d96fd", "searchTerms": [], + "slug": "us6", "title": "us6", "where": "(chart.data->'tableFolders'->>'us') IN ('6', '6*')" }, @@ -37627,6 +41488,7 @@ "inactive": false, "legacyFolderID": "Fba96371b76db981eb7674566e50ca33ee5f37c34e9ec0e3391ec3d88e26ac82f", "searchTerms": [], + "slug": "us7", "title": "us7", "where": "(chart.data->'tableFolders'->>'us') IN ('7', '7*')" }, @@ -37636,6 +41498,7 @@ "inactive": false, "legacyFolderID": "Ff2b599759376a0544f552096f113f60bb8e0c4f4e89cecb119e273d528f7ff18", "searchTerms": [], + "slug": "us8", "title": "us8", "where": "(chart.data->'tableFolders'->>'us') IN ('8', '8*')" }, @@ -37645,6 +41508,7 @@ "inactive": false, "legacyFolderID": "F4534a28f9a3f67ba89fc945d45f7fdc0f4b0adb7cdd514359f2485ba8cda655b", "searchTerms": [], + "slug": "us9", "title": "us9", "where": "(chart.data->'tableFolders'->>'us') IN ('9', '9*')" }, @@ -37654,9 +41518,10 @@ "inactive": false, "legacyFolderID": "Fb0a6ca6fbfcd8d41f93af1451de0d0181659f9797b75c551b100f46254864704", "searchTerms": [], + "slug": "1-plus", "title": "Level 1 (PLUS)", - "where": "chart.level = '1'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '1'" }, { "game": "wacca", @@ -37664,9 +41529,10 @@ "inactive": false, "legacyFolderID": "F8aeb3d7ad32ceb71da28d6d1a14840063cdb0c6e44ea42ec4452cd8a82bf631d", "searchTerms": [], + "slug": "1-reverse", "title": "Level 1 (Reverse)", - "where": "chart.level = '1'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '1'" }, { "game": "wacca", @@ -37674,9 +41540,10 @@ "inactive": false, "legacyFolderID": "F0f0b033accd752211c5860094ba217fd2c376c8b8a3242b8162bbe9fe6a76d97", "searchTerms": [], + "slug": "10-plus", "title": "Level 10 (PLUS)", - "where": "chart.level = '10'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '10'" }, { "game": "wacca", @@ -37684,9 +41551,10 @@ "inactive": false, "legacyFolderID": "F3e6fbac38bc9adffdd311a2f48ee462d2335e036f932866eeb88ddab3068a081", "searchTerms": [], + "slug": "10-reverse", "title": "Level 10 (Reverse)", - "where": "chart.level = '10'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '10'" }, { "game": "wacca", @@ -37694,9 +41562,10 @@ "inactive": false, "legacyFolderID": "Fd989ab273c0d8b119d5bea40af154ca619f5fbcacf44b2c66c0c605721e70f30", "searchTerms": [], + "slug": "10p-plus", "title": "Level 10+ (PLUS)", - "where": "chart.level = '10+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '10+'" }, { "game": "wacca", @@ -37704,9 +41573,10 @@ "inactive": false, "legacyFolderID": "Fde9fe05041c82439cf8d69c89b1a22ae6515711f3f7a9e6d385ede613e237350", "searchTerms": [], + "slug": "10p-reverse", "title": "Level 10+ (Reverse)", - "where": "chart.level = '10+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '10+'" }, { "game": "wacca", @@ -37714,9 +41584,10 @@ "inactive": false, "legacyFolderID": "F95af268f73984e42cfff5c2df549b39c922c59b85d833f30f0f6d28358540f27", "searchTerms": [], + "slug": "11-plus", "title": "Level 11 (PLUS)", - "where": "chart.level = '11'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '11'" }, { "game": "wacca", @@ -37724,9 +41595,10 @@ "inactive": false, "legacyFolderID": "F6ba84462792092ca94dc512ffb9db599c32c3d200cfc5664304c696223b76208", "searchTerms": [], + "slug": "11-reverse", "title": "Level 11 (Reverse)", - "where": "chart.level = '11'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '11'" }, { "game": "wacca", @@ -37734,9 +41606,10 @@ "inactive": false, "legacyFolderID": "Fd0655c050adb398a56e55647a09471b846c4343e6f255a0c2a0f69652f90b75b", "searchTerms": [], + "slug": "11p-plus", "title": "Level 11+ (PLUS)", - "where": "chart.level = '11+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '11+'" }, { "game": "wacca", @@ -37744,9 +41617,10 @@ "inactive": false, "legacyFolderID": "Fb8d9e28453ce0f925c043afd8debb9de99fe0a79c77ff68ebea6dee5da414687", "searchTerms": [], + "slug": "11p-reverse", "title": "Level 11+ (Reverse)", - "where": "chart.level = '11+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '11+'" }, { "game": "wacca", @@ -37754,9 +41628,10 @@ "inactive": false, "legacyFolderID": "F6c06095cac9eb7c46de71c907161ec41bf4b274ca9458fbd76b391a31dcf1030", "searchTerms": [], + "slug": "12-plus", "title": "Level 12 (PLUS)", - "where": "chart.level = '12'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '12'" }, { "game": "wacca", @@ -37764,9 +41639,10 @@ "inactive": false, "legacyFolderID": "F8398e01ff026bceb5ce8db4593ac23fb1debdb6a42396f277bde2164a7141dc8", "searchTerms": [], + "slug": "12-reverse", "title": "Level 12 (Reverse)", - "where": "chart.level = '12'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '12'" }, { "game": "wacca", @@ -37774,9 +41650,10 @@ "inactive": false, "legacyFolderID": "F09b6d0e2f00ff88cd1c8dafb21903c12c3889b96050dda16d9d5551b1632eba6", "searchTerms": [], + "slug": "12p-plus", "title": "Level 12+ (PLUS)", - "where": "chart.level = '12+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '12+'" }, { "game": "wacca", @@ -37784,9 +41661,10 @@ "inactive": false, "legacyFolderID": "F6c447417ca4140d4ef60ec936d0e9a0ba7e652dc2fc27f3558579c2600deff1d", "searchTerms": [], + "slug": "12p-reverse", "title": "Level 12+ (Reverse)", - "where": "chart.level = '12+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '12+'" }, { "game": "wacca", @@ -37794,9 +41672,10 @@ "inactive": false, "legacyFolderID": "F66d8e7c44cb9bc67a393f76b6b315dc7c2284750c69901d551f566bd031dea8f", "searchTerms": [], + "slug": "13-plus", "title": "Level 13 (PLUS)", - "where": "chart.level = '13'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '13'" }, { "game": "wacca", @@ -37804,9 +41683,10 @@ "inactive": false, "legacyFolderID": "Fa12e4e2a6803ee48877e63904ffa515a3702b403da003ce51fb868c9d2e466d0", "searchTerms": [], + "slug": "13-reverse", "title": "Level 13 (Reverse)", - "where": "chart.level = '13'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '13'" }, { "game": "wacca", @@ -37814,9 +41694,10 @@ "inactive": false, "legacyFolderID": "F05849be934f20cc451d75c06dfb55bd8afbcefba1c21b20837d7371d72c1d3b2", "searchTerms": [], + "slug": "13p-plus", "title": "Level 13+ (PLUS)", - "where": "chart.level = '13+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '13+'" }, { "game": "wacca", @@ -37824,9 +41705,10 @@ "inactive": false, "legacyFolderID": "F3ff0b54889ddb11a52cba3f3298cfed39b720dc4e4547e8ca0b749d137457b85", "searchTerms": [], + "slug": "13p-reverse", "title": "Level 13+ (Reverse)", - "where": "chart.level = '13+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '13+'" }, { "game": "wacca", @@ -37834,9 +41716,10 @@ "inactive": false, "legacyFolderID": "Fe4bfd6bbcd097b94f1d34554b1c84f681896cf75190732bd0a5373b587fd0297", "searchTerms": [], + "slug": "14-15-plus", "title": "Level 14-15 (PLUS)", - "where": "chart.level IN ('14', '15')", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level IN ('14', '15')" }, { "game": "wacca", @@ -37844,9 +41727,10 @@ "inactive": false, "legacyFolderID": "F1d578f3601945f74ab33d78fab134889ebd215e191ed66df8a960fdcaca14fb3", "searchTerms": [], + "slug": "14-15-reverse", "title": "Level 14-15 (Reverse)", - "where": "chart.level IN ('14', '15')", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level IN ('14', '15')" }, { "game": "wacca", @@ -37854,9 +41738,10 @@ "inactive": false, "legacyFolderID": "F16d1c0d92b88d253e6716e87138cf92be4179f90f669a819b416c8264eab740a", "searchTerms": [], + "slug": "2-plus", "title": "Level 2 (PLUS)", - "where": "chart.level = '2'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '2'" }, { "game": "wacca", @@ -37864,9 +41749,10 @@ "inactive": false, "legacyFolderID": "Fdb034510c09c3c823f859f4f293c7bcd66f4cfb440a80f976a5fd1c0ec0629f1", "searchTerms": [], + "slug": "2-reverse", "title": "Level 2 (Reverse)", - "where": "chart.level = '2'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '2'" }, { "game": "wacca", @@ -37874,9 +41760,10 @@ "inactive": false, "legacyFolderID": "Fad342f4e6ebae828bd3345997b857e7203af1684dd40132f131603f6806a16e6", "searchTerms": [], + "slug": "3-plus", "title": "Level 3 (PLUS)", - "where": "chart.level = '3'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '3'" }, { "game": "wacca", @@ -37884,9 +41771,10 @@ "inactive": false, "legacyFolderID": "F1f5755b2b402e55b60cf8bc6bdedf78c81d3f8448b01e634880d15b0fc893b85", "searchTerms": [], + "slug": "3-reverse", "title": "Level 3 (Reverse)", - "where": "chart.level = '3'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '3'" }, { "game": "wacca", @@ -37894,9 +41782,10 @@ "inactive": false, "legacyFolderID": "F01a87577a5fcf2d88c0acb933958e7bd05d13edd754114ca77589a36a8815854", "searchTerms": [], + "slug": "4-plus", "title": "Level 4 (PLUS)", - "where": "chart.level = '4'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '4'" }, { "game": "wacca", @@ -37904,9 +41793,10 @@ "inactive": false, "legacyFolderID": "F8c9b6a6935a19aef1963d946c77095d6f45f892e8e74033c53f488e2c4befac2", "searchTerms": [], + "slug": "4-reverse", "title": "Level 4 (Reverse)", - "where": "chart.level = '4'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '4'" }, { "game": "wacca", @@ -37914,9 +41804,10 @@ "inactive": false, "legacyFolderID": "F41b69b5c2d6b118db454e077ca6b6ae816d20b7b618afcb46a110d13e9ab9f1c", "searchTerms": [], + "slug": "5-plus", "title": "Level 5 (PLUS)", - "where": "chart.level = '5'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '5'" }, { "game": "wacca", @@ -37924,9 +41815,10 @@ "inactive": false, "legacyFolderID": "F4ec0b5ae74cb213d3a04102742f73ea03dc487e36f4d38a4c3015cd9c6ba93b8", "searchTerms": [], + "slug": "5-reverse", "title": "Level 5 (Reverse)", - "where": "chart.level = '5'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '5'" }, { "game": "wacca", @@ -37934,9 +41826,10 @@ "inactive": false, "legacyFolderID": "Fec2188a562aa409cc74dfb5ac1511fe376046b8a7bf8f8060b15f90fd8bc773f", "searchTerms": [], + "slug": "5p-plus", "title": "Level 5+ (PLUS)", - "where": "chart.level = '5+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '5+'" }, { "game": "wacca", @@ -37944,9 +41837,10 @@ "inactive": false, "legacyFolderID": "F5214217b5fec7d24c28f2ea7ca8f7f1c6ea764986e928cf497dc121341fce0a2", "searchTerms": [], + "slug": "5p-reverse", "title": "Level 5+ (Reverse)", - "where": "chart.level = '5+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '5+'" }, { "game": "wacca", @@ -37954,9 +41848,10 @@ "inactive": false, "legacyFolderID": "F1657ed1c3f6be2aee363c83bbce907ab362590ac864d1c184a5f5f00e0aa3497", "searchTerms": [], + "slug": "6-plus", "title": "Level 6 (PLUS)", - "where": "chart.level = '6'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '6'" }, { "game": "wacca", @@ -37964,9 +41859,10 @@ "inactive": false, "legacyFolderID": "F778b4a27d947039d1ff100f32d61f45084e7572dd6a2aa0c319c32644fe082d8", "searchTerms": [], + "slug": "6-reverse", "title": "Level 6 (Reverse)", - "where": "chart.level = '6'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '6'" }, { "game": "wacca", @@ -37974,9 +41870,10 @@ "inactive": false, "legacyFolderID": "F6927196d6f92f6b45eec36189280a3249360eb85e7ab0a310893f77ac598770e", "searchTerms": [], + "slug": "6p-plus", "title": "Level 6+ (PLUS)", - "where": "chart.level = '6+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '6+'" }, { "game": "wacca", @@ -37984,9 +41881,10 @@ "inactive": false, "legacyFolderID": "Fe24cf5be470a561c470cecb5c670a9abf41584d94d6fca483a4e6583e685bc86", "searchTerms": [], + "slug": "6p-reverse", "title": "Level 6+ (Reverse)", - "where": "chart.level = '6+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '6+'" }, { "game": "wacca", @@ -37994,9 +41892,10 @@ "inactive": false, "legacyFolderID": "F307c1c8d67db461e62a7a1184638d38c6054a5e6e5ff534c5033572588cc62ff", "searchTerms": [], + "slug": "7-plus", "title": "Level 7 (PLUS)", - "where": "chart.level = '7'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '7'" }, { "game": "wacca", @@ -38004,9 +41903,10 @@ "inactive": false, "legacyFolderID": "Ffdf1ed3c801a0070b14a435248eb662a46916618d842fbb1206b0381613b3bf1", "searchTerms": [], + "slug": "7-reverse", "title": "Level 7 (Reverse)", - "where": "chart.level = '7'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '7'" }, { "game": "wacca", @@ -38014,9 +41914,10 @@ "inactive": false, "legacyFolderID": "Fb6a5be4cf06b3f6ab0371465538fc9667deeba4051f228f1dafb3339597cb6fa", "searchTerms": [], + "slug": "7p-plus", "title": "Level 7+ (PLUS)", - "where": "chart.level = '7+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '7+'" }, { "game": "wacca", @@ -38024,9 +41925,10 @@ "inactive": false, "legacyFolderID": "Fef11cad79e289a244a6a24fb12c073649de6c95c5147b1b154a0cf057eb758eb", "searchTerms": [], + "slug": "7p-reverse", "title": "Level 7+ (Reverse)", - "where": "chart.level = '7+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '7+'" }, { "game": "wacca", @@ -38034,9 +41936,10 @@ "inactive": false, "legacyFolderID": "F989a34ddf2f491e87de271bd0891dfd6fa57e48478d296062fe0cbfc58e80ee3", "searchTerms": [], + "slug": "8-plus", "title": "Level 8 (PLUS)", - "where": "chart.level = '8'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '8'" }, { "game": "wacca", @@ -38044,9 +41947,10 @@ "inactive": false, "legacyFolderID": "Fea798eda8dddc669eac1e37f1ed7d7e53ff405128559894dc1e8b03efe40ee8c", "searchTerms": [], + "slug": "8-reverse", "title": "Level 8 (Reverse)", - "where": "chart.level = '8'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '8'" }, { "game": "wacca", @@ -38054,9 +41958,10 @@ "inactive": false, "legacyFolderID": "Faa8d851948cfcfbd18e7158df90ce84d93de7cfe652a53bf7003ff72a5e4cbd7", "searchTerms": [], + "slug": "8p-plus", "title": "Level 8+ (PLUS)", - "where": "chart.level = '8+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '8+'" }, { "game": "wacca", @@ -38064,9 +41969,10 @@ "inactive": false, "legacyFolderID": "Fef28d3d9bd72cf25bddcf5918f50b66e46d3d8f511161612e9a73d364a1c0268", "searchTerms": [], + "slug": "8p-reverse", "title": "Level 8+ (Reverse)", - "where": "chart.level = '8+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '8+'" }, { "game": "wacca", @@ -38074,9 +41980,10 @@ "inactive": false, "legacyFolderID": "Fc3f5317104e4a67356754ec02ef548924811b96396971ffc431685b2a2abb297", "searchTerms": [], + "slug": "9-plus", "title": "Level 9 (PLUS)", - "where": "chart.level = '9'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '9'" }, { "game": "wacca", @@ -38084,9 +41991,10 @@ "inactive": false, "legacyFolderID": "Fbf908742ada0ccaf6f1f8cc7c0552a28e35184e73ea93cee018e828c51dfde4d", "searchTerms": [], + "slug": "9-reverse", "title": "Level 9 (Reverse)", - "where": "chart.level = '9'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '9'" }, { "game": "wacca", @@ -38094,9 +42002,10 @@ "inactive": false, "legacyFolderID": "Ffcdff71dec349ddce6571accfddc70e7c11fcc6f2912f954197c04545915df7a", "searchTerms": [], + "slug": "9p-plus", "title": "Level 9+ (PLUS)", - "where": "chart.level = '9+'", - "versionFilter": ["plus"] + "versionFilter": ["plus"], + "where": "chart.level = '9+'" }, { "game": "wacca", @@ -38104,8 +42013,9 @@ "inactive": false, "legacyFolderID": "F7bf691f79175d375b8c237232e973404994947e37297375a39a950c15d163d79", "searchTerms": [], + "slug": "9p-reverse", "title": "Level 9+ (Reverse)", - "where": "chart.level = '9+'", - "versionFilter": ["reverse"] + "versionFilter": ["reverse"], + "where": "chart.level = '9+'" } ] diff --git a/db/seeds/tables.json b/db/seeds/tables.json index c59369b9e..c50bc5e86 100644 --- a/db/seeds/tables.json +++ b/db/seeds/tables.json @@ -3,20 +3,20 @@ "default": true, "description": "Levels for the mobile version of Arcaea.", "folders": [ - "F19d35f0cf252f552f9f", - "F19d35f0cf254e708a9e", - "F19d35f0cf255d406a1d", - "F19d35f0cf25ba5633ef", - "F19d35f0cf25adcb6a2e", - "F19d35f0cf25130da3e2", - "F19d35f0cf2530038ec5", - "F19d35f0cf25f9312476", - "F19d35f0cf258374e9b3", - "F19d35f0cf2597ede2b5", - "F19d35f0cf2512e6aa36", - "F19d35f0cf25125212a3", - "F19d35f0cf250d7af976", - "F19d35f0cf25b7ac2d8b" + "1-mobile", + "2-mobile", + "3-mobile", + "4-mobile", + "5-mobile", + "6-mobile", + "7-mobile", + "8-mobile", + "9-mobile", + "9p-mobile", + "10-mobile", + "10p-mobile", + "11-mobile", + "12-mobile" ], "game": "arcaea", "id": "T19d35f0d592ecda1dea", @@ -28,20 +28,20 @@ "default": true, "description": "The 14K Insane table.", "folders": [ - "F19d35f0cf26cdf47d3d", - "F19d35f0cf26ba79462c", - "F19d35f0cf2604dc02f0", - "F19d35f0cf2688b8675a", - "F19d35f0cf266cab30db", - "F19d35f0cf26cacea467", - "F19d35f0cf2623556138", - "F19d35f0cf2654f2ecf3", - "F19d35f0cf26ef84bbcb", - "F19d35f0cf26e6625659", - "F19d35f0cf26bd14b1d4", - "F19d35f0cf26094e48ac", - "F19d35f0cf26ad197e79", - "F19d35f0cf26688ecfad" + "tf-dpinsane-1", + "tf-dpinsane-2", + "tf-dpinsane-3", + "tf-dpinsane-4", + "tf-dpinsane-5", + "tf-dpinsane-6", + "tf-dpinsane-7", + "tf-dpinsane-8", + "tf-dpinsane-9", + "tf-dpinsane-1-0", + "tf-dpinsane-1-1", + "tf-dpinsane-1-2", + "tf-dpinsane-1-3", + "tf-dpinsane-q" ], "game": "bms-14k", "id": "T19d35f0d592cdd89d67", @@ -53,27 +53,27 @@ "default": false, "description": "A huge collection of Normal-Scale 14K charts.", "folders": [ - "F19d35f0cf26a6d2ce5e", - "F19d35f0cf267dd803ca", - "F19d35f0cf2693b6c15d", - "F19d35f0cf263b318ed0", - "F19d35f0cf268c54ba18", - "F19d35f0cf266091b99f", - "F19d35f0cf2642225b53", - "F19d35f0cf2676926bd9", - "F19d35f0cf26632cfe61", - "F19d35f0cf2675b19b62", - "F19d35f0cf26aa204925", - "F19d35f0cf26e60366c2", - "F19d35f0cf26377a8130", - "F19d35f0cf26be232c9b", - "F19d35f0cf2609edfdfd", - "F19d35f0cf26266d166a", - "F19d35f0cf26d1d2c05f", - "F19d35f0cf260f0d847a", - "F19d35f0cf26048e77ce", - "F19d35f0cf262f00f07d", - "F19d35f0cf265daf4608" + "tf-dplibrary-1", + "tf-dplibrary-2", + "tf-dplibrary-3", + "tf-dplibrary-4", + "tf-dplibrary-5", + "tf-dplibrary-6", + "tf-dplibrary-7", + "tf-dplibrary-8-u5f31", + "tf-dplibrary-8-u5f37", + "tf-dplibrary-9-u5f31", + "tf-dplibrary-9-u4e2d", + "tf-dplibrary-9-u5f37", + "tf-dplibrary-1-0-u5f31", + "tf-dplibrary-1-0-u4e2d", + "tf-dplibrary-1-0-u5f37", + "tf-dplibrary-1-1-u5f31", + "tf-dplibrary-1-1-u4e2d", + "tf-dplibrary-1-1-u5f37", + "tf-dplibrary-q", + "tf-dplibrary-u8db3", + "tf-dplibrary-1-2" ], "game": "bms-14k", "id": "T19d35f0d5923965dbc2", @@ -85,18 +85,18 @@ "default": false, "description": "The 14K Normal table, Sometimes called the delta table.", "folders": [ - "F19d35f0cf2680db8f5d", - "F19d35f0cf269c59ab44", - "F19d35f0cf2607853f3d", - "F19d35f0cf2662fe2a9e", - "F19d35f0cf263461c766", - "F19d35f0cf26c7ec8003", - "F19d35f0cf262b6fda9a", - "F19d35f0cf26caa0e8a7", - "F19d35f0cf2677fb2456", - "F19d35f0cf2609ec34f8", - "F19d35f0cf26f10e80f4", - "F19d35f0cf2643f91a9a" + "tf-dpnormal-1", + "tf-dpnormal-2", + "tf-dpnormal-3", + "tf-dpnormal-4", + "tf-dpnormal-5", + "tf-dpnormal-6", + "tf-dpnormal-7", + "tf-dpnormal-8", + "tf-dpnormal-9", + "tf-dpnormal-1-0", + "tf-dpnormal-1-1", + "tf-dpnormal-9-9" ], "game": "bms-14k", "id": "T19d35f0d592d3e04041", @@ -108,19 +108,19 @@ "default": false, "description": "The 14K Satellite table.", "folders": [ - "F19d35f0cf26334d213b", - "F19d35f0cf2630233b9b", - "F19d35f0cf264c1795b2", - "F19d35f0cf263534a91b", - "F19d35f0cf26b3121797", - "F19d35f0cf26dc2ca266", - "F19d35f0cf265ac6ef0d", - "F19d35f0cf26d82fe699", - "F19d35f0cf26a14241c8", - "F19d35f0cf26304ca845", - "F19d35f0cf262d74b784", - "F19d35f0cf26e7880581", - "F19d35f0cf26b5cd20b7" + "tf-dpsatellite-0", + "tf-dpsatellite-1", + "tf-dpsatellite-2", + "tf-dpsatellite-3", + "tf-dpsatellite-4", + "tf-dpsatellite-5", + "tf-dpsatellite-6", + "tf-dpsatellite-7", + "tf-dpsatellite-8", + "tf-dpsatellite-9", + "tf-dpsatellite-1-0", + "tf-dpsatellite-1-1", + "tf-dpsatellite-1-2" ], "game": "bms-14k", "id": "T19d35f0d592fd8a27b5", @@ -132,10 +132,10 @@ "default": false, "description": "Meta tables that present existing tables as if they were single folders.", "folders": [ - "F19d35f0cf26f144b6c6", - "F19d35f0cf2648b6a28d", - "F19d35f0cf267e1d3da0", - "F19d35f0cf267c560e60" + "tf-dpinsane-present", + "tf-dpnormal-present", + "tf-dpsatellite-present", + "tf-dplibrary-present" ], "game": "bms-14k", "id": "T19d35f0d592f3442e54", @@ -147,19 +147,19 @@ "default": false, "description": "The Arm-Shougakkou table is A gachi and gachi-ish practice table, Ude0 is approximately equivalent to an sl0", "folders": [ - "F19d35f0cf26340b30ba", - "F19d35f0cf26551399dc", - "F19d35f0cf266ac7f371", - "F19d35f0cf267d02b705", - "F19d35f0cf26506c1bad", - "F19d35f0cf26c9a14a8e", - "F19d35f0cf2614d6e869", - "F19d35f0cf26cb06d429", - "F19d35f0cf265256cca9", - "F19d35f0cf26fff01eba", - "F19d35f0cf2669180c6a", - "F19d35f0cf263fba20d7", - "F19d35f0cf26424ac6fc" + "tf-armshougakkou-0", + "tf-armshougakkou-1", + "tf-armshougakkou-2", + "tf-armshougakkou-3", + "tf-armshougakkou-4", + "tf-armshougakkou-5", + "tf-armshougakkou-6", + "tf-armshougakkou-7", + "tf-armshougakkou-8", + "tf-armshougakkou-9", + "tf-armshougakkou-1-0", + "tf-armshougakkou-1-1", + "tf-armshougakkou-1-2" ], "game": "bms-7k", "id": "T19d35f0d5929e4fbda4", @@ -171,23 +171,23 @@ "default": false, "description": "Delayjoy is a delay practice table. dl0 is approximately equivalent to an st0", "folders": [ - "F19d35f0cf268bed465e", - "F19d35f0cf26364c4c3e", - "F19d35f0cf267957fb96", - "F19d35f0cf264f123bd5", - "F19d35f0cf26c4eecc14", - "F19d35f0cf2637d8cce3", - "F19d35f0cf26413d74ba", - "F19d35f0cf26ae5b9bcf", - "F19d35f0cf26430155d4", - "F19d35f0cf266d126865", - "F19d35f0cf26dac05734", - "F19d35f0cf26a5b642e4", - "F19d35f0cf263d584693", - "F19d35f0cf2665777ca9", - "F19d35f0cf26ab449386", - "F19d35f0cf26a7bdd091", - "F19d35f0cf26085d6146" + "tf-delayjoy-u20-d-e-l-a-y-u20-b-e-g-i-n-n-e-r", + "tf-delayjoy-dash-2", + "tf-delayjoy-dash-1", + "tf-delayjoy-0", + "tf-delayjoy-1", + "tf-delayjoy-2", + "tf-delayjoy-3", + "tf-delayjoy-4", + "tf-delayjoy-5", + "tf-delayjoy-6", + "tf-delayjoy-7", + "tf-delayjoy-8", + "tf-delayjoy-9", + "tf-delayjoy-1-0", + "tf-delayjoy-1-1", + "tf-delayjoy-1-2", + "tf-delayjoy-u20-d-e-l-a-y-u20-m-a-s-t-e-r" ], "game": "bms-7k", "id": "T19d35f0d5920a358e8f", @@ -199,15 +199,15 @@ "default": false, "description": "The dystopia table, dy0-dy7 == st5-st12.", "folders": [ - "F19d35f0cf263a135ba2", - "F19d35f0cf2608ffab9f", - "F19d35f0cf261fdf1813", - "F19d35f0cf2692ea2a3b", - "F19d35f0cf26e100267d", - "F19d35f0cf266cec282b", - "F19d35f0cf26a7b2bfdf", - "F19d35f0cf26e1f99323", - "F19d35f0cf2673058541" + "tf-dystopia-0", + "tf-dystopia-1", + "tf-dystopia-2", + "tf-dystopia-3", + "tf-dystopia-4", + "tf-dystopia-5", + "tf-dystopia-6", + "tf-dystopia-7", + "tf-dystopia-uff1f" ], "game": "bms-7k", "id": "T19d35f0d592b440a35d", @@ -219,19 +219,19 @@ "default": false, "description": "Exoplanet is a table by the stella/satellite maintainers with less rules on what can be submitted. Includes gimmick charts.", "folders": [ - "F19d35f0cf26580d40ad", - "F19d35f0cf26edc8d0a4", - "F19d35f0cf26bf887900", - "F19d35f0cf26c6460d28", - "F19d35f0cf26577d7b30", - "F19d35f0cf2651693338", - "F19d35f0cf26ccb0c875", - "F19d35f0cf26bd9480fb", - "F19d35f0cf26c19640eb", - "F19d35f0cf269484745d", - "F19d35f0cf2694b2c49e", - "F19d35f0cf2679a0dead", - "F19d35f0cf26aea829fd" + "tf-exoplanet-0", + "tf-exoplanet-1", + "tf-exoplanet-2", + "tf-exoplanet-3", + "tf-exoplanet-4", + "tf-exoplanet-5", + "tf-exoplanet-6", + "tf-exoplanet-7", + "tf-exoplanet-8", + "tf-exoplanet-9", + "tf-exoplanet-1-0", + "tf-exoplanet-1-1", + "tf-exoplanet-1-2" ], "game": "bms-7k", "id": "T19d35f0d59266a31411", @@ -243,15 +243,15 @@ "default": false, "description": "Gachimjoy is a gachi practice table. 双1 is approximately equivalent to an ★★1", "folders": [ - "F19d35f0cf26eee6cc2e", - "F19d35f0cf2680ee9e9b", - "F19d35f0cf2686917094", - "F19d35f0cf2641525c6f", - "F19d35f0cf2662049003", - "F19d35f0cf26e170ae8e", - "F19d35f0cf26c8ca407c", - "F19d35f0cf26781dd825", - "F19d35f0cf26b5d6a87b" + "tf-gachimjoy-0", + "tf-gachimjoy-1", + "tf-gachimjoy-2", + "tf-gachimjoy-3", + "tf-gachimjoy-4", + "tf-gachimjoy-5", + "tf-gachimjoy-6", + "tf-gachimjoy-7", + "tf-gachimjoy-8" ], "game": "bms-7k", "id": "T19d35f0d592e33c0d44", @@ -263,32 +263,32 @@ "default": true, "description": "The 7K GENOSIDE insane table.", "folders": [ - "F19d35f0cf26d093dee1", - "F19d35f0cf26bf39191d", - "F19d35f0cf267a67ef59", - "F19d35f0cf2690f5273f", - "F19d35f0cf268cedce44", - "F19d35f0cf26ffff243e", - "F19d35f0cf26e4bce9f1", - "F19d35f0cf267c3ce524", - "F19d35f0cf26213fa5a9", - "F19d35f0cf26395bcb2a", - "F19d35f0cf26eda7ea1e", - "F19d35f0cf263180c896", - "F19d35f0cf26501c0a5f", - "F19d35f0cf26099b873d", - "F19d35f0cf268931fccb", - "F19d35f0cf26eaf5c455", - "F19d35f0cf26872dd36e", - "F19d35f0cf26002161ad", - "F19d35f0cf267c09f772", - "F19d35f0cf260c5a043d", - "F19d35f0cf265749429d", - "F19d35f0cf26444b621f", - "F19d35f0cf2614cebbde", - "F19d35f0cf26449a3f6e", - "F19d35f0cf26e8887edb", - "F19d35f0cf2672f8990b" + "tf-insane-1", + "tf-insane-2", + "tf-insane-3", + "tf-insane-4", + "tf-insane-5", + "tf-insane-6", + "tf-insane-7", + "tf-insane-8", + "tf-insane-9", + "tf-insane-1-0", + "tf-insane-1-1", + "tf-insane-1-2", + "tf-insane-1-3", + "tf-insane-1-4", + "tf-insane-1-5", + "tf-insane-1-6", + "tf-insane-1-7", + "tf-insane-1-8", + "tf-insane-1-9", + "tf-insane-2-0", + "tf-insane-2-1", + "tf-insane-2-2", + "tf-insane-2-3", + "tf-insane-2-4", + "tf-insane-2-5", + "tf-insane-q-q-q" ], "game": "bms-7k", "id": "T19d35f0d5927a6fab46", @@ -300,33 +300,33 @@ "default": false, "description": "A successor to the original insane table, but is generally less consistent.", "folders": [ - "F19d35f0cf259c3f6f79", - "F19d35f0cf25fa8a2807", - "F19d35f0cf2656a85471", - "F19d35f0cf26f6e32ffe", - "F19d35f0cf26d9a7ccab", - "F19d35f0cf266334e704", - "F19d35f0cf2615f5e8c4", - "F19d35f0cf2638decdf1", - "F19d35f0cf264f418814", - "F19d35f0cf26a38039f7", - "F19d35f0cf2637bf8f42", - "F19d35f0cf267ef1c50b", - "F19d35f0cf26b265d6e3", - "F19d35f0cf262037f2c2", - "F19d35f0cf26c890bd06", - "F19d35f0cf26200c1f42", - "F19d35f0cf26005cd1b3", - "F19d35f0cf26db9c21c5", - "F19d35f0cf26e184ceb1", - "F19d35f0cf26fa7e75cf", - "F19d35f0cf26f48bc0a9", - "F19d35f0cf2634df0a8f", - "F19d35f0cf2676a259cd", - "F19d35f0cf2666b17914", - "F19d35f0cf26eeba7daf", - "F19d35f0cf2662dcdf76", - "F19d35f0cf252ec47a92" + "tf-insane2-0-dash", + "tf-insane2-0", + "tf-insane2-1", + "tf-insane2-2", + "tf-insane2-3", + "tf-insane2-4", + "tf-insane2-5", + "tf-insane2-6", + "tf-insane2-7", + "tf-insane2-8", + "tf-insane2-9", + "tf-insane2-1-0", + "tf-insane2-1-1", + "tf-insane2-1-2", + "tf-insane2-1-3", + "tf-insane2-1-4", + "tf-insane2-1-5", + "tf-insane2-1-6", + "tf-insane2-1-7", + "tf-insane2-1-8", + "tf-insane2-1-9", + "tf-insane2-2-0", + "tf-insane2-2-1", + "tf-insane2-2-2", + "tf-insane2-2-3", + "tf-insane2-2-4", + "tf-insane2-q" ], "game": "bms-7k", "id": "T19d35f0d592ffb65419", @@ -338,32 +338,32 @@ "default": false, "description": "The 7K LN table.", "folders": [ - "F19d35f0cf26cc477b80", - "F19d35f0cf2666ed4ab6", - "F19d35f0cf260059e488", - "F19d35f0cf260c6da3bf", - "F19d35f0cf2616c5c86d", - "F19d35f0cf2667e12bee", - "F19d35f0cf260aa14302", - "F19d35f0cf26f9874fe9", - "F19d35f0cf2671327d4d", - "F19d35f0cf265e1662bf", - "F19d35f0cf26175eb3c6", - "F19d35f0cf264c161997", - "F19d35f0cf26bd11b8cd", - "F19d35f0cf26e97aa5b5", - "F19d35f0cf2626875fe8", - "F19d35f0cf26f6301070", - "F19d35f0cf263ac2fe45", - "F19d35f0cf2677e9b1d0", - "F19d35f0cf26d0acd100", - "F19d35f0cf26050394ce", - "F19d35f0cf265aaa6e68", - "F19d35f0cf265e0cbc7d", - "F19d35f0cf264ef130e0", - "F19d35f0cf262b341380", - "F19d35f0cf2626fe842f", - "F19d35f0cf26fe9ef1df" + "tf-ln-1", + "tf-ln-2", + "tf-ln-3", + "tf-ln-4", + "tf-ln-5", + "tf-ln-6", + "tf-ln-7", + "tf-ln-8", + "tf-ln-9", + "tf-ln-1-0", + "tf-ln-1-1", + "tf-ln-1-2", + "tf-ln-1-3", + "tf-ln-1-4", + "tf-ln-1-5", + "tf-ln-1-6", + "tf-ln-1-7", + "tf-ln-1-8", + "tf-ln-1-9", + "tf-ln-2-0", + "tf-ln-2-1", + "tf-ln-2-2", + "tf-ln-2-3", + "tf-ln-2-4", + "tf-ln-2-5", + "tf-ln-2-6" ], "game": "bms-7k", "id": "T19d35f0d5925efb47b6", @@ -375,19 +375,19 @@ "default": false, "description": "The 7K LN Overjoy table.", "folders": [ - "F19d35f0cf261102b013", - "F19d35f0cf26dae0ec32", - "F19d35f0cf2647529e14", - "F19d35f0cf26fafe5f07", - "F19d35f0cf263a15e267", - "F19d35f0cf26bb062308", - "F19d35f0cf262cfa28bc", - "F19d35f0cf2684e5f97d", - "F19d35f0cf26f480370f", - "F19d35f0cf2677e5c76b", - "F19d35f0cf26343a8ce8", - "F19d35f0cf268f82aaa0", - "F19d35f0cf260c0a8002" + "tf-lnoverjoy-1", + "tf-lnoverjoy-2", + "tf-lnoverjoy-3", + "tf-lnoverjoy-4", + "tf-lnoverjoy-5", + "tf-lnoverjoy-6", + "tf-lnoverjoy-7", + "tf-lnoverjoy-8", + "tf-lnoverjoy-9", + "tf-lnoverjoy-1-0", + "tf-lnoverjoy-1-1", + "tf-lnoverjoy-1-2", + "tf-lnoverjoy-1-3" ], "game": "bms-7k", "id": "T19d35f0d59279906186", @@ -399,32 +399,32 @@ "default": false, "description": "The 7K Luminous Table. This is an alternative to the mainline LN tables.", "folders": [ - "F19d35f0cf2602577346", - "F19d35f0cf2633d78aff", - "F19d35f0cf265fe65f0d", - "F19d35f0cf265c668fd7", - "F19d35f0cf267597b7ce", - "F19d35f0cf2625ad1f2e", - "F19d35f0cf26cfef1b73", - "F19d35f0cf265713dbc4", - "F19d35f0cf261b46016a", - "F19d35f0cf264f65d3cf", - "F19d35f0cf26fbcc7ad1", - "F19d35f0cf2608bd8d6d", - "F19d35f0cf2675ed3b46", - "F19d35f0cf26c10b1a1e", - "F19d35f0cf26bd7e726e", - "F19d35f0cf266cf70f17", - "F19d35f0cf26edc78ff8", - "F19d35f0cf2610287735", - "F19d35f0cf2642d3a3e9", - "F19d35f0cf265763f035", - "F19d35f0cf26f8651508", - "F19d35f0cf26e7baff46", - "F19d35f0cf2679bd6d7d", - "F19d35f0cf26a7d6be0a", - "F19d35f0cf2693c1e6a7", - "F19d35f0cf26987ce975" + "tf-luminous-1", + "tf-luminous-2", + "tf-luminous-3", + "tf-luminous-4", + "tf-luminous-5", + "tf-luminous-6", + "tf-luminous-7", + "tf-luminous-8", + "tf-luminous-9", + "tf-luminous-1-0", + "tf-luminous-1-1", + "tf-luminous-1-2", + "tf-luminous-1-3", + "tf-luminous-1-4", + "tf-luminous-1-5", + "tf-luminous-1-6", + "tf-luminous-1-7", + "tf-luminous-1-8", + "tf-luminous-1-9", + "tf-luminous-2-0", + "tf-luminous-2-1", + "tf-luminous-2-2", + "tf-luminous-2-3", + "tf-luminous-2-4", + "tf-luminous-2-5", + "tf-luminous-2-6" ], "game": "bms-7k", "id": "T19d35f0d5921a739936", @@ -436,27 +436,27 @@ "default": false, "description": "Meta tables that present existing tables as if they were single folders.", "folders": [ - "F19d35f0cf26ef4f3599", - "F19d35f0cf26acbc4c97", - "F19d35f0cf26b392f10d", - "F19d35f0cf26409f7f3e", - "F19d35f0cf267fcc464b", - "F19d35f0cf261dcd4ac3", - "F19d35f0cf26da2c7c4a", - "F19d35f0cf26a5edb187", - "F19d35f0cf26a6937f16", - "F19d35f0cf2667cba113", - "F19d35f0cf261c2ec416", - "F19d35f0cf265d96504b", - "F19d35f0cf26f853b689", - "F19d35f0cf26a8a47afe", - "F19d35f0cf26a516028b", - "F19d35f0cf26bd4a134e", - "F19d35f0cf26305bd44d", - "F19d35f0cf26acb69807", - "F19d35f0cf2622733a45", - "F19d35f0cf26e5fb7145", - "F19d35f0cf268141b85d" + "tf-insane-present", + "tf-overjoy-present", + "tf-normal-present", + "tf-normal2-present", + "tf-insane2-present", + "tf-stella-present", + "tf-satellite-present", + "tf-ln-present", + "tf-scratch-present", + "tf-stardust-present", + "tf-starlight-present", + "tf-lnoverjoy-present", + "tf-luminous-present", + "tf-gachimjoy-present", + "tf-delayjoy-present", + "tf-armshougakkou-present", + "tf-exoplanet-present", + "tf-dystopia-present", + "tf-scramble-present", + "tf-solar-present", + "tf-supernova-present" ], "game": "bms-7k", "id": "T19d35f0d592d728c226", @@ -468,20 +468,20 @@ "default": false, "description": "The 7K GENOSIDE normal table.", "folders": [ - "F19d35f0cf26671e17da", - "F19d35f0cf26f9ca3ac8", - "F19d35f0cf265564563b", - "F19d35f0cf26451752a4", - "F19d35f0cf269b5ce21a", - "F19d35f0cf26f7d5b3d3", - "F19d35f0cf2695451ae1", - "F19d35f0cf26bebf7abd", - "F19d35f0cf26e76b6993", - "F19d35f0cf263d1968ca", - "F19d35f0cf26effb27b4", - "F19d35f0cf26a979419e", - "F19d35f0cf26c1c44ad7", - "F19d35f0cf2659244c2c" + "tf-normal-1", + "tf-normal-2", + "tf-normal-3", + "tf-normal-4", + "tf-normal-5", + "tf-normal-6", + "tf-normal-7", + "tf-normal-8", + "tf-normal-9", + "tf-normal-1-0", + "tf-normal-1-1", + "tf-normal-1-2", + "tf-normal-1-3", + "tf-normal-x" ], "game": "bms-7k", "id": "T19d35f0d5925ed251d7", @@ -493,22 +493,22 @@ "default": false, "description": "A successor to the original normal table.", "folders": [ - "F19d35f0cf26ba706899", - "F19d35f0cf263004dd03", - "F19d35f0cf26705212ec", - "F19d35f0cf26398e23d9", - "F19d35f0cf26ef3d459f", - "F19d35f0cf26d3f12240", - "F19d35f0cf265b81a436", - "F19d35f0cf266fc4e2ad", - "F19d35f0cf26e1f783e1", - "F19d35f0cf26bc4cd332", - "F19d35f0cf26b9d272a4", - "F19d35f0cf2698c0dcb7", - "F19d35f0cf2675863e3a", - "F19d35f0cf263dffd93b", - "F19d35f0cf26919745b8", - "F19d35f0cf2694e9e54a" + "tf-normal2-1", + "tf-normal2-2", + "tf-normal2-3", + "tf-normal2-4", + "tf-normal2-5", + "tf-normal2-6", + "tf-normal2-7", + "tf-normal2-8", + "tf-normal2-9", + "tf-normal2-1-0", + "tf-normal2-1-1", + "tf-normal2-1-1-p", + "tf-normal2-1-2-dash", + "tf-normal2-1-2", + "tf-normal2-1-2-p", + "tf-normal2-uff1f" ], "game": "bms-7k", "id": "T19d35f0d5929ce3366d", @@ -520,15 +520,15 @@ "default": false, "description": "The overjoy table. Level 1 is roughly equivalent to Insane 20.", "folders": [ - "F19d35f0cf2691ed9c2d", - "F19d35f0cf26661c2e4f", - "F19d35f0cf2653ef784b", - "F19d35f0cf265734fad4", - "F19d35f0cf263d8c40c9", - "F19d35f0cf2622612fab", - "F19d35f0cf26df512b81", - "F19d35f0cf265d045e57", - "F19d35f0cf26432669a3" + "tf-overjoy-0", + "tf-overjoy-1", + "tf-overjoy-2", + "tf-overjoy-3", + "tf-overjoy-4", + "tf-overjoy-5", + "tf-overjoy-6", + "tf-overjoy-7", + "tf-overjoy-8" ], "game": "bms-7k", "id": "T19d35f0d5926ca999bb", @@ -540,19 +540,19 @@ "default": false, "description": "The satellite table, from Stellaverse.", "folders": [ - "F19d35f0cf2678f85bdc", - "F19d35f0cf264c1fdf25", - "F19d35f0cf2637ae54a9", - "F19d35f0cf26954b8a14", - "F19d35f0cf269865c345", - "F19d35f0cf266a85a83f", - "F19d35f0cf2666a72610", - "F19d35f0cf2680ad19aa", - "F19d35f0cf262a5f6663", - "F19d35f0cf268254b6c7", - "F19d35f0cf2641f0d529", - "F19d35f0cf26ae08ebae", - "F19d35f0cf26163b0aaa" + "tf-satellite-0", + "tf-satellite-1", + "tf-satellite-2", + "tf-satellite-3", + "tf-satellite-4", + "tf-satellite-5", + "tf-satellite-6", + "tf-satellite-7", + "tf-satellite-8", + "tf-satellite-9", + "tf-satellite-1-0", + "tf-satellite-1-1", + "tf-satellite-1-2" ], "game": "bms-7k", "id": "T19d35f0d5925258cff9", @@ -564,21 +564,21 @@ "default": false, "description": "The Scramble table, a 7k scratch table", "folders": [ - "F19d35f0cf263cb362a9", - "F19d35f0cf2636f32175", - "F19d35f0cf26fd64c6f6", - "F19d35f0cf26e79cf559", - "F19d35f0cf267896d81b", - "F19d35f0cf2626d2be93", - "F19d35f0cf267161e906", - "F19d35f0cf26e8f13ce5", - "F19d35f0cf26209272f6", - "F19d35f0cf26693c66f7", - "F19d35f0cf26a9b1c18c", - "F19d35f0cf263b372f21", - "F19d35f0cf262c06edad", - "F19d35f0cf267c694619", - "F19d35f0cf26e5aab7ea" + "tf-scramble-dash-1", + "tf-scramble-0", + "tf-scramble-1", + "tf-scramble-2", + "tf-scramble-3", + "tf-scramble-4", + "tf-scramble-5", + "tf-scramble-6", + "tf-scramble-7", + "tf-scramble-8", + "tf-scramble-9", + "tf-scramble-1-0", + "tf-scramble-1-1", + "tf-scramble-1-2", + "tf-scramble-excl-i" ], "game": "bms-7k", "id": "T19d35f0d592670c45c1", @@ -590,24 +590,24 @@ "default": false, "description": "The 7K Sara 3 table.", "folders": [ - "F19d35f0cf2612bc9309", - "F19d35f0cf261902bd70", - "F19d35f0cf267f1dbda1", - "F19d35f0cf2634acca29", - "F19d35f0cf267225c792", - "F19d35f0cf26beff57bb", - "F19d35f0cf2682b63a3d", - "F19d35f0cf2618bbb565", - "F19d35f0cf26ec586a79", - "F19d35f0cf264624c6a8", - "F19d35f0cf266c67f9c0", - "F19d35f0cf2655ed82f6", - "F19d35f0cf26efe48d10", - "F19d35f0cf2665c61b96", - "F19d35f0cf26fc346a5c", - "F19d35f0cf26da6274d5", - "F19d35f0cf26f0235b3c", - "F19d35f0cf266b0b041b" + "tf-scratch-dash-1", + "tf-scratch-0", + "tf-scratch-1", + "tf-scratch-2", + "tf-scratch-3", + "tf-scratch-4", + "tf-scratch-5", + "tf-scratch-6", + "tf-scratch-7", + "tf-scratch-8", + "tf-scratch-9", + "tf-scratch-1-0", + "tf-scratch-1-1", + "tf-scratch-1-2", + "tf-scratch-1-3", + "tf-scratch-1-4", + "tf-scratch-1-5", + "tf-scratch-1-6" ], "game": "bms-7k", "id": "T19d35f0d59258821c6b", @@ -619,47 +619,47 @@ "default": false, "description": "EC difficulties according to Sieglinde.", "folders": [ - "F19d35f0cf260366c461", - "F19d35f0cf2673ab1b67", - "F19d35f0cf263ddb0d0f", - "F19d35f0cf26db9f3808", - "F19d35f0cf26f17fc712", - "F19d35f0cf267a382d55", - "F19d35f0cf2683d81292", - "F19d35f0cf2623410592", - "F19d35f0cf2618e51cad", - "F19d35f0cf26dc440b51", - "F19d35f0cf260b063674", - "F19d35f0cf264236740b", - "F19d35f0cf263b13922b", - "F19d35f0cf26958ca383", - "F19d35f0cf26027949ee", - "F19d35f0cf26dd23ad1b", - "F19d35f0cf26fb0c8d12", - "F19d35f0cf26450446f6", - "F19d35f0cf26c8468746", - "F19d35f0cf26ecb3ec20", - "F19d35f0cf268d48e666", - "F19d35f0cf2612787367", - "F19d35f0cf26a590c21f", - "F19d35f0cf269629ffec", - "F19d35f0cf265a4b1369", - "F19d35f0cf26b59eb3e7", - "F19d35f0cf26fb6eb823", - "F19d35f0cf26cc2f6fc1", - "F19d35f0cf2685f89fb1", - "F19d35f0cf26c9f80570", - "F19d35f0cf26a6084945", - "F19d35f0cf264859513c", - "F19d35f0cf264a843e24", - "F19d35f0cf263baee43d", - "F19d35f0cf265a103276", - "F19d35f0cf26207841d5", - "F19d35f0cf26b943ed98", - "F19d35f0cf262a067389", - "F19d35f0cf26bd5ecd9e", - "F19d35f0cf26fd258f39", - "F19d35f0cf269dd1baf0" + "sglec-ge-1-lt-2", + "sglec-ge-2-lt-3", + "sglec-ge-3-lt-4", + "sglec-ge-4-lt-5", + "sglec-ge-5-lt-6", + "sglec-ge-6-lt-7", + "sglec-ge-7-lt-8", + "sglec-ge-8-lt-9", + "sglec-ge-9-lt-10", + "sglec-ge-10-lt-11", + "sglec-ge-11-lt-12", + "sglec-ge-12-lt-13", + "sglec-ge-13-lt-14", + "sglec-ge-14-lt-15", + "sglec-ge-15-lt-16", + "sglec-ge-16-lt-17", + "sglec-ge-17-lt-18", + "sglec-ge-18-lt-19", + "sglec-ge-19-lt-20", + "sglec-ge-20-lt-21", + "sglec-ge-21-lt-22", + "sglec-ge-22-lt-23", + "sglec-ge-23-lt-24", + "sglec-ge-24-lt-25", + "sglec-ge-25-lt-26", + "sglec-ge-26-lt-27", + "sglec-ge-27-lt-28", + "sglec-ge-28-lt-29", + "sglec-ge-29-lt-30", + "sglec-ge-30-lt-31", + "sglec-ge-31-lt-32", + "sglec-ge-32-lt-33", + "sglec-ge-33-lt-34", + "sglec-ge-34-lt-35", + "sglec-ge-35-lt-36", + "sglec-ge-36-lt-37", + "sglec-ge-37-lt-38", + "sglec-ge-38-lt-39", + "sglec-ge-39-lt-40", + "sglec-ge-40-lt-41", + "sglec-ge-41-lt-42" ], "game": "bms-7k", "id": "T19d35f0d592562c1eb7", @@ -671,47 +671,47 @@ "default": false, "description": "HC difficulties according to Sieglinde.", "folders": [ - "F19d35f0cf263f882994", - "F19d35f0cf2679c45dfb", - "F19d35f0cf2651f23247", - "F19d35f0cf2654b96608", - "F19d35f0cf26f51d03d1", - "F19d35f0cf2672300af4", - "F19d35f0cf263e3ee718", - "F19d35f0cf2679f45c10", - "F19d35f0cf26beba513a", - "F19d35f0cf265feff2bc", - "F19d35f0cf26576f59db", - "F19d35f0cf26b84e863b", - "F19d35f0cf26edc89030", - "F19d35f0cf266d50865d", - "F19d35f0cf2664129fdf", - "F19d35f0cf26022c3d12", - "F19d35f0cf2657557b49", - "F19d35f0cf261c1afe62", - "F19d35f0cf2616a9bcce", - "F19d35f0cf26a4019f36", - "F19d35f0cf261b33f164", - "F19d35f0cf26e9ef83ac", - "F19d35f0cf2610593f41", - "F19d35f0cf26be79b314", - "F19d35f0cf2660743308", - "F19d35f0cf26b40b7fb5", - "F19d35f0cf26af6505d3", - "F19d35f0cf26b242896d", - "F19d35f0cf265c942e1f", - "F19d35f0cf269d692d8d", - "F19d35f0cf267b428dca", - "F19d35f0cf2611b0d07f", - "F19d35f0cf26d8e5f35f", - "F19d35f0cf261497ee5d", - "F19d35f0cf26a51edfae", - "F19d35f0cf2653dc082e", - "F19d35f0cf26ca066388", - "F19d35f0cf2624524865", - "F19d35f0cf26ad7ef107", - "F19d35f0cf26e8ea0677", - "F19d35f0cf26dcf5b372" + "sglhc-ge-1-lt-2", + "sglhc-ge-2-lt-3", + "sglhc-ge-3-lt-4", + "sglhc-ge-4-lt-5", + "sglhc-ge-5-lt-6", + "sglhc-ge-6-lt-7", + "sglhc-ge-7-lt-8", + "sglhc-ge-8-lt-9", + "sglhc-ge-9-lt-10", + "sglhc-ge-10-lt-11", + "sglhc-ge-11-lt-12", + "sglhc-ge-12-lt-13", + "sglhc-ge-13-lt-14", + "sglhc-ge-14-lt-15", + "sglhc-ge-15-lt-16", + "sglhc-ge-16-lt-17", + "sglhc-ge-17-lt-18", + "sglhc-ge-18-lt-19", + "sglhc-ge-19-lt-20", + "sglhc-ge-20-lt-21", + "sglhc-ge-21-lt-22", + "sglhc-ge-22-lt-23", + "sglhc-ge-23-lt-24", + "sglhc-ge-24-lt-25", + "sglhc-ge-25-lt-26", + "sglhc-ge-26-lt-27", + "sglhc-ge-27-lt-28", + "sglhc-ge-28-lt-29", + "sglhc-ge-29-lt-30", + "sglhc-ge-30-lt-31", + "sglhc-ge-31-lt-32", + "sglhc-ge-32-lt-33", + "sglhc-ge-33-lt-34", + "sglhc-ge-34-lt-35", + "sglhc-ge-35-lt-36", + "sglhc-ge-36-lt-37", + "sglhc-ge-37-lt-38", + "sglhc-ge-38-lt-39", + "sglhc-ge-39-lt-40", + "sglhc-ge-40-lt-41", + "sglhc-ge-41-lt-42" ], "game": "bms-7k", "id": "T19d35f0d592d4f200be", @@ -723,19 +723,19 @@ "default": false, "description": "Solar, Stellaverse's alternative table for Insane1-esque charts. Follows the Satellite table in difficulty.", "folders": [ - "F19d35f0cf26aca49a22", - "F19d35f0cf2658b6aaea", - "F19d35f0cf2633320d02", - "F19d35f0cf26d15ef9e8", - "F19d35f0cf26a4b14dcc", - "F19d35f0cf26bc8789e8", - "F19d35f0cf26164762e3", - "F19d35f0cf26a1902252", - "F19d35f0cf26bc5a741f", - "F19d35f0cf26b6aa4e1f", - "F19d35f0cf2607d63128", - "F19d35f0cf26cdd02c41", - "F19d35f0cf268e6435ab" + "tf-solar-0", + "tf-solar-1", + "tf-solar-2", + "tf-solar-3", + "tf-solar-4", + "tf-solar-5", + "tf-solar-6", + "tf-solar-7", + "tf-solar-8", + "tf-solar-9", + "tf-solar-1-0", + "tf-solar-1-1", + "tf-solar-1-2" ], "game": "bms-7k", "id": "T19d35f0d59279146d1a", @@ -747,20 +747,20 @@ "default": false, "description": "The 7K Stardust table. This table covers ☆1 to ☆7.", "folders": [ - "F19d35f0cf26159f16c5", - "F19d35f0cf2647bc1de3", - "F19d35f0cf260d3f90c6", - "F19d35f0cf269cc0523d", - "F19d35f0cf26811d2aea", - "F19d35f0cf265b608f12", - "F19d35f0cf268cedef30", - "F19d35f0cf26436dda10", - "F19d35f0cf26bae98f04", - "F19d35f0cf267ac24dfd", - "F19d35f0cf268524e4db", - "F19d35f0cf266df605d9", - "F19d35f0cf263a7af08d", - "F19d35f0cf26b0f9fb13" + "tf-stardust-1", + "tf-stardust-2", + "tf-stardust-3", + "tf-stardust-4", + "tf-stardust-5", + "tf-stardust-6", + "tf-stardust-7", + "tf-stardust-8", + "tf-stardust-9", + "tf-stardust-1-0", + "tf-stardust-1-1", + "tf-stardust-1-2", + "tf-stardust-1-3", + "tf-stardust-q" ], "game": "bms-7k", "id": "T19d35f0d592c6ae6ceb", @@ -772,22 +772,22 @@ "default": false, "description": "The 7K Starlight table. This table covers ☆7 to ☆12.", "folders": [ - "F19d35f0cf2642da24d8", - "F19d35f0cf26827a2dd5", - "F19d35f0cf26140e9690", - "F19d35f0cf266fd19ef8", - "F19d35f0cf266327e0fd", - "F19d35f0cf2606f29af0", - "F19d35f0cf2668bf3efe", - "F19d35f0cf26b9986493", - "F19d35f0cf26667e8ce9", - "F19d35f0cf26b27977e3", - "F19d35f0cf260b487cec", - "F19d35f0cf261896a350", - "F19d35f0cf267c497361", - "F19d35f0cf2603d940f8", - "F19d35f0cf261be7b134", - "F19d35f0cf269b45eb7b" + "tf-starlight-0", + "tf-starlight-1", + "tf-starlight-2", + "tf-starlight-3", + "tf-starlight-4", + "tf-starlight-5", + "tf-starlight-6", + "tf-starlight-7", + "tf-starlight-8", + "tf-starlight-9", + "tf-starlight-1-0", + "tf-starlight-1-1", + "tf-starlight-1-2", + "tf-starlight-1-3", + "tf-starlight-q", + "tf-starlight-excl" ], "game": "bms-7k", "id": "T19d35f0d592c35c986c", @@ -799,19 +799,19 @@ "default": false, "description": "The stella table, from Stellaverse.", "folders": [ - "F19d35f0cf264757ae03", - "F19d35f0cf26eef28ffe", - "F19d35f0cf267ccd966a", - "F19d35f0cf26be375d77", - "F19d35f0cf26ba331af0", - "F19d35f0cf26e7af6c6a", - "F19d35f0cf2649f89f9b", - "F19d35f0cf26bed9a93a", - "F19d35f0cf26d02e727b", - "F19d35f0cf267dc25f64", - "F19d35f0cf2691a6d50c", - "F19d35f0cf269ca863d6", - "F19d35f0cf2656f5d2ee" + "tf-stella-0", + "tf-stella-1", + "tf-stella-2", + "tf-stella-3", + "tf-stella-4", + "tf-stella-5", + "tf-stella-6", + "tf-stella-7", + "tf-stella-8", + "tf-stella-9", + "tf-stella-1-0", + "tf-stella-1-1", + "tf-stella-1-2" ], "game": "bms-7k", "id": "T19d35f0d592642ec2f8", @@ -823,19 +823,19 @@ "default": false, "description": "Supernova, Stellaverse's alternative table for Insane1-esque charts. Follows the Stella table in difficulty.", "folders": [ - "F19d35f0cf26a6fdaae0", - "F19d35f0cf26ec96cec7", - "F19d35f0cf266bcdf85d", - "F19d35f0cf2620b408d0", - "F19d35f0cf26bdba98cc", - "F19d35f0cf26fd3995f8", - "F19d35f0cf260fe198ae", - "F19d35f0cf268ca8d552", - "F19d35f0cf26c35d81b4", - "F19d35f0cf26a96d10b5", - "F19d35f0cf26cb736199", - "F19d35f0cf267b024d9a", - "F19d35f0cf264903182c" + "tf-supernova-0", + "tf-supernova-1", + "tf-supernova-2", + "tf-supernova-3", + "tf-supernova-4", + "tf-supernova-5", + "tf-supernova-6", + "tf-supernova-7", + "tf-supernova-8", + "tf-supernova-9", + "tf-supernova-1-0", + "tf-supernova-1-1", + "tf-supernova-1-2" ], "game": "bms-7k", "id": "T19d35f0d5929b66f7cd", @@ -847,30 +847,30 @@ "default": false, "description": "Levels for CHUNITHM in LUMINOUS International.", "folders": [ - "F19d35f0cf272476a851", - "F19d35f0cf27c2e74a2b", - "F19d35f0cf27a5ee19ec", - "F19d35f0cf27e0a543ce", - "F19d35f0cf27f11667d9", - "F19d35f0cf273d7d3ebd", - "F19d35f0cf27569329b9", - "F19d35f0cf271972a007", - "F19d35f0cf27cad9d117", - "F19d35f0cf2735d0b71f", - "F19d35f0cf2748d5f974", - "F19d35f0cf276929c5ca", - "F19d35f0cf27791b37f2", - "F19d35f0cf277756db76", - "F19d35f0cf2734361758", - "F19d35f0cf278c8df26c", - "F19d35f0cf27f2e5b199", - "F19d35f0cf2739c318e0", - "F19d35f0cf275f88c656", - "F19d35f0cf278b5373f8", - "F19d35f0cf2718832fd3", - "F19d35f0cf272beb781a", - "F19d35f0cf2745bdb587", - "F19d35f0cf2749e3dc0b" + "1-luminous-intl", + "2-luminous-intl", + "3-luminous-intl", + "4-luminous-intl", + "5-luminous-intl", + "6-luminous-intl", + "7-luminous-intl", + "7p-luminous-intl", + "8-luminous-intl", + "8p-luminous-intl", + "9-luminous-intl", + "9p-luminous-intl", + "10-luminous-intl", + "10p-luminous-intl", + "11-luminous-intl", + "11p-luminous-intl", + "12-luminous-intl", + "12p-luminous-intl", + "13-luminous-intl", + "13p-luminous-intl", + "14-luminous-intl", + "14p-luminous-intl", + "15-luminous-intl", + "15p-luminous-intl" ], "game": "chunithm", "id": "T19d35f0d592eeb75478", @@ -882,11 +882,11 @@ "default": false, "description": "Difficulties for CHUNITHM in LUMINOUS International.", "folders": [ - "F19d35f0cf270db41133", - "F19d35f0cf26a77acfa4", - "F19d35f0cf270834860f", - "F19d35f0cf27cbdd2532", - "F19d35f0cf2728be9d78" + "basic-luminous-intl", + "advanced-luminous-intl", + "expert-luminous-intl", + "master-luminous-intl", + "ultima-luminous-intl" ], "game": "chunithm", "id": "T19d35f0d5925912cb1a", @@ -898,30 +898,30 @@ "default": false, "description": "Levels for CHUNITHM in LUMINOUS Omnimix.", "folders": [ - "F19d35f0cf27be43a560", - "F19d35f0cf27ef53dba1", - "F19d35f0cf27c786529c", - "F19d35f0cf27517d0655", - "F19d35f0cf272c289d3d", - "F19d35f0cf2759d9f716", - "F19d35f0cf2729e30141", - "F19d35f0cf273bd2bbf5", - "F19d35f0cf272b4e0727", - "F19d35f0cf27965c6653", - "F19d35f0cf27d798e247", - "F19d35f0cf270f21747d", - "F19d35f0cf274dff4a70", - "F19d35f0cf27c5f8d2a3", - "F19d35f0cf2782029c62", - "F19d35f0cf2741ed12ca", - "F19d35f0cf278673f1d4", - "F19d35f0cf27b870eea0", - "F19d35f0cf2700a0e7d9", - "F19d35f0cf27efb58e5b", - "F19d35f0cf274c4dbb1b", - "F19d35f0cf279ec35320", - "F19d35f0cf2769a8c017", - "F19d35f0cf2719fe6b5c" + "1-luminous-omni", + "2-luminous-omni", + "3-luminous-omni", + "4-luminous-omni", + "5-luminous-omni", + "6-luminous-omni", + "7-luminous-omni", + "7p-luminous-omni", + "8-luminous-omni", + "8p-luminous-omni", + "9-luminous-omni", + "9p-luminous-omni", + "10-luminous-omni", + "10p-luminous-omni", + "11-luminous-omni", + "11p-luminous-omni", + "12-luminous-omni", + "12p-luminous-omni", + "13-luminous-omni", + "13p-luminous-omni", + "14-luminous-omni", + "14p-luminous-omni", + "15-luminous-omni", + "15p-luminous-omni" ], "game": "chunithm", "id": "T19d35f0d592e84aacb3", @@ -933,11 +933,11 @@ "default": false, "description": "Difficulties for CHUNITHM in LUMINOUS Omnimix.", "folders": [ - "F19d35f0cf27e0946f12", - "F19d35f0cf2602f58c7a", - "F19d35f0cf278b0460c3", - "F19d35f0cf2774cb8837", - "F19d35f0cf270157479a" + "basic-luminous-omni", + "advanced-luminous-omni", + "expert-luminous-omni", + "master-luminous-omni", + "ultima-luminous-omni" ], "game": "chunithm", "id": "T19d35f0d5922d9e7af2", @@ -949,30 +949,30 @@ "default": false, "description": "Levels for CHUNITHM in LUMINOUS PLUS International.", "folders": [ - "F19d35f0cf27ee8fde65", - "F19d35f0cf27ec89f126", - "F19d35f0cf27e5cf17cf", - "F19d35f0cf277ee8bd24", - "F19d35f0cf27006b865b", - "F19d35f0cf27635cf6e6", - "F19d35f0cf278382663a", - "F19d35f0cf2755d0d4be", - "F19d35f0cf27a42e25d8", - "F19d35f0cf27efa4f936", - "F19d35f0cf27f931acb9", - "F19d35f0cf276745b2c0", - "F19d35f0cf27fb0740d9", - "F19d35f0cf272b5f609d", - "F19d35f0cf2775912f4b", - "F19d35f0cf27eaf8cb74", - "F19d35f0cf27ad1e453a", - "F19d35f0cf27e4c263d0", - "F19d35f0cf271a40a9dd", - "F19d35f0cf2730f6cc59", - "F19d35f0cf27868ad1f0", - "F19d35f0cf27ca4e9777", - "F19d35f0cf27eba5b1bc", - "F19d35f0cf27ae7906b4" + "1-luminousplus-intl", + "2-luminousplus-intl", + "3-luminousplus-intl", + "4-luminousplus-intl", + "5-luminousplus-intl", + "6-luminousplus-intl", + "7-luminousplus-intl", + "7p-luminousplus-intl", + "8-luminousplus-intl", + "8p-luminousplus-intl", + "9-luminousplus-intl", + "9p-luminousplus-intl", + "10-luminousplus-intl", + "10p-luminousplus-intl", + "11-luminousplus-intl", + "11p-luminousplus-intl", + "12-luminousplus-intl", + "12p-luminousplus-intl", + "13-luminousplus-intl", + "13p-luminousplus-intl", + "14-luminousplus-intl", + "14p-luminousplus-intl", + "15-luminousplus-intl", + "15p-luminousplus-intl" ], "game": "chunithm", "id": "T19d35f0d59273e069d8", @@ -984,11 +984,11 @@ "default": false, "description": "Difficulties for CHUNITHM in LUMINOUS PLUS International.", "folders": [ - "F19d35f0cf27c8b5b5b0", - "F19d35f0cf260028fff0", - "F19d35f0cf27ca1d85bf", - "F19d35f0cf27f75d569b", - "F19d35f0cf276014fa0b" + "basic-luminousplus-intl", + "advanced-luminousplus-intl", + "expert-luminousplus-intl", + "master-luminousplus-intl", + "ultima-luminousplus-intl" ], "game": "chunithm", "id": "T19d35f0d592c06d90d5", @@ -1000,30 +1000,30 @@ "default": false, "description": "Levels for CHUNITHM in LUMINOUS PLUS Omnimix.", "folders": [ - "F19d35f0cf27213b1bb9", - "F19d35f0cf27f9b8cf48", - "F19d35f0cf2753b81dad", - "F19d35f0cf277bec28eb", - "F19d35f0cf2787b4e4bf", - "F19d35f0cf278e9fe664", - "F19d35f0cf2761935e57", - "F19d35f0cf279daf71f5", - "F19d35f0cf279b998ef3", - "F19d35f0cf274951b083", - "F19d35f0cf27585a40bb", - "F19d35f0cf27be5d7564", - "F19d35f0cf270688439c", - "F19d35f0cf275ccf2839", - "F19d35f0cf27a9207f22", - "F19d35f0cf27a6f5ed3c", - "F19d35f0cf27706e7bb6", - "F19d35f0cf2790e30f2b", - "F19d35f0cf2708d0a8d6", - "F19d35f0cf27ab9ae4bf", - "F19d35f0cf273f6db301", - "F19d35f0cf276adad441", - "F19d35f0cf27982c9075", - "F19d35f0cf278d756269" + "1-luminousplus-omni", + "2-luminousplus-omni", + "3-luminousplus-omni", + "4-luminousplus-omni", + "5-luminousplus-omni", + "6-luminousplus-omni", + "7-luminousplus-omni", + "7p-luminousplus-omni", + "8-luminousplus-omni", + "8p-luminousplus-omni", + "9-luminousplus-omni", + "9p-luminousplus-omni", + "10-luminousplus-omni", + "10p-luminousplus-omni", + "11-luminousplus-omni", + "11p-luminousplus-omni", + "12-luminousplus-omni", + "12p-luminousplus-omni", + "13-luminousplus-omni", + "13p-luminousplus-omni", + "14-luminousplus-omni", + "14p-luminousplus-omni", + "15-luminousplus-omni", + "15p-luminousplus-omni" ], "game": "chunithm", "id": "T19d35f0d592258b415e", @@ -1035,12 +1035,12 @@ "default": false, "description": "Difficulties for CHUNITHM in LUMINOUS PLUS Omnimix.", "folders": [ - "F19d35f0cf27cdc24c04", - "F19d35f0cf26ae366396", - "F19d35f0cf27adc497b2", - "F19d35f0cf2775071034", - "F19d35f0cf2720d0730b", - "F19d35f0cf276c46fce7" + "basic-luminousplus-omni", + "advanced-luminousplus-omni", + "expert-luminousplus-omni", + "master-luminousplus-omni", + "ultima-luminousplus-omni", + "master-ultima-luminousplus-omni" ], "game": "chunithm", "id": "T19d35f0d5929fe778be", @@ -1052,30 +1052,30 @@ "default": false, "description": "Levels for CHUNITHM in LUMINOUS PLUS.", "folders": [ - "F19d35f0cf273127ca5e", - "F19d35f0cf2703934c2f", - "F19d35f0cf272359c663", - "F19d35f0cf274d4a1394", - "F19d35f0cf27708114f5", - "F19d35f0cf27195d52b2", - "F19d35f0cf2752c9d7e4", - "F19d35f0cf272a22abb0", - "F19d35f0cf27193585fd", - "F19d35f0cf27e25d1211", - "F19d35f0cf272c3a7df3", - "F19d35f0cf27ccd4b4e1", - "F19d35f0cf278fbdb460", - "F19d35f0cf275e3e9a1b", - "F19d35f0cf277f7eee3b", - "F19d35f0cf27dc4908a1", - "F19d35f0cf2754d21b04", - "F19d35f0cf27df8852fb", - "F19d35f0cf27a07faf03", - "F19d35f0cf279f3ab3b9", - "F19d35f0cf27d8f91255", - "F19d35f0cf271f406f83", - "F19d35f0cf27fb6d47e3", - "F19d35f0cf27f579b1de" + "1-luminousplus", + "2-luminousplus", + "3-luminousplus", + "4-luminousplus", + "5-luminousplus", + "6-luminousplus", + "7-luminousplus", + "7p-luminousplus", + "8-luminousplus", + "8p-luminousplus", + "9-luminousplus", + "9p-luminousplus", + "10-luminousplus", + "10p-luminousplus", + "11-luminousplus", + "11p-luminousplus", + "12-luminousplus", + "12p-luminousplus", + "13-luminousplus", + "13p-luminousplus", + "14-luminousplus", + "14p-luminousplus", + "15-luminousplus", + "15p-luminousplus" ], "game": "chunithm", "id": "T19d35f0d5929e3e2187", @@ -1087,12 +1087,12 @@ "default": false, "description": "Difficulties for CHUNITHM in LUMINOUS PLUS.", "folders": [ - "F19d35f0cf27c8fd0aa2", - "F19d35f0cf26e82fc8e6", - "F19d35f0cf27d0605843", - "F19d35f0cf2735b17b7e", - "F19d35f0cf2710e662fb", - "F19d35f0cf27bb4f7a04" + "basic-luminousplus", + "advanced-luminousplus", + "expert-luminousplus", + "master-luminousplus", + "ultima-luminousplus", + "master-ultima-luminousplus" ], "game": "chunithm", "id": "T19d35f0d592687988b1", @@ -1104,30 +1104,30 @@ "default": false, "description": "Levels for CHUNITHM in LUMINOUS.", "folders": [ - "F19d35f0cf27d63da057", - "F19d35f0cf27f474e9bc", - "F19d35f0cf27be976ffb", - "F19d35f0cf271865b489", - "F19d35f0cf2792a1caaa", - "F19d35f0cf27056f2e3c", - "F19d35f0cf27167873af", - "F19d35f0cf27af910b4f", - "F19d35f0cf2790980cde", - "F19d35f0cf27988f798c", - "F19d35f0cf27daba492e", - "F19d35f0cf273e064dc4", - "F19d35f0cf27de592538", - "F19d35f0cf273efd04d0", - "F19d35f0cf27cc8459e5", - "F19d35f0cf271aee8f8d", - "F19d35f0cf278a2aef75", - "F19d35f0cf27f5f3ce5d", - "F19d35f0cf27cad534bf", - "F19d35f0cf27f389e711", - "F19d35f0cf274c87764e", - "F19d35f0cf270b311341", - "F19d35f0cf278cac7c9d", - "F19d35f0cf27fe49b52b" + "1-luminous", + "2-luminous", + "3-luminous", + "4-luminous", + "5-luminous", + "6-luminous", + "7-luminous", + "7p-luminous", + "8-luminous", + "8p-luminous", + "9-luminous", + "9p-luminous", + "10-luminous", + "10p-luminous", + "11-luminous", + "11p-luminous", + "12-luminous", + "12p-luminous", + "13-luminous", + "13p-luminous", + "14-luminous", + "14p-luminous", + "15-luminous", + "15p-luminous" ], "game": "chunithm", "id": "T19d35f0d59276cee33f", @@ -1139,11 +1139,11 @@ "default": false, "description": "Difficulties for CHUNITHM in LUMINOUS.", "folders": [ - "F19d35f0cf276ac306ee", - "F19d35f0cf268cfa6870", - "F19d35f0cf27cfe1f04e", - "F19d35f0cf27ff86cbeb", - "F19d35f0cf2771e50575" + "basic-luminous", + "advanced-luminous", + "expert-luminous", + "master-luminous", + "ultima-luminous" ], "game": "chunithm", "id": "T19d35f0d5929b3af999", @@ -1155,30 +1155,30 @@ "default": false, "description": "Levels for CHUNITHM in NEW PLUS.", "folders": [ - "F19d35f0cf2756c4c886", - "F19d35f0cf27e4fc71bc", - "F19d35f0cf27defd0ac3", - "F19d35f0cf273a11ba36", - "F19d35f0cf2721cc3be6", - "F19d35f0cf2750b498ba", - "F19d35f0cf279ea554f0", - "F19d35f0cf27fd2da6c5", - "F19d35f0cf27c7e5ca0e", - "F19d35f0cf2741f0b1b0", - "F19d35f0cf273c7b3985", - "F19d35f0cf2700cba33d", - "F19d35f0cf27ed3bfa72", - "F19d35f0cf27d695c8dd", - "F19d35f0cf276025005e", - "F19d35f0cf27c808738a", - "F19d35f0cf27912b4998", - "F19d35f0cf27df3d1731", - "F19d35f0cf27037ec0c3", - "F19d35f0cf27c05c17ef", - "F19d35f0cf2721cb9422", - "F19d35f0cf272335fd90", - "F19d35f0cf27f86873fa", - "F19d35f0cf27b340250c" + "1-newplus", + "2-newplus", + "3-newplus", + "4-newplus", + "5-newplus", + "6-newplus", + "7-newplus", + "7p-newplus", + "8-newplus", + "8p-newplus", + "9-newplus", + "9p-newplus", + "10-newplus", + "10p-newplus", + "11-newplus", + "11p-newplus", + "12-newplus", + "12p-newplus", + "13-newplus", + "13p-newplus", + "14-newplus", + "14p-newplus", + "15-newplus", + "15p-newplus" ], "game": "chunithm", "id": "T19d35f0d592a8686b55", @@ -1190,11 +1190,11 @@ "default": false, "description": "Difficulties for CHUNITHM in NEW PLUS.", "folders": [ - "F19d35f0cf270e3ca240", - "F19d35f0cf2683ec46a3", - "F19d35f0cf27af44a391", - "F19d35f0cf27d2abb966", - "F19d35f0cf27fbd778a3" + "basic-newplus", + "advanced-newplus", + "expert-newplus", + "master-newplus", + "ultima-newplus" ], "game": "chunithm", "id": "T19d35f0d59296e1ce30", @@ -1206,30 +1206,30 @@ "default": false, "description": "Levels for CHUNITHM in NEW.", "folders": [ - "F19d35f0cf2774fbf0cf", - "F19d35f0cf279a402f82", - "F19d35f0cf27521008f8", - "F19d35f0cf277c8a2a0d", - "F19d35f0cf2781e0e882", - "F19d35f0cf278abead32", - "F19d35f0cf27b42281f6", - "F19d35f0cf27210874ab", - "F19d35f0cf27b2e75ef0", - "F19d35f0cf27f72b6a60", - "F19d35f0cf271e2381b5", - "F19d35f0cf2757636404", - "F19d35f0cf27939d2f1b", - "F19d35f0cf27c936ab50", - "F19d35f0cf2717b57acc", - "F19d35f0cf272d3a54f4", - "F19d35f0cf27b9504048", - "F19d35f0cf273a2e0859", - "F19d35f0cf27dcee7f65", - "F19d35f0cf27016f646f", - "F19d35f0cf27897a2be4", - "F19d35f0cf27158b8142", - "F19d35f0cf27372efdd1", - "F19d35f0cf2744c22d9f" + "1-new", + "2-new", + "3-new", + "4-new", + "5-new", + "6-new", + "7-new", + "7p-new", + "8-new", + "8p-new", + "9-new", + "9p-new", + "10-new", + "10p-new", + "11-new", + "11p-new", + "12-new", + "12p-new", + "13-new", + "13p-new", + "14-new", + "14p-new", + "15-new", + "15p-new" ], "game": "chunithm", "id": "T19d35f0d592f4fbc1ab", @@ -1240,13 +1240,7 @@ { "default": false, "description": "Difficulties for CHUNITHM in NEW.", - "folders": [ - "F19d35f0cf2791935569", - "F19d35f0cf2610aadd20", - "F19d35f0cf277a80f1af", - "F19d35f0cf27bdb5731c", - "F19d35f0cf274794a55a" - ], + "folders": ["basic-new", "advanced-new", "expert-new", "master-new", "ultima-new"], "game": "chunithm", "id": "T19d35f0d5925bdc60bb", "inactive": true, @@ -1257,30 +1251,30 @@ "default": false, "description": "Levels for CHUNITHM in Paradise Lost.", "folders": [ - "F19d35f0cf2741084d28", - "F19d35f0cf27372a8db5", - "F19d35f0cf275a681383", - "F19d35f0cf276d9aa5ec", - "F19d35f0cf2788eb7368", - "F19d35f0cf27a254f869", - "F19d35f0cf27b4a906e9", - "F19d35f0cf27f499795f", - "F19d35f0cf278900b12b", - "F19d35f0cf27eefc89a3", - "F19d35f0cf279f4d173f", - "F19d35f0cf27f9f718c1", - "F19d35f0cf27cb9112b1", - "F19d35f0cf272afce7c7", - "F19d35f0cf2708e26cbf", - "F19d35f0cf2782c933fd", - "F19d35f0cf271af02476", - "F19d35f0cf27f65ffc56", - "F19d35f0cf27f0861ec2", - "F19d35f0cf27a74d1039", - "F19d35f0cf27a309ce79", - "F19d35f0cf273ad0bef7", - "F19d35f0cf27e5cde453", - "F19d35f0cf27da795c89" + "1-paradiselost", + "2-paradiselost", + "3-paradiselost", + "4-paradiselost", + "5-paradiselost", + "6-paradiselost", + "7-paradiselost", + "7p-paradiselost", + "8-paradiselost", + "8p-paradiselost", + "9-paradiselost", + "9p-paradiselost", + "10-paradiselost", + "10p-paradiselost", + "11-paradiselost", + "11p-paradiselost", + "12-paradiselost", + "12p-paradiselost", + "13-paradiselost", + "13p-paradiselost", + "14-paradiselost", + "14p-paradiselost", + "15-paradiselost", + "15p-paradiselost" ], "game": "chunithm", "id": "T19d35f0d5923f3f3576", @@ -1292,30 +1286,30 @@ "default": false, "description": "Levels for CHUNITHM in SUN Omnimix.", "folders": [ - "F19d35f0cf275857f64a", - "F19d35f0cf2773ebd6c3", - "F19d35f0cf2763b686cd", - "F19d35f0cf27935e41bb", - "F19d35f0cf27146a6a9e", - "F19d35f0cf276d2f8b20", - "F19d35f0cf2792ae2935", - "F19d35f0cf276ba1c289", - "F19d35f0cf2742c1694d", - "F19d35f0cf27f7e801ee", - "F19d35f0cf2753aa61d1", - "F19d35f0cf2772c26d54", - "F19d35f0cf27d53dba84", - "F19d35f0cf27075dedb2", - "F19d35f0cf2744c1c347", - "F19d35f0cf2715512611", - "F19d35f0cf2759bdc4fa", - "F19d35f0cf276e44a7a1", - "F19d35f0cf27d8abd4ce", - "F19d35f0cf270a7dd203", - "F19d35f0cf2787129e73", - "F19d35f0cf2766195913", - "F19d35f0cf27a412744d", - "F19d35f0cf27c3a6c1e4" + "1-sun-omni", + "2-sun-omni", + "3-sun-omni", + "4-sun-omni", + "5-sun-omni", + "6-sun-omni", + "7-sun-omni", + "7p-sun-omni", + "8-sun-omni", + "8p-sun-omni", + "9-sun-omni", + "9p-sun-omni", + "10-sun-omni", + "10p-sun-omni", + "11-sun-omni", + "11p-sun-omni", + "12-sun-omni", + "12p-sun-omni", + "13-sun-omni", + "13p-sun-omni", + "14-sun-omni", + "14p-sun-omni", + "15-sun-omni", + "15p-sun-omni" ], "game": "chunithm", "id": "T19d35f0d592f37d87cc", @@ -1327,11 +1321,11 @@ "default": false, "description": "Difficulties for CHUNITHM in SUN Omnimix.", "folders": [ - "F19d35f0cf276cf35cb2", - "F19d35f0cf265ecdae4d", - "F19d35f0cf27a7f05833", - "F19d35f0cf27319e1bb7", - "F19d35f0cf278c39a474" + "basic-sun-omni", + "advanced-sun-omni", + "expert-sun-omni", + "master-sun-omni", + "ultima-sun-omni" ], "game": "chunithm", "id": "T19d35f0d5935e588c34", @@ -1343,30 +1337,30 @@ "default": false, "description": "Levels for CHUNITHM in SUN PLUS International.", "folders": [ - "F19d35f0cf27b68c86f0", - "F19d35f0cf277364f6f7", - "F19d35f0cf27f9de655b", - "F19d35f0cf2701a8abb4", - "F19d35f0cf27bcbea726", - "F19d35f0cf2702fa026e", - "F19d35f0cf27fac8ac0e", - "F19d35f0cf270f61eb34", - "F19d35f0cf2795cd1094", - "F19d35f0cf276d94f168", - "F19d35f0cf276d47375b", - "F19d35f0cf27007ef031", - "F19d35f0cf27315cf54d", - "F19d35f0cf27d6f2f842", - "F19d35f0cf27413332fe", - "F19d35f0cf27a657deb2", - "F19d35f0cf2787333258", - "F19d35f0cf27cd5638cc", - "F19d35f0cf279be3f235", - "F19d35f0cf27ceee4cf3", - "F19d35f0cf27ccdacf40", - "F19d35f0cf2729d86303", - "F19d35f0cf2713442010", - "F19d35f0cf27062fe16a" + "1-sunplus-intl", + "2-sunplus-intl", + "3-sunplus-intl", + "4-sunplus-intl", + "5-sunplus-intl", + "6-sunplus-intl", + "7-sunplus-intl", + "7p-sunplus-intl", + "8-sunplus-intl", + "8p-sunplus-intl", + "9-sunplus-intl", + "9p-sunplus-intl", + "10-sunplus-intl", + "10p-sunplus-intl", + "11-sunplus-intl", + "11p-sunplus-intl", + "12-sunplus-intl", + "12p-sunplus-intl", + "13-sunplus-intl", + "13p-sunplus-intl", + "14-sunplus-intl", + "14p-sunplus-intl", + "15-sunplus-intl", + "15p-sunplus-intl" ], "game": "chunithm", "id": "T19d35f0d5935792e65e", @@ -1378,11 +1372,11 @@ "default": false, "description": "Difficulties for CHUNITHM in SUN PLUS International.", "folders": [ - "F19d35f0cf2709ff1c6a", - "F19d35f0cf26fd8087cc", - "F19d35f0cf27e6ecf4ee", - "F19d35f0cf27d86c7cd8", - "F19d35f0cf271f325db7" + "basic-sunplus-intl", + "advanced-sunplus-intl", + "expert-sunplus-intl", + "master-sunplus-intl", + "ultima-sunplus-intl" ], "game": "chunithm", "id": "T19d35f0d59383b54e82", @@ -1394,30 +1388,30 @@ "default": false, "description": "Levels for CHUNITHM in SUN PLUS Omnimix.", "folders": [ - "F19d35f0cf2709033578", - "F19d35f0cf27c9feba12", - "F19d35f0cf27fe011800", - "F19d35f0cf27062c92b8", - "F19d35f0cf27fc593e2b", - "F19d35f0cf27e593b5c8", - "F19d35f0cf276d6a1cb4", - "F19d35f0cf2760bd4b4b", - "F19d35f0cf27872e0d8e", - "F19d35f0cf276707edba", - "F19d35f0cf27539381de", - "F19d35f0cf2748d83494", - "F19d35f0cf275134e97c", - "F19d35f0cf2798c10406", - "F19d35f0cf27dc936480", - "F19d35f0cf27e1067a61", - "F19d35f0cf274d2aaca5", - "F19d35f0cf272eb27530", - "F19d35f0cf27abc9592b", - "F19d35f0cf27246868cc", - "F19d35f0cf270bb7f63d", - "F19d35f0cf27c089b7d1", - "F19d35f0cf2768a34677", - "F19d35f0cf27cbba4f9a" + "1-sunplus-omni", + "2-sunplus-omni", + "3-sunplus-omni", + "4-sunplus-omni", + "5-sunplus-omni", + "6-sunplus-omni", + "7-sunplus-omni", + "7p-sunplus-omni", + "8-sunplus-omni", + "8p-sunplus-omni", + "9-sunplus-omni", + "9p-sunplus-omni", + "10-sunplus-omni", + "10p-sunplus-omni", + "11-sunplus-omni", + "11p-sunplus-omni", + "12-sunplus-omni", + "12p-sunplus-omni", + "13-sunplus-omni", + "13p-sunplus-omni", + "14-sunplus-omni", + "14p-sunplus-omni", + "15-sunplus-omni", + "15p-sunplus-omni" ], "game": "chunithm", "id": "T19d35f0d59337adcb91", @@ -1429,11 +1423,11 @@ "default": false, "description": "Difficulties for CHUNITHM in SUN PLUS Omnimix.", "folders": [ - "F19d35f0cf27719b96c9", - "F19d35f0cf26bc4c5553", - "F19d35f0cf270a97bc9b", - "F19d35f0cf27c57bea2f", - "F19d35f0cf27cf9d1026" + "basic-sunplus-omni", + "advanced-sunplus-omni", + "expert-sunplus-omni", + "master-sunplus-omni", + "ultima-sunplus-omni" ], "game": "chunithm", "id": "T19d35f0d593506b2d01", @@ -1445,30 +1439,30 @@ "default": false, "description": "Levels for CHUNITHM in SUN PLUS.", "folders": [ - "F19d35f0cf273c643341", - "F19d35f0cf272ffb10ee", - "F19d35f0cf279e7c4060", - "F19d35f0cf27ed22561b", - "F19d35f0cf27902999d9", - "F19d35f0cf27a6b3ac45", - "F19d35f0cf279a07bfc3", - "F19d35f0cf27bf70079f", - "F19d35f0cf27d49709fb", - "F19d35f0cf2732dcccd6", - "F19d35f0cf27e62a6e2c", - "F19d35f0cf2739c3569a", - "F19d35f0cf273ecb2aab", - "F19d35f0cf2705d511ce", - "F19d35f0cf27bb080550", - "F19d35f0cf272d561a42", - "F19d35f0cf2705bb31a9", - "F19d35f0cf27455e05fe", - "F19d35f0cf27294664c2", - "F19d35f0cf27782eb93b", - "F19d35f0cf27649c7ec2", - "F19d35f0cf2765a7ec27", - "F19d35f0cf27c42a5d7e", - "F19d35f0cf27cc78fd4f" + "1-sunplus", + "2-sunplus", + "3-sunplus", + "4-sunplus", + "5-sunplus", + "6-sunplus", + "7-sunplus", + "7p-sunplus", + "8-sunplus", + "8p-sunplus", + "9-sunplus", + "9p-sunplus", + "10-sunplus", + "10p-sunplus", + "11-sunplus", + "11p-sunplus", + "12-sunplus", + "12p-sunplus", + "13-sunplus", + "13p-sunplus", + "14-sunplus", + "14p-sunplus", + "15-sunplus", + "15p-sunplus" ], "game": "chunithm", "id": "T19d35f0d5938fb04feb", @@ -1480,11 +1474,11 @@ "default": false, "description": "Difficulties for CHUNITHM in SUN PLUS.", "folders": [ - "F19d35f0cf2751913ebe", - "F19d35f0cf2677a8beba", - "F19d35f0cf276e152f87", - "F19d35f0cf27a13c61ad", - "F19d35f0cf27b40670c4" + "basic-sunplus", + "advanced-sunplus", + "expert-sunplus", + "master-sunplus", + "ultima-sunplus" ], "game": "chunithm", "id": "T19d35f0d593a01c443d", @@ -1496,30 +1490,30 @@ "default": false, "description": "Levels for CHUNITHM in SUN.", "folders": [ - "F19d35f0cf275d69d08d", - "F19d35f0cf27167fece0", - "F19d35f0cf27d8d8b3db", - "F19d35f0cf27000b6258", - "F19d35f0cf273e285ca7", - "F19d35f0cf27d133f715", - "F19d35f0cf27b063834e", - "F19d35f0cf275c4c97fa", - "F19d35f0cf27dee5266a", - "F19d35f0cf27bfe40522", - "F19d35f0cf27ce6a58d9", - "F19d35f0cf27c25d94ad", - "F19d35f0cf270dd94eff", - "F19d35f0cf27389b9888", - "F19d35f0cf27f513be8e", - "F19d35f0cf27871cf702", - "F19d35f0cf27dcfb8150", - "F19d35f0cf2798e3a60a", - "F19d35f0cf272f15fc2a", - "F19d35f0cf2774631b75", - "F19d35f0cf27e7ab8dc1", - "F19d35f0cf272dbfbe4a", - "F19d35f0cf27696c54ba", - "F19d35f0cf273f64cbda" + "1-sun", + "2-sun", + "3-sun", + "4-sun", + "5-sun", + "6-sun", + "7-sun", + "7p-sun", + "8-sun", + "8p-sun", + "9-sun", + "9p-sun", + "10-sun", + "10p-sun", + "11-sun", + "11p-sun", + "12-sun", + "12p-sun", + "13-sun", + "13p-sun", + "14-sun", + "14p-sun", + "15-sun", + "15p-sun" ], "game": "chunithm", "id": "T19d35f0d593739b85f1", @@ -1530,13 +1524,7 @@ { "default": false, "description": "Difficulties for CHUNITHM in SUN.", - "folders": [ - "F19d35f0cf27f260de44", - "F19d35f0cf26e3048f16", - "F19d35f0cf27301d546d", - "F19d35f0cf279129cfd2", - "F19d35f0cf27d28dd608" - ], + "folders": ["basic-sun", "advanced-sun", "expert-sun", "master-sun", "ultima-sun"], "game": "chunithm", "id": "T19d35f0d59300ed4b23", "inactive": true, @@ -1547,30 +1535,30 @@ "default": false, "description": "Levels for CHUNITHM in VERSE International.", "folders": [ - "F19d35f0cf27c83eb441", - "F19d35f0cf27820cc863", - "F19d35f0cf278c155c47", - "F19d35f0cf27831725ee", - "F19d35f0cf27dae9894c", - "F19d35f0cf2738d038d5", - "F19d35f0cf279480e078", - "F19d35f0cf27a1a66e3c", - "F19d35f0cf270013b682", - "F19d35f0cf27130cfab9", - "F19d35f0cf277d526bec", - "F19d35f0cf278ac09c1e", - "F19d35f0cf27e0647cb9", - "F19d35f0cf270e252f64", - "F19d35f0cf2787030e5a", - "F19d35f0cf27732d1a68", - "F19d35f0cf275709a20f", - "F19d35f0cf27ad2eaf9f", - "F19d35f0cf278c70e207", - "F19d35f0cf27428de4cc", - "F19d35f0cf27f95c5e75", - "F19d35f0cf27654eac3d", - "F19d35f0cf2751556d57", - "F19d35f0cf272614ff21" + "1-verse-intl", + "2-verse-intl", + "3-verse-intl", + "4-verse-intl", + "5-verse-intl", + "6-verse-intl", + "7-verse-intl", + "7p-verse-intl", + "8-verse-intl", + "8p-verse-intl", + "9-verse-intl", + "9p-verse-intl", + "10-verse-intl", + "10p-verse-intl", + "11-verse-intl", + "11p-verse-intl", + "12-verse-intl", + "12p-verse-intl", + "13-verse-intl", + "13p-verse-intl", + "14-verse-intl", + "14p-verse-intl", + "15-verse-intl", + "15p-verse-intl" ], "game": "chunithm", "id": "T19d35f0d5939be7d3f1", @@ -1582,12 +1570,12 @@ "default": false, "description": "Difficulties for CHUNITHM in VERSE International.", "folders": [ - "F19d35f0cf271e083def", - "F19d35f0cf2785741b7a", - "F19d35f0cf2724cbf93b", - "F19d35f0cf2732fded01", - "F19d35f0cf27ff81bb89", - "F19d35f0cf2709d729a6" + "basic-verse-intl", + "advanced-verse-intl", + "expert-verse-intl", + "master-verse-intl", + "ultima-verse-intl", + "master-ultima-verse-intl" ], "game": "chunithm", "id": "T19d35f0d593150c3074", @@ -1599,30 +1587,30 @@ "default": false, "description": "Levels for CHUNITHM in VERSE Omnimix.", "folders": [ - "F19d35f0cf278c96829b", - "F19d35f0cf2791645658", - "F19d35f0cf27747112f2", - "F19d35f0cf2739f1864e", - "F19d35f0cf27b5fd9996", - "F19d35f0cf278e7357e6", - "F19d35f0cf27d278d280", - "F19d35f0cf27a90065fc", - "F19d35f0cf27e26709eb", - "F19d35f0cf2764804ff2", - "F19d35f0cf27329b5b7b", - "F19d35f0cf27c8bdd773", - "F19d35f0cf27c8b50c02", - "F19d35f0cf2772434f0b", - "F19d35f0cf27c8829596", - "F19d35f0cf27550c2394", - "F19d35f0cf2704b1e9ad", - "F19d35f0cf270af65038", - "F19d35f0cf2782e29e0c", - "F19d35f0cf271e37387d", - "F19d35f0cf27860aa935", - "F19d35f0cf27318e1ae4", - "F19d35f0cf27265c04d3", - "F19d35f0cf2726cdc422" + "1-verse-omni", + "2-verse-omni", + "3-verse-omni", + "4-verse-omni", + "5-verse-omni", + "6-verse-omni", + "7-verse-omni", + "7p-verse-omni", + "8-verse-omni", + "8p-verse-omni", + "9-verse-omni", + "9p-verse-omni", + "10-verse-omni", + "10p-verse-omni", + "11-verse-omni", + "11p-verse-omni", + "12-verse-omni", + "12p-verse-omni", + "13-verse-omni", + "13p-verse-omni", + "14-verse-omni", + "14p-verse-omni", + "15-verse-omni", + "15p-verse-omni" ], "game": "chunithm", "id": "T19d35f0d5933de58bd3", @@ -1634,12 +1622,12 @@ "default": false, "description": "Difficulties for CHUNITHM in VERSE Omnimix.", "folders": [ - "F19d35f0cf27e02cd920", - "F19d35f0cf271f0a919b", - "F19d35f0cf273964e79c", - "F19d35f0cf27c829ed16", - "F19d35f0cf277217e072", - "F19d35f0cf27b56d1d3c" + "basic-verse-omni", + "advanced-verse-omni", + "expert-verse-omni", + "master-verse-omni", + "ultima-verse-omni", + "master-ultima-verse-omni" ], "game": "chunithm", "id": "T19d35f0d5938e2265fc", @@ -1651,30 +1639,30 @@ "default": false, "description": "Levels for CHUNITHM in VERSE.", "folders": [ - "F19d35f0cf2789312581", - "F19d35f0cf27768cb092", - "F19d35f0cf271095d0ba", - "F19d35f0cf276e405412", - "F19d35f0cf279248ebe6", - "F19d35f0cf27b60473d5", - "F19d35f0cf27e206b288", - "F19d35f0cf277512e011", - "F19d35f0cf279ae85b98", - "F19d35f0cf27a60ddb94", - "F19d35f0cf277855227f", - "F19d35f0cf2739f546d4", - "F19d35f0cf272ee8d3d6", - "F19d35f0cf27f6c1394b", - "F19d35f0cf27caa77f1e", - "F19d35f0cf275eaec66e", - "F19d35f0cf271070c989", - "F19d35f0cf27df5c7e6b", - "F19d35f0cf27d00db06e", - "F19d35f0cf272a875fb8", - "F19d35f0cf2765269880", - "F19d35f0cf27ee810891", - "F19d35f0cf2744e487e0", - "F19d35f0cf2787d05d6f" + "1-verse", + "2-verse", + "3-verse", + "4-verse", + "5-verse", + "6-verse", + "7-verse", + "7p-verse", + "8-verse", + "8p-verse", + "9-verse", + "9p-verse", + "10-verse", + "10p-verse", + "11-verse", + "11p-verse", + "12-verse", + "12p-verse", + "13-verse", + "13p-verse", + "14-verse", + "14p-verse", + "15-verse", + "15p-verse" ], "game": "chunithm", "id": "T19d35f0d593f8c0b5aa", @@ -1686,12 +1674,12 @@ "default": false, "description": "Difficulties for CHUNITHM in VERSE.", "folders": [ - "F19d35f0cf278234cd05", - "F19d35f0cf2750e3f36e", - "F19d35f0cf272c51b140", - "F19d35f0cf279214aacb", - "F19d35f0cf2777d000e1", - "F19d35f0cf27ea01a90e" + "basic-verse", + "advanced-verse", + "expert-verse", + "master-verse", + "ultima-verse", + "master-ultima-verse" ], "game": "chunithm", "id": "T19d35f0d593d4cd21e7", @@ -1703,30 +1691,30 @@ "default": false, "description": "Levels for CHUNITHM in X-VERSE International.", "folders": [ - "F19d35f0cf2779c30f4f", - "F19d35f0cf2721e545f8", - "F19d35f0cf27840b04fe", - "F19d35f0cf27ce693b63", - "F19d35f0cf2783a5c0dd", - "F19d35f0cf27cac26758", - "F19d35f0cf2766ed49cb", - "F19d35f0cf275bc299e5", - "F19d35f0cf27efe4b733", - "F19d35f0cf276432f7ce", - "F19d35f0cf2792f9ab57", - "F19d35f0cf273e4f08ff", - "F19d35f0cf27357d0747", - "F19d35f0cf279ce097a9", - "F19d35f0cf27c58b914c", - "F19d35f0cf27df991f81", - "F19d35f0cf27eb9c59b7", - "F19d35f0cf274a54221b", - "F19d35f0cf2752701c1f", - "F19d35f0cf27f84b0ccf", - "F19d35f0cf27c8f6472b", - "F19d35f0cf27634ec22f", - "F19d35f0cf27df74c2e8", - "F19d35f0cf2776b2ab58" + "1-xverse-intl", + "2-xverse-intl", + "3-xverse-intl", + "4-xverse-intl", + "5-xverse-intl", + "6-xverse-intl", + "7-xverse-intl", + "7p-xverse-intl", + "8-xverse-intl", + "8p-xverse-intl", + "9-xverse-intl", + "9p-xverse-intl", + "10-xverse-intl", + "10p-xverse-intl", + "11-xverse-intl", + "11p-xverse-intl", + "12-xverse-intl", + "12p-xverse-intl", + "13-xverse-intl", + "13p-xverse-intl", + "14-xverse-intl", + "14p-xverse-intl", + "15-xverse-intl", + "15p-xverse-intl" ], "game": "chunithm", "id": "T19d35f0d593caa8e1ed", @@ -1738,12 +1726,12 @@ "default": false, "description": "Difficulties for CHUNITHM in X-VERSE International.", "folders": [ - "F19d35f0cf27f93b3902", - "F19d35f0cf2791cc29b0", - "F19d35f0cf278fd9e7ba", - "F19d35f0cf2742307c9d", - "F19d35f0cf27badd4c6f", - "F19d35f0cf27c0148afa" + "basic-xverse-intl", + "advanced-xverse-intl", + "expert-xverse-intl", + "master-xverse-intl", + "ultima-xverse-intl", + "master-ultima-xverse-intl" ], "game": "chunithm", "id": "T19d35f0d593bc50518b", @@ -1755,30 +1743,30 @@ "default": false, "description": "Levels for CHUNITHM in X-VERSE Omnimix.", "folders": [ - "F19d35f0cf2718c60dd7", - "F19d35f0cf2726a561f3", - "F19d35f0cf278eb74ae4", - "F19d35f0cf27f063cf82", - "F19d35f0cf27b95cc096", - "F19d35f0cf27be662765", - "F19d35f0cf276675caf4", - "F19d35f0cf278f36dba0", - "F19d35f0cf2747141c0c", - "F19d35f0cf27168a37c4", - "F19d35f0cf27c19526a8", - "F19d35f0cf27ce6da07b", - "F19d35f0cf27bd8f157e", - "F19d35f0cf276634d469", - "F19d35f0cf27c6b6c6e4", - "F19d35f0cf27cb55a32a", - "F19d35f0cf2735656f30", - "F19d35f0cf2765cde42d", - "F19d35f0cf2734cc98f4", - "F19d35f0cf27c8af30d7", - "F19d35f0cf27f5a3c3d2", - "F19d35f0cf27a8cfafeb", - "F19d35f0cf27bca2022b", - "F19d35f0cf27481fd3e5" + "1-xverse-omni", + "2-xverse-omni", + "3-xverse-omni", + "4-xverse-omni", + "5-xverse-omni", + "6-xverse-omni", + "7-xverse-omni", + "7p-xverse-omni", + "8-xverse-omni", + "8p-xverse-omni", + "9-xverse-omni", + "9p-xverse-omni", + "10-xverse-omni", + "10p-xverse-omni", + "11-xverse-omni", + "11p-xverse-omni", + "12-xverse-omni", + "12p-xverse-omni", + "13-xverse-omni", + "13p-xverse-omni", + "14-xverse-omni", + "14p-xverse-omni", + "15-xverse-omni", + "15p-xverse-omni" ], "game": "chunithm", "id": "T19d35f0d593c8a47693", @@ -1790,12 +1778,12 @@ "default": false, "description": "Difficulties for CHUNITHM in X-VERSE Omnimix.", "folders": [ - "F19d35f0cf2777cd4629", - "F19d35f0cf276386d0e5", - "F19d35f0cf2732cbc61d", - "F19d35f0cf275c41c32d", - "F19d35f0cf2744ffafa2", - "F19d35f0cf272513cf63" + "basic-xverse-omni", + "advanced-xverse-omni", + "expert-xverse-omni", + "master-xverse-omni", + "ultima-xverse-omni", + "master-ultima-xverse-omni" ], "game": "chunithm", "id": "T19d35f0d593120dd85e", @@ -1807,30 +1795,30 @@ "default": true, "description": "Levels for CHUNITHM in X-VERSE.", "folders": [ - "F19d35f0cf2739f2c8f4", - "F19d35f0cf27131b0385", - "F19d35f0cf27848aa874", - "F19d35f0cf270943f4e3", - "F19d35f0cf2754d64434", - "F19d35f0cf273bb6b1ff", - "F19d35f0cf277847ff8f", - "F19d35f0cf27aa030ecf", - "F19d35f0cf2729d1938a", - "F19d35f0cf274b067fb0", - "F19d35f0cf279a0ead47", - "F19d35f0cf27294a1bd6", - "F19d35f0cf27409be9fc", - "F19d35f0cf278298af17", - "F19d35f0cf2708702ea5", - "F19d35f0cf27657568a0", - "F19d35f0cf275aa6164f", - "F19d35f0cf270eb84616", - "F19d35f0cf27d4b70a89", - "F19d35f0cf27de2bcf32", - "F19d35f0cf279bfa5c0e", - "F19d35f0cf27d4932034", - "F19d35f0cf277c6535ae", - "F19d35f0cf274229136a" + "1-xverse", + "2-xverse", + "3-xverse", + "4-xverse", + "5-xverse", + "6-xverse", + "7-xverse", + "7p-xverse", + "8-xverse", + "8p-xverse", + "9-xverse", + "9p-xverse", + "10-xverse", + "10p-xverse", + "11-xverse", + "11p-xverse", + "12-xverse", + "12p-xverse", + "13-xverse", + "13p-xverse", + "14-xverse", + "14p-xverse", + "15-xverse", + "15p-xverse" ], "game": "chunithm", "id": "T19d35f0d5935c9248ef", @@ -1842,12 +1830,12 @@ "default": false, "description": "Difficulties for CHUNITHM in X-VERSE.", "folders": [ - "F19d35f0cf27b30ee26a", - "F19d35f0cf27da677596", - "F19d35f0cf2722c50046", - "F19d35f0cf27aabd54d1", - "F19d35f0cf27aff7befc", - "F19d35f0cf279eb4f882" + "basic-xverse", + "advanced-xverse", + "expert-xverse", + "master-xverse", + "ultima-xverse", + "master-ultima-xverse" ], "game": "chunithm", "id": "T19d35f0d59392b4398d", @@ -1859,25 +1847,25 @@ "default": false, "description": "Levels for DDR (DP) in a", "folders": [ - "F19d35f0cf271c7173ee", - "F19d35f0cf27eba6ad12", - "F19d35f0cf27ea80d8ef", - "F19d35f0cf27719c0913", - "F19d35f0cf27272609d8", - "F19d35f0cf27edb16c8d", - "F19d35f0cf28d134676c", - "F19d35f0cf28df47af07", - "F19d35f0cf28e7951d71", - "F19d35f0cf27cb7f4b95", - "F19d35f0cf27cf0f8ab4", - "F19d35f0cf273bb074fb", - "F19d35f0cf275c17e0cd", - "F19d35f0cf278d257234", - "F19d35f0cf27ee2a3ffb", - "F19d35f0cf274f0eb187", - "F19d35f0cf274f5bf2f1", - "F19d35f0cf274a328aa5", - "F19d35f0cf27264d0fe5" + "1-a", + "2-a", + "3-a", + "4-a", + "5-a", + "6-a", + "7-a", + "8-a", + "9-a", + "10-a", + "11-a", + "12-a", + "13-a", + "14-a", + "15-a", + "16-a", + "17-a", + "18-a", + "19-a" ], "game": "ddr-dp", "id": "T19d35f0d593b0fac9b9", @@ -1889,25 +1877,25 @@ "default": false, "description": "Levels for DDR (DP) in a20", "folders": [ - "F19d35f0cf27565f40a5", - "F19d35f0cf27972a2dc1", - "F19d35f0cf27bac3b6db", - "F19d35f0cf272d51d2c8", - "F19d35f0cf275e449031", - "F19d35f0cf2755e9a4ba", - "F19d35f0cf2896a8ee3d", - "F19d35f0cf28e3e92b4d", - "F19d35f0cf281ac552db", - "F19d35f0cf27bdd8f896", - "F19d35f0cf2732baae56", - "F19d35f0cf27504f2642", - "F19d35f0cf2749ce3e12", - "F19d35f0cf27db3a63db", - "F19d35f0cf2796000c36", - "F19d35f0cf27622d9369", - "F19d35f0cf27efd55637", - "F19d35f0cf279766469f", - "F19d35f0cf278c6c43cf" + "1-a20", + "2-a20", + "3-a20", + "4-a20", + "5-a20", + "6-a20", + "7-a20", + "8-a20", + "9-a20", + "10-a20", + "11-a20", + "12-a20", + "13-a20", + "14-a20", + "15-a20", + "16-a20", + "17-a20", + "18-a20", + "19-a20" ], "game": "ddr-dp", "id": "T19d35f0d593a68bfd69", @@ -1919,25 +1907,25 @@ "default": false, "description": "Levels for DDR (DP) in a20plus", "folders": [ - "F19d35f0cf27fda29e91", - "F19d35f0cf272db47d6a", - "F19d35f0cf27ae45928e", - "F19d35f0cf27e11a2f89", - "F19d35f0cf2746ea3562", - "F19d35f0cf2766dffeb1", - "F19d35f0cf2805f41680", - "F19d35f0cf28160abf75", - "F19d35f0cf28c0998f91", - "F19d35f0cf27746a4c50", - "F19d35f0cf27b5d67d94", - "F19d35f0cf27538050a3", - "F19d35f0cf274b8e76be", - "F19d35f0cf27b9917ee8", - "F19d35f0cf27062af7da", - "F19d35f0cf27ed093e5c", - "F19d35f0cf2705f4f0f9", - "F19d35f0cf2787857414", - "F19d35f0cf27a0f2e2f9" + "1-a20plus", + "2-a20plus", + "3-a20plus", + "4-a20plus", + "5-a20plus", + "6-a20plus", + "7-a20plus", + "8-a20plus", + "9-a20plus", + "10-a20plus", + "11-a20plus", + "12-a20plus", + "13-a20plus", + "14-a20plus", + "15-a20plus", + "16-a20plus", + "17-a20plus", + "18-a20plus", + "19-a20plus" ], "game": "ddr-dp", "id": "T19d35f0d5933ecdfd2a", @@ -1949,25 +1937,25 @@ "default": true, "description": "Levels for DDR (DP) in a3", "folders": [ - "F19d35f0cf27a6a33326", - "F19d35f0cf2734ab13f8", - "F19d35f0cf27e3ed8c6c", - "F19d35f0cf275bb12cad", - "F19d35f0cf275967caaa", - "F19d35f0cf28dd4057bd", - "F19d35f0cf2827290ecf", - "F19d35f0cf28be1460dc", - "F19d35f0cf28d4f5a8ae", - "F19d35f0cf274e7c3531", - "F19d35f0cf27b022ddf1", - "F19d35f0cf27d1d94240", - "F19d35f0cf274c9df798", - "F19d35f0cf27060d6acb", - "F19d35f0cf27bfa0a7af", - "F19d35f0cf276285782d", - "F19d35f0cf27746eb584", - "F19d35f0cf27d3f8bfa0", - "F19d35f0cf27186a471b" + "1-a3", + "2-a3", + "3-a3", + "4-a3", + "5-a3", + "6-a3", + "7-a3", + "8-a3", + "9-a3", + "10-a3", + "11-a3", + "12-a3", + "13-a3", + "14-a3", + "15-a3", + "16-a3", + "17-a3", + "18-a3", + "19-a3" ], "game": "ddr-dp", "id": "T19d35f0d593d4bed0be", @@ -1979,25 +1967,25 @@ "default": false, "description": "Levels for DDR (DP) in konaste", "folders": [ - "F19d35f0cf275921ba6e", - "F19d35f0cf2779752882", - "F19d35f0cf27b99a9b12", - "F19d35f0cf27e33dc474", - "F19d35f0cf27141e541a", - "F19d35f0cf284222fdb4", - "F19d35f0cf28cbae15e4", - "F19d35f0cf2886af6a7f", - "F19d35f0cf28550adff1", - "F19d35f0cf274c97b166", - "F19d35f0cf27dcfdb5ae", - "F19d35f0cf27509baf5a", - "F19d35f0cf27f8c05311", - "F19d35f0cf277f860c82", - "F19d35f0cf272440617f", - "F19d35f0cf2737448528", - "F19d35f0cf2743d1094a", - "F19d35f0cf278732cdb5", - "F19d35f0cf27a8157f39" + "1-konaste", + "2-konaste", + "3-konaste", + "4-konaste", + "5-konaste", + "6-konaste", + "7-konaste", + "8-konaste", + "9-konaste", + "10-konaste", + "11-konaste", + "12-konaste", + "13-konaste", + "14-konaste", + "15-konaste", + "16-konaste", + "17-konaste", + "18-konaste", + "19-konaste" ], "game": "ddr-dp", "id": "T19d35f0d593d16f1142", @@ -2009,25 +1997,25 @@ "default": false, "description": "Levels for DDR (DP) in world", "folders": [ - "F19d35f0cf2767b58b2e", - "F19d35f0cf27a5974a9b", - "F19d35f0cf2733b7edaf", - "F19d35f0cf27b79eafde", - "F19d35f0cf272c6eff16", - "F19d35f0cf2811dc554d", - "F19d35f0cf2854466e48", - "F19d35f0cf28f50246bd", - "F19d35f0cf289af3ebc3", - "F19d35f0cf2754ac7760", - "F19d35f0cf274d9046fc", - "F19d35f0cf277713f4ae", - "F19d35f0cf27e18a5c62", - "F19d35f0cf27e7b70aa3", - "F19d35f0cf274b6e96a7", - "F19d35f0cf276096fcdb", - "F19d35f0cf278b461315", - "F19d35f0cf272a4d6b5b", - "F19d35f0cf2756a63862" + "1-world", + "2-world", + "3-world", + "4-world", + "5-world", + "6-world", + "7-world", + "8-world", + "9-world", + "10-world", + "11-world", + "12-world", + "13-world", + "14-world", + "15-world", + "16-world", + "17-world", + "18-world", + "19-world" ], "game": "ddr-dp", "id": "T19d35f0d5932914b6f5", @@ -2039,25 +2027,25 @@ "default": false, "description": "Levels for DDR (SP) in a", "folders": [ - "F19d35f0cf273c8f5d67", - "F19d35f0cf27f9e8f915", - "F19d35f0cf27f0a0afaf", - "F19d35f0cf27ff3ca079", - "F19d35f0cf2720f7d70a", - "F19d35f0cf2759ae9f3e", - "F19d35f0cf28b964deb9", - "F19d35f0cf2875a7f809", - "F19d35f0cf28730aab09", - "F19d35f0cf27ae456372", - "F19d35f0cf2794c17c19", - "F19d35f0cf278775dbf5", - "F19d35f0cf27284dcade", - "F19d35f0cf271577bb76", - "F19d35f0cf273032b791", - "F19d35f0cf2750fa4f78", - "F19d35f0cf27510d91dd", - "F19d35f0cf27cf6ea39b", - "F19d35f0cf273c908d4c" + "1-a", + "2-a", + "3-a", + "4-a", + "5-a", + "6-a", + "7-a", + "8-a", + "9-a", + "10-a", + "11-a", + "12-a", + "13-a", + "14-a", + "15-a", + "16-a", + "17-a", + "18-a", + "19-a" ], "game": "ddr-sp", "id": "T19d35f0d593944b5d34", @@ -2069,25 +2057,25 @@ "default": false, "description": "Levels for DDR (SP) in a20", "folders": [ - "F19d35f0cf27d690a393", - "F19d35f0cf277955b78c", - "F19d35f0cf27709dd6bc", - "F19d35f0cf27b82d5c54", - "F19d35f0cf273a0f31f3", - "F19d35f0cf27658fb11d", - "F19d35f0cf2811196fc3", - "F19d35f0cf284af8087b", - "F19d35f0cf289ab09bbd", - "F19d35f0cf27a0979b05", - "F19d35f0cf275011dea6", - "F19d35f0cf2732f48f5b", - "F19d35f0cf27ba14e506", - "F19d35f0cf273f9ee29e", - "F19d35f0cf2763133917", - "F19d35f0cf27527ce1b7", - "F19d35f0cf27884a17aa", - "F19d35f0cf2718014bd4", - "F19d35f0cf274b411546" + "1-a20", + "2-a20", + "3-a20", + "4-a20", + "5-a20", + "6-a20", + "7-a20", + "8-a20", + "9-a20", + "10-a20", + "11-a20", + "12-a20", + "13-a20", + "14-a20", + "15-a20", + "16-a20", + "17-a20", + "18-a20", + "19-a20" ], "game": "ddr-sp", "id": "T19d35f0d59302c3f5e6", @@ -2099,25 +2087,25 @@ "default": false, "description": "Levels for DDR (SP) in a20plus", "folders": [ - "F19d35f0cf2776ca21a0", - "F19d35f0cf271920606f", - "F19d35f0cf27ffea2b93", - "F19d35f0cf27fb147a98", - "F19d35f0cf27d837cfe1", - "F19d35f0cf27e38714b8", - "F19d35f0cf28649c1351", - "F19d35f0cf2891e21d61", - "F19d35f0cf2884646559", - "F19d35f0cf274532b135", - "F19d35f0cf27832126f9", - "F19d35f0cf27082d1ea9", - "F19d35f0cf27f8a0fddd", - "F19d35f0cf2760a82184", - "F19d35f0cf27ae37dda8", - "F19d35f0cf27608f4853", - "F19d35f0cf2758519979", - "F19d35f0cf276ddbbfd2", - "F19d35f0cf276f22f970" + "1-a20plus", + "2-a20plus", + "3-a20plus", + "4-a20plus", + "5-a20plus", + "6-a20plus", + "7-a20plus", + "8-a20plus", + "9-a20plus", + "10-a20plus", + "11-a20plus", + "12-a20plus", + "13-a20plus", + "14-a20plus", + "15-a20plus", + "16-a20plus", + "17-a20plus", + "18-a20plus", + "19-a20plus" ], "game": "ddr-sp", "id": "T19d35f0d593461d20e3", @@ -2129,25 +2117,25 @@ "default": true, "description": "Levels for DDR (SP) in a3", "folders": [ - "F19d35f0cf274cb62957", - "F19d35f0cf27ce5bbe69", - "F19d35f0cf270c309791", - "F19d35f0cf27c19e2213", - "F19d35f0cf2782271640", - "F19d35f0cf280ae7c8c6", - "F19d35f0cf28b90046f1", - "F19d35f0cf28f87dacb7", - "F19d35f0cf281ddb248f", - "F19d35f0cf2725a9fee3", - "F19d35f0cf27d3dd8e53", - "F19d35f0cf27a748260e", - "F19d35f0cf27a3071305", - "F19d35f0cf2725c5c681", - "F19d35f0cf27674ef854", - "F19d35f0cf27bdf336bf", - "F19d35f0cf27af535e5c", - "F19d35f0cf27de2b5d6a", - "F19d35f0cf2769180c97" + "1-a3", + "2-a3", + "3-a3", + "4-a3", + "5-a3", + "6-a3", + "7-a3", + "8-a3", + "9-a3", + "10-a3", + "11-a3", + "12-a3", + "13-a3", + "14-a3", + "15-a3", + "16-a3", + "17-a3", + "18-a3", + "19-a3" ], "game": "ddr-sp", "id": "T19d35f0d593b7a93e55", @@ -2159,25 +2147,25 @@ "default": false, "description": "Levels for DDR (SP) in konaste", "folders": [ - "F19d35f0cf278084b6f6", - "F19d35f0cf27e313b8ca", - "F19d35f0cf2733301e88", - "F19d35f0cf27051ff1c5", - "F19d35f0cf2787c7db0c", - "F19d35f0cf2809204485", - "F19d35f0cf28fa8291a0", - "F19d35f0cf286875a788", - "F19d35f0cf28307eb4f8", - "F19d35f0cf27cfee2dd0", - "F19d35f0cf27b33986ab", - "F19d35f0cf27069ff428", - "F19d35f0cf27c8fd27a1", - "F19d35f0cf27cefdca27", - "F19d35f0cf27da3fbec1", - "F19d35f0cf275c87e4df", - "F19d35f0cf27e23981a0", - "F19d35f0cf27d0136a55", - "F19d35f0cf27c8f76e34" + "1-konaste", + "2-konaste", + "3-konaste", + "4-konaste", + "5-konaste", + "6-konaste", + "7-konaste", + "8-konaste", + "9-konaste", + "10-konaste", + "11-konaste", + "12-konaste", + "13-konaste", + "14-konaste", + "15-konaste", + "16-konaste", + "17-konaste", + "18-konaste", + "19-konaste" ], "game": "ddr-sp", "id": "T19d35f0d593495a6c6e", @@ -2189,25 +2177,25 @@ "default": false, "description": "Levels for DDR (SP) in world", "folders": [ - "F19d35f0cf276929172f", - "F19d35f0cf27912938a9", - "F19d35f0cf27894d209b", - "F19d35f0cf27a0344928", - "F19d35f0cf27b6b32b43", - "F19d35f0cf2870a7cef1", - "F19d35f0cf2876d753d3", - "F19d35f0cf2830c58cfe", - "F19d35f0cf28ff293862", - "F19d35f0cf278507fbbc", - "F19d35f0cf27e2629b17", - "F19d35f0cf2719d4d4a9", - "F19d35f0cf275cddab75", - "F19d35f0cf270cbdc2e9", - "F19d35f0cf2744dab422", - "F19d35f0cf278d2a1808", - "F19d35f0cf27dfd9d82f", - "F19d35f0cf2729bbdaae", - "F19d35f0cf276bf7b6a3" + "1-world", + "2-world", + "3-world", + "4-world", + "5-world", + "6-world", + "7-world", + "8-world", + "9-world", + "10-world", + "11-world", + "12-world", + "13-world", + "14-world", + "15-world", + "16-world", + "17-world", + "18-world", + "19-world" ], "game": "ddr-sp", "id": "T19d35f0d5938df73e57", @@ -2219,20 +2207,20 @@ "default": true, "description": "Level Folders for GITADORA Konaste.", "folders": [ - "F19d35f0cf28f6fe0bbf", - "F19d35f0cf28149a9062", - "F19d35f0cf28afb06e8f", - "F19d35f0cf28f5a1d55c", - "F19d35f0cf28e8ae1cbc", - "F19d35f0cf2876a674db", - "F19d35f0cf28445bd793", - "F19d35f0cf28a2ff371c", - "F19d35f0cf2896d04e0f", - "F19d35f0cf28d6ac5446", - "F19d35f0cf28a49d2447", - "F19d35f0cf2851727d4b", - "F19d35f0cf28d770e54c", - "F19d35f0cf283cd101cb" + "konaste-ge-1-lt-2", + "konaste-ge-2-lt-3", + "konaste-ge-3-lt-4", + "konaste-ge-4-lt-5", + "konaste-ge-5-lt-5p5", + "konaste-ge-5p5-lt-6", + "konaste-ge-6-lt-6p5", + "konaste-ge-6p5-lt-7", + "konaste-ge-7-lt-7p5", + "konaste-ge-7p5-lt-8", + "konaste-ge-8-lt-8p5", + "konaste-ge-8p5-lt-9", + "konaste-ge-9-lt-9p5", + "konaste-ge-9p5-lt-10" ], "game": "gitadora-dora", "id": "T19d35f0d593dd6e668b", @@ -2244,20 +2232,20 @@ "default": true, "description": "Level Folders for GITADORA Konaste.", "folders": [ - "F19d35f0cf2891f201ae", - "F19d35f0cf2849906730", - "F19d35f0cf280ac13879", - "F19d35f0cf28b1ff9dfc", - "F19d35f0cf2806c883d3", - "F19d35f0cf28d57edd7a", - "F19d35f0cf28807ec5a0", - "F19d35f0cf28034567a7", - "F19d35f0cf2800babaa2", - "F19d35f0cf28b2037cab", - "F19d35f0cf287b7d790f", - "F19d35f0cf28775f1fb3", - "F19d35f0cf288bd6fd6f", - "F19d35f0cf28e0def528" + "konaste-ge-1-lt-2", + "konaste-ge-2-lt-3", + "konaste-ge-3-lt-4", + "konaste-ge-4-lt-5", + "konaste-ge-5-lt-5p5", + "konaste-ge-5p5-lt-6", + "konaste-ge-6-lt-6p5", + "konaste-ge-6p5-lt-7", + "konaste-ge-7-lt-7p5", + "konaste-ge-7p5-lt-8", + "konaste-ge-8-lt-8p5", + "konaste-ge-8p5-lt-9", + "konaste-ge-9-lt-9p5", + "konaste-ge-9p5-lt-10" ], "game": "gitadora-gita", "id": "T19d35f0d59305c37ab2", @@ -2269,18 +2257,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in 10th Style CS.", "folders": [ - "F19d35f0cf285c64ccb6", - "F19d35f0cf28550a3aad", - "F19d35f0cf2801059d65", - "F19d35f0cf286ae55971", - "F19d35f0cf28b17e7d7c", - "F19d35f0cf28f5f77d59", - "F19d35f0cf28e74c59f4", - "F19d35f0cf285670710e", - "F19d35f0cf29f5fe4b88", - "F19d35f0cf2825e96e87", - "F19d35f0cf2894edd381", - "F19d35f0cf285ecc4f2e" + "1-10th-cs", + "2-10th-cs", + "3-10th-cs", + "4-10th-cs", + "5-10th-cs", + "6-10th-cs", + "7-10th-cs", + "8-10th-cs", + "9-10th-cs", + "10-10th-cs", + "11-10th-cs", + "12-10th-cs" ], "game": "iidx-dp", "id": "T19d35f0d5938bdb1631", @@ -2292,18 +2280,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in 3rd Style CS.", "folders": [ - "F19d35f0cf28df41fe3d", - "F19d35f0cf282fa788a7", - "F19d35f0cf286b9d0824", - "F19d35f0cf28d8040b1a", - "F19d35f0cf28aa246a43", - "F19d35f0cf285e1caee0", - "F19d35f0cf28d7b3f33e", - "F19d35f0cf28c4fef65b", - "F19d35f0cf29076bbd95", - "F19d35f0cf28cfa75b1d", - "F19d35f0cf28634dd4e2", - "F19d35f0cf289e88489d" + "1-3rd-cs", + "2-3rd-cs", + "3-3rd-cs", + "4-3rd-cs", + "5-3rd-cs", + "6-3rd-cs", + "7-3rd-cs", + "8-3rd-cs", + "9-3rd-cs", + "10-3rd-cs", + "11-3rd-cs", + "12-3rd-cs" ], "game": "iidx-dp", "id": "T19d35f0d5936887ca99", @@ -2315,18 +2303,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in 4th Style CS.", "folders": [ - "F19d35f0cf2809e6a481", - "F19d35f0cf28b4684ad7", - "F19d35f0cf283d0be7f0", - "F19d35f0cf28d1bd144c", - "F19d35f0cf28d54bf55f", - "F19d35f0cf2875f96f53", - "F19d35f0cf28cd847051", - "F19d35f0cf286b9de74f", - "F19d35f0cf29bdd83728", - "F19d35f0cf286c3a1f83", - "F19d35f0cf2859c72995", - "F19d35f0cf28860cf290" + "1-4th-cs", + "2-4th-cs", + "3-4th-cs", + "4-4th-cs", + "5-4th-cs", + "6-4th-cs", + "7-4th-cs", + "8-4th-cs", + "9-4th-cs", + "10-4th-cs", + "11-4th-cs", + "12-4th-cs" ], "game": "iidx-dp", "id": "T19d35f0d593159511dd", @@ -2338,18 +2326,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in 5th Style CS.", "folders": [ - "F19d35f0cf28373c220d", - "F19d35f0cf28101a623e", - "F19d35f0cf2833171283", - "F19d35f0cf28ad6eb4ab", - "F19d35f0cf287514713a", - "F19d35f0cf281f49f6a2", - "F19d35f0cf286adc4ba7", - "F19d35f0cf286d40e44c", - "F19d35f0cf2940ebf8fd", - "F19d35f0cf28063fcf43", - "F19d35f0cf28472b7a31", - "F19d35f0cf287060c7e9" + "1-5th-cs", + "2-5th-cs", + "3-5th-cs", + "4-5th-cs", + "5-5th-cs", + "6-5th-cs", + "7-5th-cs", + "8-5th-cs", + "9-5th-cs", + "10-5th-cs", + "11-5th-cs", + "12-5th-cs" ], "game": "iidx-dp", "id": "T19d35f0d5937bc68121", @@ -2361,18 +2349,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in 6th Style CS.", "folders": [ - "F19d35f0cf288776b2dc", - "F19d35f0cf28718e24d8", - "F19d35f0cf28ca59bf9e", - "F19d35f0cf286742e087", - "F19d35f0cf284612a28f", - "F19d35f0cf283a687f4d", - "F19d35f0cf28d923d2bb", - "F19d35f0cf286f8053b4", - "F19d35f0cf29299b9bd4", - "F19d35f0cf28de1997d8", - "F19d35f0cf28d4022b4c", - "F19d35f0cf28b5b2ac6e" + "1-6th-cs", + "2-6th-cs", + "3-6th-cs", + "4-6th-cs", + "5-6th-cs", + "6-6th-cs", + "7-6th-cs", + "8-6th-cs", + "9-6th-cs", + "10-6th-cs", + "11-6th-cs", + "12-6th-cs" ], "game": "iidx-dp", "id": "T19d35f0d593d12ed2cb", @@ -2384,18 +2372,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in 7th Style CS.", "folders": [ - "F19d35f0cf28809171d8", - "F19d35f0cf2845a34e3b", - "F19d35f0cf285c2d73ef", - "F19d35f0cf281e97ebe2", - "F19d35f0cf283fd66001", - "F19d35f0cf289a683b3a", - "F19d35f0cf28e7945c0f", - "F19d35f0cf28210aa730", - "F19d35f0cf29a1e59c4e", - "F19d35f0cf289d50b4a8", - "F19d35f0cf281ae5704d", - "F19d35f0cf28b2dc587c" + "1-7th-cs", + "2-7th-cs", + "3-7th-cs", + "4-7th-cs", + "5-7th-cs", + "6-7th-cs", + "7-7th-cs", + "8-7th-cs", + "9-7th-cs", + "10-7th-cs", + "11-7th-cs", + "12-7th-cs" ], "game": "iidx-dp", "id": "T19d35f0d5932d5a7ef5", @@ -2407,18 +2395,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in 8th Style CS.", "folders": [ - "F19d35f0cf28d76c4c33", - "F19d35f0cf28db957d26", - "F19d35f0cf28e49c0fc3", - "F19d35f0cf28e0f00c28", - "F19d35f0cf28d028a562", - "F19d35f0cf281fdec4ca", - "F19d35f0cf285141d76c", - "F19d35f0cf281d1595ac", - "F19d35f0cf291d2a8fa7", - "F19d35f0cf28559a997f", - "F19d35f0cf28d7d3ec0a", - "F19d35f0cf289cff9948" + "1-8th-cs", + "2-8th-cs", + "3-8th-cs", + "4-8th-cs", + "5-8th-cs", + "6-8th-cs", + "7-8th-cs", + "8-8th-cs", + "9-8th-cs", + "10-8th-cs", + "11-8th-cs", + "12-8th-cs" ], "game": "iidx-dp", "id": "T19d35f0d5937166cd80", @@ -2430,18 +2418,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in 9th Style CS.", "folders": [ - "F19d35f0cf28bf6152ed", - "F19d35f0cf289b0e9d02", - "F19d35f0cf282bff4505", - "F19d35f0cf283ad4fd9d", - "F19d35f0cf281490dd99", - "F19d35f0cf28eee1ee1b", - "F19d35f0cf28e017c205", - "F19d35f0cf2862c4e2dd", - "F19d35f0cf291f527e27", - "F19d35f0cf28abccbb7b", - "F19d35f0cf281bc0e2fa", - "F19d35f0cf28e7f6595f" + "1-9th-cs", + "2-9th-cs", + "3-9th-cs", + "4-9th-cs", + "5-9th-cs", + "6-9th-cs", + "7-9th-cs", + "8-9th-cs", + "9-9th-cs", + "10-9th-cs", + "11-9th-cs", + "12-9th-cs" ], "game": "iidx-dp", "id": "T19d35f0d593affecdb2", @@ -2453,18 +2441,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in BEATMANIA US.", "folders": [ - "F19d35f0cf2811bab49c", - "F19d35f0cf28a7bdb06b", - "F19d35f0cf289497d4de", - "F19d35f0cf287e1d8801", - "F19d35f0cf284d963134", - "F19d35f0cf28375811d0", - "F19d35f0cf28f8ae3867", - "F19d35f0cf28745c937f", - "F19d35f0cf290f4b2a45", - "F19d35f0cf284bc0b91e", - "F19d35f0cf28f829d4cb", - "F19d35f0cf2852f36e15" + "1-bmus", + "2-bmus", + "3-bmus", + "4-bmus", + "5-bmus", + "6-bmus", + "7-bmus", + "8-bmus", + "9-bmus", + "10-bmus", + "11-bmus", + "12-bmus" ], "game": "iidx-dp", "id": "T19d35f0d593fa1afe80", @@ -2476,18 +2464,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in BISTROVER Omnimix.", "folders": [ - "F19d35f0cf288928fa8a", - "F19d35f0cf28578c04e1", - "F19d35f0cf28cf586dc2", - "F19d35f0cf287f30f658", - "F19d35f0cf28365e649e", - "F19d35f0cf28dcf5707e", - "F19d35f0cf289f90270b", - "F19d35f0cf282d3db704", - "F19d35f0cf2942d94a2c", - "F19d35f0cf281ccc2cf7", - "F19d35f0cf282ba8b8f1", - "F19d35f0cf2850ea6e1a" + "1-bistrover-omni", + "2-bistrover-omni", + "3-bistrover-omni", + "4-bistrover-omni", + "5-bistrover-omni", + "6-bistrover-omni", + "7-bistrover-omni", + "8-bistrover-omni", + "9-bistrover-omni", + "10-bistrover-omni", + "11-bistrover-omni", + "12-bistrover-omni" ], "game": "iidx-dp", "id": "T19d35f0d5934ecbbed9", @@ -2499,18 +2487,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in BISTROVER.", "folders": [ - "F19d35f0cf28568031c1", - "F19d35f0cf284736f21f", - "F19d35f0cf28c05f278b", - "F19d35f0cf28cadcbea7", - "F19d35f0cf28eb470b32", - "F19d35f0cf2838bf4f46", - "F19d35f0cf28c51a05a5", - "F19d35f0cf28f8a4414f", - "F19d35f0cf29f3eede7f", - "F19d35f0cf28edfc6fa3", - "F19d35f0cf2833fdd98a", - "F19d35f0cf283d7b5a50" + "1-bistrover", + "2-bistrover", + "3-bistrover", + "4-bistrover", + "5-bistrover", + "6-bistrover", + "7-bistrover", + "8-bistrover", + "9-bistrover", + "10-bistrover", + "11-bistrover", + "12-bistrover" ], "game": "iidx-dp", "id": "T19d35f0d593fe458503", @@ -2522,18 +2510,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in CANNON BALLERS.", "folders": [ - "F19d35f0cf2819ec62ae", - "F19d35f0cf287b260b90", - "F19d35f0cf28689f14f3", - "F19d35f0cf289747bdb9", - "F19d35f0cf2865e95187", - "F19d35f0cf28de4f18cb", - "F19d35f0cf286d778c30", - "F19d35f0cf280bc5adfb", - "F19d35f0cf29d2c8aacb", - "F19d35f0cf28f1909856", - "F19d35f0cf2835bd2830", - "F19d35f0cf289f6320a2" + "1-cannonballers", + "2-cannonballers", + "3-cannonballers", + "4-cannonballers", + "5-cannonballers", + "6-cannonballers", + "7-cannonballers", + "8-cannonballers", + "9-cannonballers", + "10-cannonballers", + "11-cannonballers", + "12-cannonballers" ], "game": "iidx-dp", "id": "T19d35f0d5935b95440b", @@ -2545,18 +2533,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in CastHour Omnimix.", "folders": [ - "F19d35f0cf2868337932", - "F19d35f0cf28679cace7", - "F19d35f0cf28b62da26f", - "F19d35f0cf28d616323c", - "F19d35f0cf2801b062e2", - "F19d35f0cf28c7ccdff1", - "F19d35f0cf28090393e1", - "F19d35f0cf28aec01d4c", - "F19d35f0cf2962cd8989", - "F19d35f0cf28f548f414", - "F19d35f0cf28b489e825", - "F19d35f0cf28c54c2621" + "1-casthour-omni", + "2-casthour-omni", + "3-casthour-omni", + "4-casthour-omni", + "5-casthour-omni", + "6-casthour-omni", + "7-casthour-omni", + "8-casthour-omni", + "9-casthour-omni", + "10-casthour-omni", + "11-casthour-omni", + "12-casthour-omni" ], "game": "iidx-dp", "id": "T19d35f0d5933155650b", @@ -2568,18 +2556,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in CastHour.", "folders": [ - "F19d35f0cf281a360be3", - "F19d35f0cf28c3b98787", - "F19d35f0cf28b9ddbf80", - "F19d35f0cf286f30edff", - "F19d35f0cf28c6b70021", - "F19d35f0cf286d535175", - "F19d35f0cf28be62b4b4", - "F19d35f0cf28a4352396", - "F19d35f0cf295b95dda3", - "F19d35f0cf288ed3acb6", - "F19d35f0cf28c8d40f5f", - "F19d35f0cf28c758f5de" + "1-casthour", + "2-casthour", + "3-casthour", + "4-casthour", + "5-casthour", + "6-casthour", + "7-casthour", + "8-casthour", + "9-casthour", + "10-casthour", + "11-casthour", + "12-casthour" ], "game": "iidx-dp", "id": "T19d35f0d593a03353a0", @@ -2591,18 +2579,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in copula.", "folders": [ - "F19d35f0cf28efbf86cb", - "F19d35f0cf2829fc3760", - "F19d35f0cf281b95547e", - "F19d35f0cf287fe5e3ea", - "F19d35f0cf282af0249b", - "F19d35f0cf28626be364", - "F19d35f0cf28e1649a3c", - "F19d35f0cf282fecf5fb", - "F19d35f0cf29677ed6a9", - "F19d35f0cf28279870b6", - "F19d35f0cf289f816b8d", - "F19d35f0cf28bd43cdfa" + "1-copula", + "2-copula", + "3-copula", + "4-copula", + "5-copula", + "6-copula", + "7-copula", + "8-copula", + "9-copula", + "10-copula", + "11-copula", + "12-copula" ], "game": "iidx-dp", "id": "T19d35f0d59338fd94e6", @@ -2614,18 +2602,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in DISTORTED CS.", "folders": [ - "F19d35f0cf28b803f436", - "F19d35f0cf2818038c10", - "F19d35f0cf28a5337dae", - "F19d35f0cf28d2028407", - "F19d35f0cf28f0fc7941", - "F19d35f0cf28ac87b7b6", - "F19d35f0cf28e56c4ae7", - "F19d35f0cf28b4b19cde", - "F19d35f0cf2940ab4456", - "F19d35f0cf2870d664d6", - "F19d35f0cf285b2f2802", - "F19d35f0cf2860f75572" + "1-13th-cs", + "2-13th-cs", + "3-13th-cs", + "4-13th-cs", + "5-13th-cs", + "6-13th-cs", + "7-13th-cs", + "8-13th-cs", + "9-13th-cs", + "10-13th-cs", + "11-13th-cs", + "12-13th-cs" ], "game": "iidx-dp", "id": "T19d35f0d593fcc86f73", @@ -2637,18 +2625,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in DJ TROOPERS CS.", "folders": [ - "F19d35f0cf284e952d6c", - "F19d35f0cf28db8c08ba", - "F19d35f0cf283e6fbdf1", - "F19d35f0cf28d7100c0a", - "F19d35f0cf28d12c4e89", - "F19d35f0cf280ceb63b1", - "F19d35f0cf28ea97d885", - "F19d35f0cf2835f69dd1", - "F19d35f0cf294b72606f", - "F19d35f0cf28b98913a3", - "F19d35f0cf28baa7d105", - "F19d35f0cf28894670ad" + "1-15th-cs", + "2-15th-cs", + "3-15th-cs", + "4-15th-cs", + "5-15th-cs", + "6-15th-cs", + "7-15th-cs", + "8-15th-cs", + "9-15th-cs", + "10-15th-cs", + "11-15th-cs", + "12-15th-cs" ], "game": "iidx-dp", "id": "T19d35f0d59353e2286c", @@ -2660,18 +2648,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in EMPRESS CS.", "folders": [ - "F19d35f0cf282464b37c", - "F19d35f0cf28999c5149", - "F19d35f0cf28b00d585a", - "F19d35f0cf286eee0913", - "F19d35f0cf28289abf66", - "F19d35f0cf2826679dd6", - "F19d35f0cf28624afac6", - "F19d35f0cf283f01150f", - "F19d35f0cf2957cabd34", - "F19d35f0cf2882b151a5", - "F19d35f0cf28f6940b0b", - "F19d35f0cf28beddbe68" + "1-16th-cs", + "2-16th-cs", + "3-16th-cs", + "4-16th-cs", + "5-16th-cs", + "6-16th-cs", + "7-16th-cs", + "8-16th-cs", + "9-16th-cs", + "10-16th-cs", + "11-16th-cs", + "12-16th-cs" ], "game": "iidx-dp", "id": "T19d35f0d5931029e460", @@ -2683,18 +2671,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in Epolis Omnimix.", "folders": [ - "F19d35f0cf285abfa33e", - "F19d35f0cf28d3f2da5f", - "F19d35f0cf28526c16b9", - "F19d35f0cf286af47dd8", - "F19d35f0cf280c712416", - "F19d35f0cf28081e1a93", - "F19d35f0cf286fc5e046", - "F19d35f0cf28fb5a9fea", - "F19d35f0cf29546d6508", - "F19d35f0cf288d5c43c4", - "F19d35f0cf28908c80d9", - "F19d35f0cf288f7f68c7" + "1-epolis-omni", + "2-epolis-omni", + "3-epolis-omni", + "4-epolis-omni", + "5-epolis-omni", + "6-epolis-omni", + "7-epolis-omni", + "8-epolis-omni", + "9-epolis-omni", + "10-epolis-omni", + "11-epolis-omni", + "12-epolis-omni" ], "game": "iidx-dp", "id": "T19d35f0d5936dcdd8bb", @@ -2706,18 +2694,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in Epolis.", "folders": [ - "F19d35f0cf287916b117", - "F19d35f0cf2862f625a2", - "F19d35f0cf28d696c866", - "F19d35f0cf2877136959", - "F19d35f0cf28dcdbd5b2", - "F19d35f0cf28b0ddeb93", - "F19d35f0cf285b59be0c", - "F19d35f0cf28de128c9e", - "F19d35f0cf29941cd5c0", - "F19d35f0cf28d26c8ac9", - "F19d35f0cf285bbeaf81", - "F19d35f0cf28124db392" + "1-epolis", + "2-epolis", + "3-epolis", + "4-epolis", + "5-epolis", + "6-epolis", + "7-epolis", + "8-epolis", + "9-epolis", + "10-epolis", + "11-epolis", + "12-epolis" ], "game": "iidx-dp", "id": "T19d35f0d593a7aefd09", @@ -2729,18 +2717,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in GOLD CS.", "folders": [ - "F19d35f0cf280ccb10bb", - "F19d35f0cf28dcbd9e10", - "F19d35f0cf280086ae05", - "F19d35f0cf288b19e736", - "F19d35f0cf28914a10a2", - "F19d35f0cf287b9705f5", - "F19d35f0cf28eeb3ef66", - "F19d35f0cf286fca0dc1", - "F19d35f0cf2985177ae2", - "F19d35f0cf28cc769605", - "F19d35f0cf280cca883f", - "F19d35f0cf28400e064c" + "1-14th-cs", + "2-14th-cs", + "3-14th-cs", + "4-14th-cs", + "5-14th-cs", + "6-14th-cs", + "7-14th-cs", + "8-14th-cs", + "9-14th-cs", + "10-14th-cs", + "11-14th-cs", + "12-14th-cs" ], "game": "iidx-dp", "id": "T19d35f0d5930093da20", @@ -2752,18 +2740,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in HAPPY SKY CS.", "folders": [ - "F19d35f0cf28c94e5411", - "F19d35f0cf28ee8b0d33", - "F19d35f0cf28a1322149", - "F19d35f0cf28e2f515e2", - "F19d35f0cf28c99a3b58", - "F19d35f0cf2892c0ab02", - "F19d35f0cf28d9983e52", - "F19d35f0cf283245d578", - "F19d35f0cf29055abf69", - "F19d35f0cf28d46677d8", - "F19d35f0cf280ceff791", - "F19d35f0cf282722e7be" + "1-12th-cs", + "2-12th-cs", + "3-12th-cs", + "4-12th-cs", + "5-12th-cs", + "6-12th-cs", + "7-12th-cs", + "8-12th-cs", + "9-12th-cs", + "10-12th-cs", + "11-12th-cs", + "12-12th-cs" ], "game": "iidx-dp", "id": "T19d35f0d593ccf6a2be", @@ -2775,18 +2763,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in HEROIC VERSE 2dxtra.", "folders": [ - "F19d35f0cf2882cba4a4", - "F19d35f0cf281a4b1ffd", - "F19d35f0cf28b7293e58", - "F19d35f0cf28784ef031", - "F19d35f0cf282d8f8cea", - "F19d35f0cf28ccb5e6f5", - "F19d35f0cf28a76ef96f", - "F19d35f0cf2897258b57", - "F19d35f0cf295c66977f", - "F19d35f0cf2874c7c390", - "F19d35f0cf28dbc22574", - "F19d35f0cf28c910f5b7" + "1-heroicverse-2dxtra", + "2-heroicverse-2dxtra", + "3-heroicverse-2dxtra", + "4-heroicverse-2dxtra", + "5-heroicverse-2dxtra", + "6-heroicverse-2dxtra", + "7-heroicverse-2dxtra", + "8-heroicverse-2dxtra", + "9-heroicverse-2dxtra", + "10-heroicverse-2dxtra", + "11-heroicverse-2dxtra", + "12-heroicverse-2dxtra" ], "game": "iidx-dp", "id": "T19d35f0d5936372748a", @@ -2798,18 +2786,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in HEROIC VERSE Omnimix.", "folders": [ - "F19d35f0cf2888abc21f", - "F19d35f0cf287ee41f48", - "F19d35f0cf287762960c", - "F19d35f0cf28df7dabeb", - "F19d35f0cf28ecdfc97f", - "F19d35f0cf28ab68256a", - "F19d35f0cf284f9eb298", - "F19d35f0cf2825bc033a", - "F19d35f0cf292ba5bfcd", - "F19d35f0cf28575b6306", - "F19d35f0cf2824a35320", - "F19d35f0cf28f8a2f218" + "1-heroicverse-omni", + "2-heroicverse-omni", + "3-heroicverse-omni", + "4-heroicverse-omni", + "5-heroicverse-omni", + "6-heroicverse-omni", + "7-heroicverse-omni", + "8-heroicverse-omni", + "9-heroicverse-omni", + "10-heroicverse-omni", + "11-heroicverse-omni", + "12-heroicverse-omni" ], "game": "iidx-dp", "id": "T19d35f0d5930435884c", @@ -2821,18 +2809,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in HEROIC VERSE.", "folders": [ - "F19d35f0cf285b798d33", - "F19d35f0cf28c8c9b434", - "F19d35f0cf2856cb86cc", - "F19d35f0cf28afce1004", - "F19d35f0cf28cd39b04a", - "F19d35f0cf289c97fdcd", - "F19d35f0cf28b93aa363", - "F19d35f0cf2812e21e1e", - "F19d35f0cf29562fa566", - "F19d35f0cf28807fb35c", - "F19d35f0cf28d2e7c8f1", - "F19d35f0cf289266728f" + "1-heroicverse", + "2-heroicverse", + "3-heroicverse", + "4-heroicverse", + "5-heroicverse", + "6-heroicverse", + "7-heroicverse", + "8-heroicverse", + "9-heroicverse", + "10-heroicverse", + "11-heroicverse", + "12-heroicverse" ], "game": "iidx-dp", "id": "T19d35f0d593f046d707", @@ -2844,18 +2832,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in IIDX RED CS.", "folders": [ - "F19d35f0cf28d9e7ffcf", - "F19d35f0cf2873a00191", - "F19d35f0cf28a0eb529c", - "F19d35f0cf2808dd6418", - "F19d35f0cf28391dd787", - "F19d35f0cf28b9ee666d", - "F19d35f0cf288f6c4496", - "F19d35f0cf28095a039e", - "F19d35f0cf293e4c1047", - "F19d35f0cf28ebbbcf77", - "F19d35f0cf281b2bd38d", - "F19d35f0cf28789fe9ab" + "1-11th-cs", + "2-11th-cs", + "3-11th-cs", + "4-11th-cs", + "5-11th-cs", + "6-11th-cs", + "7-11th-cs", + "8-11th-cs", + "9-11th-cs", + "10-11th-cs", + "11-11th-cs", + "12-11th-cs" ], "game": "iidx-dp", "id": "T19d35f0d59319916029", @@ -2867,18 +2855,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in INFINITAS.", "folders": [ - "F19d35f0cf28d56b1bbf", - "F19d35f0cf280da6b887", - "F19d35f0cf28f8d70774", - "F19d35f0cf2803fcb7d2", - "F19d35f0cf28c14ed61a", - "F19d35f0cf280bc5772a", - "F19d35f0cf28407920e0", - "F19d35f0cf2807f2a768", - "F19d35f0cf29830a1c76", - "F19d35f0cf286937165c", - "F19d35f0cf2832e5cd12", - "F19d35f0cf286b2bf309" + "1-inf", + "2-inf", + "3-inf", + "4-inf", + "5-inf", + "6-inf", + "7-inf", + "8-inf", + "9-inf", + "10-inf", + "11-inf", + "12-inf" ], "game": "iidx-dp", "id": "T19d35f0d593feee8cc9", @@ -2890,18 +2878,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in PENDUAL.", "folders": [ - "F19d35f0cf2828517fc0", - "F19d35f0cf28dd563a43", - "F19d35f0cf28126dbd03", - "F19d35f0cf280695f176", - "F19d35f0cf28aadc107f", - "F19d35f0cf28c3558858", - "F19d35f0cf2896c83b04", - "F19d35f0cf290406852e", - "F19d35f0cf29ca6f18e8", - "F19d35f0cf28740ab5dc", - "F19d35f0cf281055584b", - "F19d35f0cf28dfc75b54" + "1-pendual", + "2-pendual", + "3-pendual", + "4-pendual", + "5-pendual", + "6-pendual", + "7-pendual", + "8-pendual", + "9-pendual", + "10-pendual", + "11-pendual", + "12-pendual" ], "game": "iidx-dp", "id": "T19d35f0d593921b9772", @@ -2913,18 +2901,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in Pinky Crush Omnimix.", "folders": [ - "F19d35f0cf287fbc6af4", - "F19d35f0cf281df507e2", - "F19d35f0cf2865b5a149", - "F19d35f0cf284e7d51c3", - "F19d35f0cf2847db79c6", - "F19d35f0cf284d0ac18b", - "F19d35f0cf28f1626ba5", - "F19d35f0cf2995391bef", - "F19d35f0cf29585dc895", - "F19d35f0cf283260b63e", - "F19d35f0cf289c8f1970", - "F19d35f0cf2837b4973a" + "1-pinkycrush-omni", + "2-pinkycrush-omni", + "3-pinkycrush-omni", + "4-pinkycrush-omni", + "5-pinkycrush-omni", + "6-pinkycrush-omni", + "7-pinkycrush-omni", + "8-pinkycrush-omni", + "9-pinkycrush-omni", + "10-pinkycrush-omni", + "11-pinkycrush-omni", + "12-pinkycrush-omni" ], "game": "iidx-dp", "id": "T19d35f0d59334223382", @@ -2936,18 +2924,18 @@ "default": true, "description": "Levels for beatmania IIDX (DP) in Pinky Crush.", "folders": [ - "F19d35f0cf281777ee7d", - "F19d35f0cf283836f73c", - "F19d35f0cf28725ddcb5", - "F19d35f0cf281c282eca", - "F19d35f0cf288a09bd20", - "F19d35f0cf280bbff470", - "F19d35f0cf285c14a9c9", - "F19d35f0cf297cd91e6c", - "F19d35f0cf291b6a9c4a", - "F19d35f0cf288585705f", - "F19d35f0cf28985e2faa", - "F19d35f0cf286521bb65" + "1-pinkycrush", + "2-pinkycrush", + "3-pinkycrush", + "4-pinkycrush", + "5-pinkycrush", + "6-pinkycrush", + "7-pinkycrush", + "8-pinkycrush", + "9-pinkycrush", + "10-pinkycrush", + "11-pinkycrush", + "12-pinkycrush" ], "game": "iidx-dp", "id": "T19d35f0d5932c901434", @@ -2959,18 +2947,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in Resident Omnimix.", "folders": [ - "F19d35f0cf28b502370b", - "F19d35f0cf289845d5b6", - "F19d35f0cf28ad02de3b", - "F19d35f0cf28c0246d61", - "F19d35f0cf286719dd5d", - "F19d35f0cf28844263ab", - "F19d35f0cf288cdb385e", - "F19d35f0cf29a7a36d1f", - "F19d35f0cf2914527c69", - "F19d35f0cf2817385056", - "F19d35f0cf285fd8f064", - "F19d35f0cf285d7aa301" + "1-resident-omni", + "2-resident-omni", + "3-resident-omni", + "4-resident-omni", + "5-resident-omni", + "6-resident-omni", + "7-resident-omni", + "8-resident-omni", + "9-resident-omni", + "10-resident-omni", + "11-resident-omni", + "12-resident-omni" ], "game": "iidx-dp", "id": "T19d35f0d593ae860617", @@ -2982,18 +2970,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in Resident.", "folders": [ - "F19d35f0cf2858a487cb", - "F19d35f0cf2862c07b4f", - "F19d35f0cf28cc419507", - "F19d35f0cf280a936b0e", - "F19d35f0cf28b0ff56c9", - "F19d35f0cf28cdf5da43", - "F19d35f0cf28838f3352", - "F19d35f0cf2964f248c8", - "F19d35f0cf29665ff630", - "F19d35f0cf28d90e84fe", - "F19d35f0cf280bfdc82e", - "F19d35f0cf28a7960fa8" + "1-resident", + "2-resident", + "3-resident", + "4-resident", + "5-resident", + "6-resident", + "7-resident", + "8-resident", + "9-resident", + "10-resident", + "11-resident", + "12-resident" ], "game": "iidx-dp", "id": "T19d35f0d5935c3229e2", @@ -3005,18 +2993,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in ROOTAGE Omnimix.", "folders": [ - "F19d35f0cf2817d5540f", - "F19d35f0cf2840b84f3e", - "F19d35f0cf282e704099", - "F19d35f0cf28f717d660", - "F19d35f0cf288f4aba7b", - "F19d35f0cf28808c402f", - "F19d35f0cf2856a34e53", - "F19d35f0cf2925421f9c", - "F19d35f0cf2950230a62", - "F19d35f0cf2829292564", - "F19d35f0cf28db55cb50", - "F19d35f0cf286f5347a2" + "1-rootage-omni", + "2-rootage-omni", + "3-rootage-omni", + "4-rootage-omni", + "5-rootage-omni", + "6-rootage-omni", + "7-rootage-omni", + "8-rootage-omni", + "9-rootage-omni", + "10-rootage-omni", + "11-rootage-omni", + "12-rootage-omni" ], "game": "iidx-dp", "id": "T19d35f0d593f3c9d5bd", @@ -3028,18 +3016,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in ROOTAGE.", "folders": [ - "F19d35f0cf28940deef5", - "F19d35f0cf285fcf43b9", - "F19d35f0cf28406fa07b", - "F19d35f0cf287b4cdf87", - "F19d35f0cf28a45a3fdd", - "F19d35f0cf28953c5042", - "F19d35f0cf287459c20d", - "F19d35f0cf29b31f8936", - "F19d35f0cf293bc6c4eb", - "F19d35f0cf283c2bf5dc", - "F19d35f0cf28702434e7", - "F19d35f0cf28903ba613" + "1-rootage", + "2-rootage", + "3-rootage", + "4-rootage", + "5-rootage", + "6-rootage", + "7-rootage", + "8-rootage", + "9-rootage", + "10-rootage", + "11-rootage", + "12-rootage" ], "game": "iidx-dp", "id": "T19d35f0d5934bb5eaec", @@ -3051,18 +3039,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in SINOBUZ.", "folders": [ - "F19d35f0cf28f4b23445", - "F19d35f0cf282cca54b1", - "F19d35f0cf28a7cc70ce", - "F19d35f0cf28d82dfeff", - "F19d35f0cf28959d2987", - "F19d35f0cf28d8fa1678", - "F19d35f0cf28a7a349a3", - "F19d35f0cf291b2efbb6", - "F19d35f0cf29ba98e7ee", - "F19d35f0cf28c320dd8b", - "F19d35f0cf286c9d4d69", - "F19d35f0cf286a38d5cd" + "1-sinobuz", + "2-sinobuz", + "3-sinobuz", + "4-sinobuz", + "5-sinobuz", + "6-sinobuz", + "7-sinobuz", + "8-sinobuz", + "9-sinobuz", + "10-sinobuz", + "11-sinobuz", + "12-sinobuz" ], "game": "iidx-dp", "id": "T19d35f0d593c1105372", @@ -3074,18 +3062,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in SPADA.", "folders": [ - "F19d35f0cf28c9f7f59e", - "F19d35f0cf2838b0f052", - "F19d35f0cf280ddad7b4", - "F19d35f0cf28a4152b64", - "F19d35f0cf2865e0ccc9", - "F19d35f0cf28231fb794", - "F19d35f0cf28345347f5", - "F19d35f0cf29a1f1005a", - "F19d35f0cf294039e632", - "F19d35f0cf285f406296", - "F19d35f0cf287928ef34", - "F19d35f0cf28586cdc07" + "1-spada", + "2-spada", + "3-spada", + "4-spada", + "5-spada", + "6-spada", + "7-spada", + "8-spada", + "9-spada", + "10-spada", + "11-spada", + "12-spada" ], "game": "iidx-dp", "id": "T19d35f0d5931d5cbfa7", @@ -3097,18 +3085,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in Sparkle Shower.", "folders": [ - "F19d35f0cf28288ded0b", - "F19d35f0cf285fd634c1", - "F19d35f0cf2801baa068", - "F19d35f0cf28963ef37b", - "F19d35f0cf28cf03375d", - "F19d35f0cf286d15b56c", - "F19d35f0cf288435ec5e", - "F19d35f0cf29608e6b04", - "F19d35f0cf2981ddf746", - "F19d35f0cf28f99d9f2c", - "F19d35f0cf28afcba7ba", - "F19d35f0cf285e02f7da" + "1-sparkleshower", + "2-sparkleshower", + "3-sparkleshower", + "4-sparkleshower", + "5-sparkleshower", + "6-sparkleshower", + "7-sparkleshower", + "8-sparkleshower", + "9-sparkleshower", + "10-sparkleshower", + "11-sparkleshower", + "12-sparkleshower" ], "game": "iidx-dp", "id": "T19d35f0d593e330cf7d", @@ -3120,18 +3108,18 @@ "default": false, "description": "Levels for beatmania IIDX (DP) in tricoro.", "folders": [ - "F19d35f0cf28d745571f", - "F19d35f0cf28b54dcac3", - "F19d35f0cf284af2ea6b", - "F19d35f0cf2833213de6", - "F19d35f0cf2843779d5e", - "F19d35f0cf2839a67191", - "F19d35f0cf2896e0b485", - "F19d35f0cf29e7a21daf", - "F19d35f0cf29e723c4f5", - "F19d35f0cf282787297e", - "F19d35f0cf289cdccf0b", - "F19d35f0cf28d6d70d5c" + "1-tricoro", + "2-tricoro", + "3-tricoro", + "4-tricoro", + "5-tricoro", + "6-tricoro", + "7-tricoro", + "8-tricoro", + "9-tricoro", + "10-tricoro", + "11-tricoro", + "12-tricoro" ], "game": "iidx-dp", "id": "T19d35f0d593163f3341", @@ -3143,18 +3131,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in 10th Style CS.", "folders": [ - "F19d35f0cf28c86c77c6", - "F19d35f0cf286dde35eb", - "F19d35f0cf2812a03d4a", - "F19d35f0cf2841229c23", - "F19d35f0cf28576d7391", - "F19d35f0cf28a014c1ed", - "F19d35f0cf28b544a8bb", - "F19d35f0cf2813917733", - "F19d35f0cf29cb2a05fa", - "F19d35f0cf282af899a7", - "F19d35f0cf287a0a72f5", - "F19d35f0cf282482e1d1" + "1-10th-cs", + "2-10th-cs", + "3-10th-cs", + "4-10th-cs", + "5-10th-cs", + "6-10th-cs", + "7-10th-cs", + "8-10th-cs", + "9-10th-cs", + "10-10th-cs", + "11-10th-cs", + "12-10th-cs" ], "game": "iidx-sp", "id": "T19d35f0d59309fd0182", @@ -3166,18 +3154,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in 3rd Style CS.", "folders": [ - "F19d35f0cf282e2e8356", - "F19d35f0cf2879f02cf9", - "F19d35f0cf287ab132e8", - "F19d35f0cf2803790e74", - "F19d35f0cf2823a4bfb6", - "F19d35f0cf2805bb6545", - "F19d35f0cf289e7a120b", - "F19d35f0cf287356a727", - "F19d35f0cf29d78794f8", - "F19d35f0cf286a2e3ee7", - "F19d35f0cf28ae5e3a25", - "F19d35f0cf285ec6bc6b" + "1-3rd-cs", + "2-3rd-cs", + "3-3rd-cs", + "4-3rd-cs", + "5-3rd-cs", + "6-3rd-cs", + "7-3rd-cs", + "8-3rd-cs", + "9-3rd-cs", + "10-3rd-cs", + "11-3rd-cs", + "12-3rd-cs" ], "game": "iidx-sp", "id": "T19d35f0d593840f333f", @@ -3189,18 +3177,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in 4th Style CS.", "folders": [ - "F19d35f0cf289e211a26", - "F19d35f0cf28951162cf", - "F19d35f0cf282609ccde", - "F19d35f0cf289aa14667", - "F19d35f0cf28c792be27", - "F19d35f0cf28a0e03802", - "F19d35f0cf281627dd5f", - "F19d35f0cf28c667d302", - "F19d35f0cf294ae36171", - "F19d35f0cf287362e450", - "F19d35f0cf28c493e4da", - "F19d35f0cf2850f08440" + "1-4th-cs", + "2-4th-cs", + "3-4th-cs", + "4-4th-cs", + "5-4th-cs", + "6-4th-cs", + "7-4th-cs", + "8-4th-cs", + "9-4th-cs", + "10-4th-cs", + "11-4th-cs", + "12-4th-cs" ], "game": "iidx-sp", "id": "T19d35f0d5935695697f", @@ -3212,18 +3200,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in 5th Style CS.", "folders": [ - "F19d35f0cf28d772eaf0", - "F19d35f0cf28f911e140", - "F19d35f0cf283e5913d3", - "F19d35f0cf289e412681", - "F19d35f0cf28d28bfab0", - "F19d35f0cf28d50b9581", - "F19d35f0cf289818906d", - "F19d35f0cf28c93623dc", - "F19d35f0cf2946a6560a", - "F19d35f0cf28137105bc", - "F19d35f0cf28990273e4", - "F19d35f0cf285276bd07" + "1-5th-cs", + "2-5th-cs", + "3-5th-cs", + "4-5th-cs", + "5-5th-cs", + "6-5th-cs", + "7-5th-cs", + "8-5th-cs", + "9-5th-cs", + "10-5th-cs", + "11-5th-cs", + "12-5th-cs" ], "game": "iidx-sp", "id": "T19d35f0d5939aa0abe3", @@ -3235,18 +3223,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in 6th Style CS.", "folders": [ - "F19d35f0cf28a33a8b0f", - "F19d35f0cf28f3f43fec", - "F19d35f0cf28f9d95917", - "F19d35f0cf2887b969d6", - "F19d35f0cf28f4ca52c0", - "F19d35f0cf28804bfb7b", - "F19d35f0cf287cbfc6f4", - "F19d35f0cf2874c5ce61", - "F19d35f0cf29115933a1", - "F19d35f0cf285635b63c", - "F19d35f0cf2879a304a4", - "F19d35f0cf2892419658" + "1-6th-cs", + "2-6th-cs", + "3-6th-cs", + "4-6th-cs", + "5-6th-cs", + "6-6th-cs", + "7-6th-cs", + "8-6th-cs", + "9-6th-cs", + "10-6th-cs", + "11-6th-cs", + "12-6th-cs" ], "game": "iidx-sp", "id": "T19d35f0d5935bd76b46", @@ -3258,18 +3246,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in 7th Style CS.", "folders": [ - "F19d35f0cf28c0103096", - "F19d35f0cf2807ef5f63", - "F19d35f0cf289a5c5944", - "F19d35f0cf2820db69d8", - "F19d35f0cf28af805591", - "F19d35f0cf28abe04904", - "F19d35f0cf288fac76b8", - "F19d35f0cf28c1b463a1", - "F19d35f0cf294f23f10d", - "F19d35f0cf28126525b1", - "F19d35f0cf28be953b46", - "F19d35f0cf28ea9e3ad6" + "1-7th-cs", + "2-7th-cs", + "3-7th-cs", + "4-7th-cs", + "5-7th-cs", + "6-7th-cs", + "7-7th-cs", + "8-7th-cs", + "9-7th-cs", + "10-7th-cs", + "11-7th-cs", + "12-7th-cs" ], "game": "iidx-sp", "id": "T19d35f0d593aa2bdab0", @@ -3281,18 +3269,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in 8th Style CS.", "folders": [ - "F19d35f0cf283795d60a", - "F19d35f0cf2815577cba", - "F19d35f0cf28f9f1ff90", - "F19d35f0cf2897e21ea5", - "F19d35f0cf28ce2b1cc7", - "F19d35f0cf28b11066f2", - "F19d35f0cf288cb1dd95", - "F19d35f0cf2898aa59e5", - "F19d35f0cf29a1550716", - "F19d35f0cf2821e57f53", - "F19d35f0cf283538ae07", - "F19d35f0cf282db24b25" + "1-8th-cs", + "2-8th-cs", + "3-8th-cs", + "4-8th-cs", + "5-8th-cs", + "6-8th-cs", + "7-8th-cs", + "8-8th-cs", + "9-8th-cs", + "10-8th-cs", + "11-8th-cs", + "12-8th-cs" ], "game": "iidx-sp", "id": "T19d35f0d593725bc772", @@ -3304,18 +3292,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in 9th Style CS.", "folders": [ - "F19d35f0cf2801c7e908", - "F19d35f0cf289531fbf0", - "F19d35f0cf28c73cbecf", - "F19d35f0cf28174322f2", - "F19d35f0cf28e94a2a07", - "F19d35f0cf28816a11e5", - "F19d35f0cf283ada6ccd", - "F19d35f0cf281b64e9b2", - "F19d35f0cf2961edfc47", - "F19d35f0cf28687cf914", - "F19d35f0cf286b45cdc1", - "F19d35f0cf2865f9daa0" + "1-9th-cs", + "2-9th-cs", + "3-9th-cs", + "4-9th-cs", + "5-9th-cs", + "6-9th-cs", + "7-9th-cs", + "8-9th-cs", + "9-9th-cs", + "10-9th-cs", + "11-9th-cs", + "12-9th-cs" ], "game": "iidx-sp", "id": "T19d35f0d59325471a54", @@ -3327,18 +3315,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in BEATMANIA US.", "folders": [ - "F19d35f0cf28a8222c00", - "F19d35f0cf287123a06a", - "F19d35f0cf28a5115022", - "F19d35f0cf284218d09f", - "F19d35f0cf2851eb24ad", - "F19d35f0cf28ae2fd471", - "F19d35f0cf28bb5b8652", - "F19d35f0cf2848ec9c99", - "F19d35f0cf290bfbba79", - "F19d35f0cf2856eea685", - "F19d35f0cf28ffb0765d", - "F19d35f0cf2819bfb67e" + "1-bmus", + "2-bmus", + "3-bmus", + "4-bmus", + "5-bmus", + "6-bmus", + "7-bmus", + "8-bmus", + "9-bmus", + "10-bmus", + "11-bmus", + "12-bmus" ], "game": "iidx-sp", "id": "T19d35f0d5939db1100f", @@ -3350,18 +3338,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in BISTROVER Omnimix.", "folders": [ - "F19d35f0cf28f6bcce72", - "F19d35f0cf28e20e8df2", - "F19d35f0cf28a794309e", - "F19d35f0cf28cda0b6da", - "F19d35f0cf28957e49a3", - "F19d35f0cf28bee53364", - "F19d35f0cf2834a49e37", - "F19d35f0cf28f24d62c7", - "F19d35f0cf298650d713", - "F19d35f0cf28d889eccb", - "F19d35f0cf2807409bd0", - "F19d35f0cf282b6991d8" + "1-bistrover-omni", + "2-bistrover-omni", + "3-bistrover-omni", + "4-bistrover-omni", + "5-bistrover-omni", + "6-bistrover-omni", + "7-bistrover-omni", + "8-bistrover-omni", + "9-bistrover-omni", + "10-bistrover-omni", + "11-bistrover-omni", + "12-bistrover-omni" ], "game": "iidx-sp", "id": "T19d35f0d593d7029385", @@ -3373,18 +3361,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in BISTROVER.", "folders": [ - "F19d35f0cf287f08b913", - "F19d35f0cf28dc64c800", - "F19d35f0cf281abed7bd", - "F19d35f0cf28bb902fc3", - "F19d35f0cf287f0415be", - "F19d35f0cf2824b962bd", - "F19d35f0cf28221b7a74", - "F19d35f0cf28e87832f4", - "F19d35f0cf29de985f46", - "F19d35f0cf288321f192", - "F19d35f0cf282d2aba17", - "F19d35f0cf280fbd212b" + "1-bistrover", + "2-bistrover", + "3-bistrover", + "4-bistrover", + "5-bistrover", + "6-bistrover", + "7-bistrover", + "8-bistrover", + "9-bistrover", + "10-bistrover", + "11-bistrover", + "12-bistrover" ], "game": "iidx-sp", "id": "T19d35f0d593e5999888", @@ -3396,18 +3384,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in CANNON BALLERS.", "folders": [ - "F19d35f0cf28882deec2", - "F19d35f0cf285de917aa", - "F19d35f0cf28fecadc3b", - "F19d35f0cf2811f87ab0", - "F19d35f0cf28f3208b35", - "F19d35f0cf289e57e7a3", - "F19d35f0cf28902e2f6a", - "F19d35f0cf28fdfb8656", - "F19d35f0cf2950442859", - "F19d35f0cf28f266e718", - "F19d35f0cf28992f6692", - "F19d35f0cf283b7db925" + "1-cannonballers", + "2-cannonballers", + "3-cannonballers", + "4-cannonballers", + "5-cannonballers", + "6-cannonballers", + "7-cannonballers", + "8-cannonballers", + "9-cannonballers", + "10-cannonballers", + "11-cannonballers", + "12-cannonballers" ], "game": "iidx-sp", "id": "T19d35f0d593dec3d695", @@ -3419,18 +3407,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in CastHour Omnimix.", "folders": [ - "F19d35f0cf2883c2da94", - "F19d35f0cf28ff3c16c5", - "F19d35f0cf28f3131cc1", - "F19d35f0cf28b7356877", - "F19d35f0cf2825f417b6", - "F19d35f0cf28af40b020", - "F19d35f0cf282118358f", - "F19d35f0cf280293fe79", - "F19d35f0cf295bf5047c", - "F19d35f0cf28b092e29b", - "F19d35f0cf281bce0480", - "F19d35f0cf28ea89f4ef" + "1-casthour-omni", + "2-casthour-omni", + "3-casthour-omni", + "4-casthour-omni", + "5-casthour-omni", + "6-casthour-omni", + "7-casthour-omni", + "8-casthour-omni", + "9-casthour-omni", + "10-casthour-omni", + "11-casthour-omni", + "12-casthour-omni" ], "game": "iidx-sp", "id": "T19d35f0d593abda9c44", @@ -3442,18 +3430,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in CastHour.", "folders": [ - "F19d35f0cf28a19c6ba5", - "F19d35f0cf289c6d2bf4", - "F19d35f0cf28a00fb455", - "F19d35f0cf28a42f1122", - "F19d35f0cf281519d5a2", - "F19d35f0cf28e101fa79", - "F19d35f0cf28cdc47ae9", - "F19d35f0cf28bb80c00a", - "F19d35f0cf29a9db88d4", - "F19d35f0cf283cdfb15a", - "F19d35f0cf28bc354ac6", - "F19d35f0cf28a4492ffe" + "1-casthour", + "2-casthour", + "3-casthour", + "4-casthour", + "5-casthour", + "6-casthour", + "7-casthour", + "8-casthour", + "9-casthour", + "10-casthour", + "11-casthour", + "12-casthour" ], "game": "iidx-sp", "id": "T19d35f0d593449e96cc", @@ -3465,18 +3453,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in copula.", "folders": [ - "F19d35f0cf284cdda3d7", - "F19d35f0cf2839e3f8da", - "F19d35f0cf28572d65a6", - "F19d35f0cf282b8a474b", - "F19d35f0cf284b57ffc0", - "F19d35f0cf28ce812f64", - "F19d35f0cf28036c8d67", - "F19d35f0cf28596be79f", - "F19d35f0cf29647b44dd", - "F19d35f0cf2816774edb", - "F19d35f0cf28d9d1ff32", - "F19d35f0cf2888e8ba20" + "1-copula", + "2-copula", + "3-copula", + "4-copula", + "5-copula", + "6-copula", + "7-copula", + "8-copula", + "9-copula", + "10-copula", + "11-copula", + "12-copula" ], "game": "iidx-sp", "id": "T19d35f0d593558d6086", @@ -3488,18 +3476,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in DISTORTED CS.", "folders": [ - "F19d35f0cf283ad1dce5", - "F19d35f0cf280e3c494f", - "F19d35f0cf2891312e37", - "F19d35f0cf28abaf2187", - "F19d35f0cf28041943bc", - "F19d35f0cf2851015d34", - "F19d35f0cf2824ce530f", - "F19d35f0cf286382b4c0", - "F19d35f0cf2907c0c6d1", - "F19d35f0cf28a06cf51f", - "F19d35f0cf28394e08ab", - "F19d35f0cf2843643809" + "1-13th-cs", + "2-13th-cs", + "3-13th-cs", + "4-13th-cs", + "5-13th-cs", + "6-13th-cs", + "7-13th-cs", + "8-13th-cs", + "9-13th-cs", + "10-13th-cs", + "11-13th-cs", + "12-13th-cs" ], "game": "iidx-sp", "id": "T19d35f0d59317e5584d", @@ -3511,18 +3499,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in DJ TROOPERS CS.", "folders": [ - "F19d35f0cf28c3b44713", - "F19d35f0cf2803ad1c39", - "F19d35f0cf28f0f07901", - "F19d35f0cf28360ad52a", - "F19d35f0cf2827d7dacf", - "F19d35f0cf2877fb132b", - "F19d35f0cf28e7f4c3ac", - "F19d35f0cf2809a5af25", - "F19d35f0cf29a39af01a", - "F19d35f0cf28ddb3f611", - "F19d35f0cf28bbad500f", - "F19d35f0cf28c67948ab" + "1-15th-cs", + "2-15th-cs", + "3-15th-cs", + "4-15th-cs", + "5-15th-cs", + "6-15th-cs", + "7-15th-cs", + "8-15th-cs", + "9-15th-cs", + "10-15th-cs", + "11-15th-cs", + "12-15th-cs" ], "game": "iidx-sp", "id": "T19d35f0d59356231d36", @@ -3534,18 +3522,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in EMPRESS CS.", "folders": [ - "F19d35f0cf2845509287", - "F19d35f0cf28a9f5a348", - "F19d35f0cf28dcacb317", - "F19d35f0cf28fd390ad8", - "F19d35f0cf289f13c8ee", - "F19d35f0cf283717b722", - "F19d35f0cf288437e0e0", - "F19d35f0cf284ecd122e", - "F19d35f0cf294ce88bca", - "F19d35f0cf285ee2dabd", - "F19d35f0cf28ac129524", - "F19d35f0cf28e9e5b19d" + "1-16th-cs", + "2-16th-cs", + "3-16th-cs", + "4-16th-cs", + "5-16th-cs", + "6-16th-cs", + "7-16th-cs", + "8-16th-cs", + "9-16th-cs", + "10-16th-cs", + "11-16th-cs", + "12-16th-cs" ], "game": "iidx-sp", "id": "T19d35f0d593bf014fc2", @@ -3557,18 +3545,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in Epolis Omnimix.", "folders": [ - "F19d35f0cf2830187718", - "F19d35f0cf283ba61311", - "F19d35f0cf28bcad5c74", - "F19d35f0cf28f0e947a3", - "F19d35f0cf288bd136d2", - "F19d35f0cf28bc6dbbec", - "F19d35f0cf282796b29f", - "F19d35f0cf28c12b9b11", - "F19d35f0cf29df33404b", - "F19d35f0cf28703adf14", - "F19d35f0cf287c080f67", - "F19d35f0cf28f0437237" + "1-epolis-omni", + "2-epolis-omni", + "3-epolis-omni", + "4-epolis-omni", + "5-epolis-omni", + "6-epolis-omni", + "7-epolis-omni", + "8-epolis-omni", + "9-epolis-omni", + "10-epolis-omni", + "11-epolis-omni", + "12-epolis-omni" ], "game": "iidx-sp", "id": "T19d35f0d593992931bb", @@ -3580,18 +3568,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in Epolis.", "folders": [ - "F19d35f0cf28e018ee35", - "F19d35f0cf2871b84700", - "F19d35f0cf28ae6abd81", - "F19d35f0cf28c0399fa9", - "F19d35f0cf28316f97b2", - "F19d35f0cf286aa7c535", - "F19d35f0cf2840b4989c", - "F19d35f0cf2891ccc942", - "F19d35f0cf29f7c52716", - "F19d35f0cf2814810b70", - "F19d35f0cf28aaf858fb", - "F19d35f0cf28ab94f33b" + "1-epolis", + "2-epolis", + "3-epolis", + "4-epolis", + "5-epolis", + "6-epolis", + "7-epolis", + "8-epolis", + "9-epolis", + "10-epolis", + "11-epolis", + "12-epolis" ], "game": "iidx-sp", "id": "T19d35f0d593c11256b6", @@ -3603,18 +3591,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in GOLD CS.", "folders": [ - "F19d35f0cf28567f2395", - "F19d35f0cf283683a707", - "F19d35f0cf28de7ef36f", - "F19d35f0cf28625c26ef", - "F19d35f0cf28c0bbf956", - "F19d35f0cf28cc42d164", - "F19d35f0cf28f692e3d1", - "F19d35f0cf28da07efc0", - "F19d35f0cf29e2e38ccf", - "F19d35f0cf2871908aba", - "F19d35f0cf28c279ce0d", - "F19d35f0cf28c8b583fe" + "1-14th-cs", + "2-14th-cs", + "3-14th-cs", + "4-14th-cs", + "5-14th-cs", + "6-14th-cs", + "7-14th-cs", + "8-14th-cs", + "9-14th-cs", + "10-14th-cs", + "11-14th-cs", + "12-14th-cs" ], "game": "iidx-sp", "id": "T19d35f0d5939f3b57ab", @@ -3626,18 +3614,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in HAPPY SKY CS.", "folders": [ - "F19d35f0cf28d9e7b5bb", - "F19d35f0cf2813ed4bc4", - "F19d35f0cf287ddfca31", - "F19d35f0cf289ff31ae8", - "F19d35f0cf287baaf69d", - "F19d35f0cf281c15b5c8", - "F19d35f0cf282f16a3dc", - "F19d35f0cf28019da118", - "F19d35f0cf295b210ca7", - "F19d35f0cf2874b0b3de", - "F19d35f0cf2853ef1b87", - "F19d35f0cf2854e55f4f" + "1-12th-cs", + "2-12th-cs", + "3-12th-cs", + "4-12th-cs", + "5-12th-cs", + "6-12th-cs", + "7-12th-cs", + "8-12th-cs", + "9-12th-cs", + "10-12th-cs", + "11-12th-cs", + "12-12th-cs" ], "game": "iidx-sp", "id": "T19d35f0d593cd40c3ea", @@ -3649,18 +3637,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in HEROIC VERSE 2dxtra.", "folders": [ - "F19d35f0cf28a0ffbc70", - "F19d35f0cf28525e64bb", - "F19d35f0cf280a802090", - "F19d35f0cf28c3f08540", - "F19d35f0cf281a163a58", - "F19d35f0cf286a8af2da", - "F19d35f0cf2809e0a423", - "F19d35f0cf28367a18dd", - "F19d35f0cf29993e5851", - "F19d35f0cf28a18feb26", - "F19d35f0cf28d7305256", - "F19d35f0cf28b90af52b" + "1-heroicverse-2dxtra", + "2-heroicverse-2dxtra", + "3-heroicverse-2dxtra", + "4-heroicverse-2dxtra", + "5-heroicverse-2dxtra", + "6-heroicverse-2dxtra", + "7-heroicverse-2dxtra", + "8-heroicverse-2dxtra", + "9-heroicverse-2dxtra", + "10-heroicverse-2dxtra", + "11-heroicverse-2dxtra", + "12-heroicverse-2dxtra" ], "game": "iidx-sp", "id": "T19d35f0d593c63ce3e8", @@ -3672,18 +3660,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in HEROIC VERSE Omnimix.", "folders": [ - "F19d35f0cf28ec3dcdc6", - "F19d35f0cf28a1d2ee5a", - "F19d35f0cf28ef61a223", - "F19d35f0cf2842e64dcf", - "F19d35f0cf289dab907a", - "F19d35f0cf28574e2f4c", - "F19d35f0cf283a6629b2", - "F19d35f0cf28471ec185", - "F19d35f0cf2959a3ec65", - "F19d35f0cf280a55bec8", - "F19d35f0cf2800f3ecf5", - "F19d35f0cf2831c19164" + "1-heroicverse-omni", + "2-heroicverse-omni", + "3-heroicverse-omni", + "4-heroicverse-omni", + "5-heroicverse-omni", + "6-heroicverse-omni", + "7-heroicverse-omni", + "8-heroicverse-omni", + "9-heroicverse-omni", + "10-heroicverse-omni", + "11-heroicverse-omni", + "12-heroicverse-omni" ], "game": "iidx-sp", "id": "T19d35f0d593bcb2d93a", @@ -3695,18 +3683,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in HEROIC VERSE.", "folders": [ - "F19d35f0cf28706b3fdb", - "F19d35f0cf28f43793bb", - "F19d35f0cf283e538263", - "F19d35f0cf28c3c92a74", - "F19d35f0cf28a91144c3", - "F19d35f0cf285e25cec9", - "F19d35f0cf289043570e", - "F19d35f0cf286145842c", - "F19d35f0cf29f1ecc8fd", - "F19d35f0cf2831e1a7ed", - "F19d35f0cf284c43f073", - "F19d35f0cf2865f9f172" + "1-heroicverse", + "2-heroicverse", + "3-heroicverse", + "4-heroicverse", + "5-heroicverse", + "6-heroicverse", + "7-heroicverse", + "8-heroicverse", + "9-heroicverse", + "10-heroicverse", + "11-heroicverse", + "12-heroicverse" ], "game": "iidx-sp", "id": "T19d35f0d593245864a5", @@ -3718,18 +3706,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in IIDX RED CS.", "folders": [ - "F19d35f0cf28cfddb6cd", - "F19d35f0cf289c14417f", - "F19d35f0cf280a643c55", - "F19d35f0cf28e7fbbd0a", - "F19d35f0cf2804575a57", - "F19d35f0cf28071610ac", - "F19d35f0cf28c6918c0c", - "F19d35f0cf28f8e32c37", - "F19d35f0cf29a877eeab", - "F19d35f0cf28089e9b07", - "F19d35f0cf285f2f99c3", - "F19d35f0cf28ea2cf80d" + "1-11th-cs", + "2-11th-cs", + "3-11th-cs", + "4-11th-cs", + "5-11th-cs", + "6-11th-cs", + "7-11th-cs", + "8-11th-cs", + "9-11th-cs", + "10-11th-cs", + "11-11th-cs", + "12-11th-cs" ], "game": "iidx-sp", "id": "T19d35f0d5931ad9837a", @@ -3741,18 +3729,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in INFINITAS.", "folders": [ - "F19d35f0cf28790a417c", - "F19d35f0cf2885e4f833", - "F19d35f0cf28705fd411", - "F19d35f0cf28248fd78e", - "F19d35f0cf2807269455", - "F19d35f0cf28d8604a63", - "F19d35f0cf283e539308", - "F19d35f0cf299f0de081", - "F19d35f0cf290415cb02", - "F19d35f0cf28ccfb3b27", - "F19d35f0cf2841d2a257", - "F19d35f0cf28406595a5" + "1-inf", + "2-inf", + "3-inf", + "4-inf", + "5-inf", + "6-inf", + "7-inf", + "8-inf", + "9-inf", + "10-inf", + "11-inf", + "12-inf" ], "game": "iidx-sp", "id": "T19d35f0d5937f759deb", @@ -3764,18 +3752,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in PENDUAL.", "folders": [ - "F19d35f0cf28f89727ad", - "F19d35f0cf286e7a880f", - "F19d35f0cf28c59f1670", - "F19d35f0cf2815340596", - "F19d35f0cf28a927e77a", - "F19d35f0cf28e0ac4377", - "F19d35f0cf28a4926ef8", - "F19d35f0cf296fb353f6", - "F19d35f0cf29ccaf50d3", - "F19d35f0cf2867013003", - "F19d35f0cf281ac83498", - "F19d35f0cf283d08983f" + "1-pendual", + "2-pendual", + "3-pendual", + "4-pendual", + "5-pendual", + "6-pendual", + "7-pendual", + "8-pendual", + "9-pendual", + "10-pendual", + "11-pendual", + "12-pendual" ], "game": "iidx-sp", "id": "T19d35f0d593d9626d30", @@ -3787,18 +3775,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in Pinky Crush Omnimix.", "folders": [ - "F19d35f0cf28162be6be", - "F19d35f0cf28bda39e1a", - "F19d35f0cf28209a956d", - "F19d35f0cf288c636431", - "F19d35f0cf2824cb9012", - "F19d35f0cf2804773d70", - "F19d35f0cf283e1b6edd", - "F19d35f0cf29222f1e69", - "F19d35f0cf29fc0742f7", - "F19d35f0cf2803a318ff", - "F19d35f0cf285baa0c78", - "F19d35f0cf280f65d2f6" + "1-pinkycrush-omni", + "2-pinkycrush-omni", + "3-pinkycrush-omni", + "4-pinkycrush-omni", + "5-pinkycrush-omni", + "6-pinkycrush-omni", + "7-pinkycrush-omni", + "8-pinkycrush-omni", + "9-pinkycrush-omni", + "10-pinkycrush-omni", + "11-pinkycrush-omni", + "12-pinkycrush-omni" ], "game": "iidx-sp", "id": "T19d35f0d5939810c007", @@ -3810,18 +3798,18 @@ "default": true, "description": "Levels for beatmania IIDX (SP) in Pinky Crush.", "folders": [ - "F19d35f0cf28f0963ad3", - "F19d35f0cf28562bf6df", - "F19d35f0cf282d5df334", - "F19d35f0cf286aaf0a3c", - "F19d35f0cf28d3e18db6", - "F19d35f0cf28c290f8d1", - "F19d35f0cf284a715025", - "F19d35f0cf2926cdc5f6", - "F19d35f0cf29d4d938b0", - "F19d35f0cf2842f15c7f", - "F19d35f0cf28a166573c", - "F19d35f0cf2808cdc420" + "1-pinkycrush", + "2-pinkycrush", + "3-pinkycrush", + "4-pinkycrush", + "5-pinkycrush", + "6-pinkycrush", + "7-pinkycrush", + "8-pinkycrush", + "9-pinkycrush", + "10-pinkycrush", + "11-pinkycrush", + "12-pinkycrush" ], "game": "iidx-sp", "id": "T19d35f0d5931645c1f5", @@ -3833,18 +3821,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in Resident Omnimix.", "folders": [ - "F19d35f0cf28675a2b86", - "F19d35f0cf281fbe20ed", - "F19d35f0cf28c7200216", - "F19d35f0cf28847b0170", - "F19d35f0cf2848e0398f", - "F19d35f0cf28a32a9768", - "F19d35f0cf28a459d36a", - "F19d35f0cf29512a0c43", - "F19d35f0cf292ab6146c", - "F19d35f0cf28bcd5e12a", - "F19d35f0cf28d971eb5e", - "F19d35f0cf2859680cfe" + "1-resident-omni", + "2-resident-omni", + "3-resident-omni", + "4-resident-omni", + "5-resident-omni", + "6-resident-omni", + "7-resident-omni", + "8-resident-omni", + "9-resident-omni", + "10-resident-omni", + "11-resident-omni", + "12-resident-omni" ], "game": "iidx-sp", "id": "T19d35f0d59350008a92", @@ -3856,18 +3844,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in Resident.", "folders": [ - "F19d35f0cf28a5e1ce39", - "F19d35f0cf28a8642541", - "F19d35f0cf2850036e2c", - "F19d35f0cf288daec8ce", - "F19d35f0cf28bee07383", - "F19d35f0cf28e5b48cff", - "F19d35f0cf284cb7fb8a", - "F19d35f0cf29e86a3f61", - "F19d35f0cf294229791e", - "F19d35f0cf2861b8e72d", - "F19d35f0cf28272182b3", - "F19d35f0cf28b559bb2d" + "1-resident", + "2-resident", + "3-resident", + "4-resident", + "5-resident", + "6-resident", + "7-resident", + "8-resident", + "9-resident", + "10-resident", + "11-resident", + "12-resident" ], "game": "iidx-sp", "id": "T19d35f0d593a46a45ab", @@ -3879,18 +3867,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in ROOTAGE Omnimix.", "folders": [ - "F19d35f0cf28670b082c", - "F19d35f0cf2870d87c9e", - "F19d35f0cf28f7c59359", - "F19d35f0cf28834b795a", - "F19d35f0cf28c04a6652", - "F19d35f0cf2811199b65", - "F19d35f0cf287884140e", - "F19d35f0cf299462bcd8", - "F19d35f0cf29ed3f3546", - "F19d35f0cf28cf2f865a", - "F19d35f0cf28853c5d21", - "F19d35f0cf28a00dda43" + "1-rootage-omni", + "2-rootage-omni", + "3-rootage-omni", + "4-rootage-omni", + "5-rootage-omni", + "6-rootage-omni", + "7-rootage-omni", + "8-rootage-omni", + "9-rootage-omni", + "10-rootage-omni", + "11-rootage-omni", + "12-rootage-omni" ], "game": "iidx-sp", "id": "T19d35f0d5934f9cf58f", @@ -3902,18 +3890,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in ROOTAGE.", "folders": [ - "F19d35f0cf28ff349517", - "F19d35f0cf285a888737", - "F19d35f0cf28f900d3fb", - "F19d35f0cf2809fd5877", - "F19d35f0cf28852f224f", - "F19d35f0cf28628a6795", - "F19d35f0cf2880157551", - "F19d35f0cf2956bce0e4", - "F19d35f0cf29fa359df3", - "F19d35f0cf286ac9b7c6", - "F19d35f0cf2815d3f990", - "F19d35f0cf28af1d33d0" + "1-rootage", + "2-rootage", + "3-rootage", + "4-rootage", + "5-rootage", + "6-rootage", + "7-rootage", + "8-rootage", + "9-rootage", + "10-rootage", + "11-rootage", + "12-rootage" ], "game": "iidx-sp", "id": "T19d35f0d5935350fcfe", @@ -3925,18 +3913,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in SINOBUZ.", "folders": [ - "F19d35f0cf284ca72414", - "F19d35f0cf28021cf818", - "F19d35f0cf28f4ac5c1a", - "F19d35f0cf2878c26f7e", - "F19d35f0cf286d16d5d0", - "F19d35f0cf28f9f22019", - "F19d35f0cf286468feac", - "F19d35f0cf2990c2ea81", - "F19d35f0cf297d456164", - "F19d35f0cf2830079ae4", - "F19d35f0cf285cacc59c", - "F19d35f0cf28492462f2" + "1-sinobuz", + "2-sinobuz", + "3-sinobuz", + "4-sinobuz", + "5-sinobuz", + "6-sinobuz", + "7-sinobuz", + "8-sinobuz", + "9-sinobuz", + "10-sinobuz", + "11-sinobuz", + "12-sinobuz" ], "game": "iidx-sp", "id": "T19d35f0d593f0123119", @@ -3948,18 +3936,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in SPADA.", "folders": [ - "F19d35f0cf28e3508d60", - "F19d35f0cf28bbafe321", - "F19d35f0cf2839271a09", - "F19d35f0cf28df5b4374", - "F19d35f0cf28032bfc3d", - "F19d35f0cf28a7e96a9f", - "F19d35f0cf28e3cb5da3", - "F19d35f0cf29a5cdc9cf", - "F19d35f0cf29bfbfc08f", - "F19d35f0cf287e606dbb", - "F19d35f0cf2870352cb3", - "F19d35f0cf28296bb70a" + "1-spada", + "2-spada", + "3-spada", + "4-spada", + "5-spada", + "6-spada", + "7-spada", + "8-spada", + "9-spada", + "10-spada", + "11-spada", + "12-spada" ], "game": "iidx-sp", "id": "T19d35f0d59338ba5e6f", @@ -3971,18 +3959,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in Sparkle Shower.", "folders": [ - "F19d35f0cf28a313ed1c", - "F19d35f0cf283ea138ab", - "F19d35f0cf28151ae96c", - "F19d35f0cf2875fb39a5", - "F19d35f0cf285d63e55c", - "F19d35f0cf28bbb37771", - "F19d35f0cf28d5300776", - "F19d35f0cf29d471955d", - "F19d35f0cf29317b186a", - "F19d35f0cf28a349de50", - "F19d35f0cf28019c0792", - "F19d35f0cf28086c917a" + "1-sparkleshower", + "2-sparkleshower", + "3-sparkleshower", + "4-sparkleshower", + "5-sparkleshower", + "6-sparkleshower", + "7-sparkleshower", + "8-sparkleshower", + "9-sparkleshower", + "10-sparkleshower", + "11-sparkleshower", + "12-sparkleshower" ], "game": "iidx-sp", "id": "T19d35f0d593180090d4", @@ -3994,18 +3982,18 @@ "default": false, "description": "Levels for beatmania IIDX (SP) in tricoro.", "folders": [ - "F19d35f0cf286669b062", - "F19d35f0cf28e5594cb2", - "F19d35f0cf282993e44b", - "F19d35f0cf28dbbefbe8", - "F19d35f0cf28dbf5f7b6", - "F19d35f0cf280be6dd75", - "F19d35f0cf28e511ac85", - "F19d35f0cf2919249563", - "F19d35f0cf297c553464", - "F19d35f0cf283ea55811", - "F19d35f0cf28a4329810", - "F19d35f0cf28f67c5b0f" + "1-tricoro", + "2-tricoro", + "3-tricoro", + "4-tricoro", + "5-tricoro", + "6-tricoro", + "7-tricoro", + "8-tricoro", + "9-tricoro", + "10-tricoro", + "11-tricoro", + "12-tricoro" ], "game": "iidx-sp", "id": "T19d35f0d5930963408b", @@ -4017,45 +4005,45 @@ "default": false, "description": "All charts for ITG. These may not be accurately rated.", "folders": [ - "F19d35f0cf294104d747", - "F19d35f0cf29f87ee702", - "F19d35f0cf29079fc3ae", - "F19d35f0cf2958308fc9", - "F19d35f0cf29a26290c1", - "F19d35f0cf29c24b8781", - "F19d35f0cf297cfafdf8", - "F19d35f0cf297e049680", - "F19d35f0cf291068235a", - "F19d35f0cf291f2b6583", - "F19d35f0cf29e1fe700b", - "F19d35f0cf29e9bcdf06", - "F19d35f0cf29127a2d0c", - "F19d35f0cf2920ae38b9", - "F19d35f0cf296889f73b", - "F19d35f0cf293e205907", - "F19d35f0cf299d6cd21a", - "F19d35f0cf295fcee9ae", - "F19d35f0cf292a12c636", - "F19d35f0cf291bc204de", - "F19d35f0cf29c52e2ae8", - "F19d35f0cf2945c431c1", - "F19d35f0cf292bbacdd1", - "F19d35f0cf29372c8eba", - "F19d35f0cf294bbbf590", - "F19d35f0cf29411cbfd6", - "F19d35f0cf29541518db", - "F19d35f0cf2972b32a77", - "F19d35f0cf2914e16461", - "F19d35f0cf29f08476ba", - "F19d35f0cf294b9475aa", - "F19d35f0cf294f89743e", - "F19d35f0cf2947527996", - "F19d35f0cf296696b01a", - "F19d35f0cf299bc03350", - "F19d35f0cf29f7b0c7bc", - "F19d35f0cf29e8f4c630", - "F19d35f0cf29c90e0b7f", - "F19d35f0cf29b228b0da" + "level-1-w-unranked", + "level-2-w-unranked", + "level-3-w-unranked", + "level-4-w-unranked", + "level-5-w-unranked", + "level-6-w-unranked", + "level-7-w-unranked", + "level-8-w-unranked", + "level-9-w-unranked", + "level-10-w-unranked", + "level-11-w-unranked", + "level-12-w-unranked", + "level-13-w-unranked", + "level-14-w-unranked", + "level-15-w-unranked", + "level-16-w-unranked", + "level-17-w-unranked", + "level-18-w-unranked", + "level-19-w-unranked", + "level-20-w-unranked", + "level-21-w-unranked", + "level-22-w-unranked", + "level-23-w-unranked", + "level-24-w-unranked", + "level-25-w-unranked", + "level-26-w-unranked", + "level-27-w-unranked", + "level-28-w-unranked", + "level-29-w-unranked", + "level-30-w-unranked", + "level-31-w-unranked", + "level-32-w-unranked", + "level-33-w-unranked", + "level-34-w-unranked", + "level-35-w-unranked", + "level-36-w-unranked", + "level-37-w-unranked", + "level-38-w-unranked", + "level-39-w-unranked" ], "game": "itg-stamina", "id": "T19d35f0d593d18036c3", @@ -4067,45 +4055,45 @@ "default": false, "description": "All marathons for ITG. These may not be accurately rated.", "folders": [ - "F19d35f0cf297c4c52c3", - "F19d35f0cf295935f6e0", - "F19d35f0cf292e867a4a", - "F19d35f0cf292cf59d96", - "F19d35f0cf29caa9a64d", - "F19d35f0cf2925e82413", - "F19d35f0cf29bea34230", - "F19d35f0cf294ca9edee", - "F19d35f0cf2971b30cf1", - "F19d35f0cf29f5b0d3dc", - "F19d35f0cf29f8b6a609", - "F19d35f0cf29fa668e9c", - "F19d35f0cf291e7d5b7c", - "F19d35f0cf299bf2b716", - "F19d35f0cf2917cfa66f", - "F19d35f0cf29d57484b2", - "F19d35f0cf29260676ed", - "F19d35f0cf2908e3c2c7", - "F19d35f0cf29593753b2", - "F19d35f0cf29c939f4e5", - "F19d35f0cf299e09fe2d", - "F19d35f0cf2926b89844", - "F19d35f0cf29f4955a84", - "F19d35f0cf297c33f964", - "F19d35f0cf29e8b089c7", - "F19d35f0cf290ddca0ee", - "F19d35f0cf29384d69ad", - "F19d35f0cf291a0f37f6", - "F19d35f0cf298b6b0ebe", - "F19d35f0cf29668611ab", - "F19d35f0cf2989b3e056", - "F19d35f0cf29e9eccd43", - "F19d35f0cf292c126f44", - "F19d35f0cf29408056b0", - "F19d35f0cf291dfcdaad", - "F19d35f0cf294b94e6fa", - "F19d35f0cf2941753b27", - "F19d35f0cf290a160264", - "F19d35f0cf29044e8d26" + "level-1-all-marathons", + "level-2-all-marathons", + "level-3-all-marathons", + "level-4-all-marathons", + "level-5-all-marathons", + "level-6-all-marathons", + "level-7-all-marathons", + "level-8-all-marathons", + "level-9-all-marathons", + "level-10-all-marathons", + "level-11-all-marathons", + "level-12-all-marathons", + "level-13-all-marathons", + "level-14-all-marathons", + "level-15-all-marathons", + "level-16-all-marathons", + "level-17-all-marathons", + "level-18-all-marathons", + "level-19-all-marathons", + "level-20-all-marathons", + "level-21-all-marathons", + "level-22-all-marathons", + "level-23-all-marathons", + "level-24-all-marathons", + "level-25-all-marathons", + "level-26-all-marathons", + "level-27-all-marathons", + "level-28-all-marathons", + "level-29-all-marathons", + "level-30-all-marathons", + "level-31-all-marathons", + "level-32-all-marathons", + "level-33-all-marathons", + "level-34-all-marathons", + "level-35-all-marathons", + "level-36-all-marathons", + "level-37-all-marathons", + "level-38-all-marathons", + "level-39-all-marathons" ], "game": "itg-stamina", "id": "T19d35f0d5936cfe6743", @@ -4117,45 +4105,45 @@ "default": true, "description": "Ranked charts for ITG. These are more reliably accurately rated than trusting what the charter thinks.", "folders": [ - "F19d35f0cf294cc8e4cf", - "F19d35f0cf292c7e2ba0", - "F19d35f0cf294ce081ae", - "F19d35f0cf2987bcd86e", - "F19d35f0cf2929e155a4", - "F19d35f0cf29a3795c45", - "F19d35f0cf291a51bddb", - "F19d35f0cf292861a47e", - "F19d35f0cf296f16bc4d", - "F19d35f0cf29a889d7a5", - "F19d35f0cf294d56e9ab", - "F19d35f0cf2991af0417", - "F19d35f0cf2966baa183", - "F19d35f0cf2916f22c7a", - "F19d35f0cf2954833ff0", - "F19d35f0cf29cd90cacf", - "F19d35f0cf29c31781c3", - "F19d35f0cf298a9194eb", - "F19d35f0cf29ff753f58", - "F19d35f0cf29c317f654", - "F19d35f0cf29cf5794e3", - "F19d35f0cf29cbd94ebc", - "F19d35f0cf29c5a9d9f7", - "F19d35f0cf291505ac93", - "F19d35f0cf29a2bfb897", - "F19d35f0cf2912662b5e", - "F19d35f0cf293a5d3f6f", - "F19d35f0cf296fdfa7a1", - "F19d35f0cf298f012add", - "F19d35f0cf298ec6ee83", - "F19d35f0cf29b5ec9f3b", - "F19d35f0cf29f6b6533b", - "F19d35f0cf29ee8ab709", - "F19d35f0cf292beb8a94", - "F19d35f0cf2927214cfd", - "F19d35f0cf2916771b7a", - "F19d35f0cf297b9287f9", - "F19d35f0cf29420eaf66", - "F19d35f0cf29960afc00" + "level-1-ranked", + "level-2-ranked", + "level-3-ranked", + "level-4-ranked", + "level-5-ranked", + "level-6-ranked", + "level-7-ranked", + "level-8-ranked", + "level-9-ranked", + "level-10-ranked", + "level-11-ranked", + "level-12-ranked", + "level-13-ranked", + "level-14-ranked", + "level-15-ranked", + "level-16-ranked", + "level-17-ranked", + "level-18-ranked", + "level-19-ranked", + "level-20-ranked", + "level-21-ranked", + "level-22-ranked", + "level-23-ranked", + "level-24-ranked", + "level-25-ranked", + "level-26-ranked", + "level-27-ranked", + "level-28-ranked", + "level-29-ranked", + "level-30-ranked", + "level-31-ranked", + "level-32-ranked", + "level-33-ranked", + "level-34-ranked", + "level-35-ranked", + "level-36-ranked", + "level-37-ranked", + "level-38-ranked", + "level-39-ranked" ], "game": "itg-stamina", "id": "T19d35f0d59385890838", @@ -4167,45 +4155,45 @@ "default": false, "description": "Ranked marathons for ITG. These are more reliably accurately rated than trusting what the charter thinks.", "folders": [ - "F19d35f0cf29374c6c4e", - "F19d35f0cf2913a295a8", - "F19d35f0cf297a96d928", - "F19d35f0cf29d7646f51", - "F19d35f0cf2939cc6183", - "F19d35f0cf29d7084de2", - "F19d35f0cf296ef8d038", - "F19d35f0cf29545a70bc", - "F19d35f0cf29bb454cc0", - "F19d35f0cf29eb23a782", - "F19d35f0cf298bc0a5d1", - "F19d35f0cf29deb3d2a9", - "F19d35f0cf298b15d58e", - "F19d35f0cf2958dd0950", - "F19d35f0cf299eda2282", - "F19d35f0cf298dbc5a35", - "F19d35f0cf290c668b97", - "F19d35f0cf29a2402f73", - "F19d35f0cf297e3a2a61", - "F19d35f0cf293669b8aa", - "F19d35f0cf29aaac3689", - "F19d35f0cf29ede7ae29", - "F19d35f0cf293604f261", - "F19d35f0cf29affb4f19", - "F19d35f0cf29e6b7a52e", - "F19d35f0cf29eabc8cbf", - "F19d35f0cf297c4d6833", - "F19d35f0cf29545cf29e", - "F19d35f0cf297492f11e", - "F19d35f0cf292a373309", - "F19d35f0cf299ea7532f", - "F19d35f0cf2972ac9d0e", - "F19d35f0cf29ec9fcd7b", - "F19d35f0cf2965a3c377", - "F19d35f0cf2981b76fc9", - "F19d35f0cf29c1b72aec", - "F19d35f0cf2974016519", - "F19d35f0cf296238490e", - "F19d35f0cf29c4871e19" + "level-1-ranked-marathons", + "level-2-ranked-marathons", + "level-3-ranked-marathons", + "level-4-ranked-marathons", + "level-5-ranked-marathons", + "level-6-ranked-marathons", + "level-7-ranked-marathons", + "level-8-ranked-marathons", + "level-9-ranked-marathons", + "level-10-ranked-marathons", + "level-11-ranked-marathons", + "level-12-ranked-marathons", + "level-13-ranked-marathons", + "level-14-ranked-marathons", + "level-15-ranked-marathons", + "level-16-ranked-marathons", + "level-17-ranked-marathons", + "level-18-ranked-marathons", + "level-19-ranked-marathons", + "level-20-ranked-marathons", + "level-21-ranked-marathons", + "level-22-ranked-marathons", + "level-23-ranked-marathons", + "level-24-ranked-marathons", + "level-25-ranked-marathons", + "level-26-ranked-marathons", + "level-27-ranked-marathons", + "level-28-ranked-marathons", + "level-29-ranked-marathons", + "level-30-ranked-marathons", + "level-31-ranked-marathons", + "level-32-ranked-marathons", + "level-33-ranked-marathons", + "level-34-ranked-marathons", + "level-35-ranked-marathons", + "level-36-ranked-marathons", + "level-37-ranked-marathons", + "level-38-ranked-marathons", + "level-39-ranked-marathons" ], "game": "itg-stamina", "id": "T19d35f0d5935029e02a", @@ -4217,26 +4205,26 @@ "default": false, "description": "Decimal Level Folders for jubeat Ave..", "folders": [ - "F19d35f0cf293f4cda3b", - "F19d35f0cf293070585c", - "F19d35f0cf29653aace4", - "F19d35f0cf29932febe8", - "F19d35f0cf2925c42b8e", - "F19d35f0cf29fbd29496", - "F19d35f0cf2909944f2e", - "F19d35f0cf29d7cfc543", - "F19d35f0cf29308d863e", - "F19d35f0cf29cc289ee2", - "F19d35f0cf295161519e", - "F19d35f0cf29ef3ce4f4", - "F19d35f0cf29be15ebdc", - "F19d35f0cf2908af2dd2", - "F19d35f0cf29c74d8095", - "F19d35f0cf29be16eb5b", - "F19d35f0cf298003a57d", - "F19d35f0cf294a2ca751", - "F19d35f0cf292743de97", - "F19d35f0cf29da691a9e" + "9p0-normal-ave", + "9p1-normal-ave", + "9p2-normal-ave", + "9p3-normal-ave", + "9p4-normal-ave", + "9p5-normal-ave", + "9p6-normal-ave", + "9p7-normal-ave", + "9p8-normal-ave", + "9p9-normal-ave", + "10p0-normal-ave", + "10p1-normal-ave", + "10p2-normal-ave", + "10p3-normal-ave", + "10p4-normal-ave", + "10p5-normal-ave", + "10p6-normal-ave", + "10p7-normal-ave", + "10p8-normal-ave", + "10p9-normal-ave" ], "game": "jubeat", "id": "T19d35f0d593db42fccf", @@ -4248,26 +4236,26 @@ "default": false, "description": "Decimal Level Folders for jubeat festo Omnimix.", "folders": [ - "F19d35f0cf29338ef09f", - "F19d35f0cf29876bb4f5", - "F19d35f0cf295561920b", - "F19d35f0cf29e14bb1a5", - "F19d35f0cf29407eea39", - "F19d35f0cf29c755f7bd", - "F19d35f0cf29c63e9437", - "F19d35f0cf292ad4efd5", - "F19d35f0cf294fe193b1", - "F19d35f0cf29a533548f", - "F19d35f0cf29b1aaf4f7", - "F19d35f0cf2923fe5e8a", - "F19d35f0cf2964f103a4", - "F19d35f0cf29433b86df", - "F19d35f0cf29f266b658", - "F19d35f0cf2978de2171", - "F19d35f0cf293a35363f", - "F19d35f0cf293dac21d6", - "F19d35f0cf29960dd0e7", - "F19d35f0cf29ce38ca53" + "9p0-normal-festo-omni", + "9p1-normal-festo-omni", + "9p2-normal-festo-omni", + "9p3-normal-festo-omni", + "9p4-normal-festo-omni", + "9p5-normal-festo-omni", + "9p6-normal-festo-omni", + "9p7-normal-festo-omni", + "9p8-normal-festo-omni", + "9p9-normal-festo-omni", + "10p0-normal-festo-omni", + "10p1-normal-festo-omni", + "10p2-normal-festo-omni", + "10p3-normal-festo-omni", + "10p4-normal-festo-omni", + "10p5-normal-festo-omni", + "10p6-normal-festo-omni", + "10p7-normal-festo-omni", + "10p8-normal-festo-omni", + "10p9-normal-festo-omni" ], "game": "jubeat", "id": "T19d35f0d593e1ff5088", @@ -4279,26 +4267,26 @@ "default": false, "description": "Decimal Level Folders for jubeat.", "folders": [ - "F19d35f0cf295b905aae", - "F19d35f0cf29ecffd9a4", - "F19d35f0cf2933291ac0", - "F19d35f0cf29b0608dcc", - "F19d35f0cf292a14f29c", - "F19d35f0cf29b6b45596", - "F19d35f0cf29ff8ec3b6", - "F19d35f0cf29cf627b82", - "F19d35f0cf29d8bc396f", - "F19d35f0cf2901c51370", - "F19d35f0cf290edbe8fb", - "F19d35f0cf29d79e2c94", - "F19d35f0cf29619db7bf", - "F19d35f0cf29125aa025", - "F19d35f0cf291649c8d0", - "F19d35f0cf291b5e8e40", - "F19d35f0cf293014ad9d", - "F19d35f0cf29cb48cadd", - "F19d35f0cf29ab636cf3", - "F19d35f0cf29f3537a73" + "9p0-normal-festo", + "9p1-normal-festo", + "9p2-normal-festo", + "9p3-normal-festo", + "9p4-normal-festo", + "9p5-normal-festo", + "9p6-normal-festo", + "9p7-normal-festo", + "9p8-normal-festo", + "9p9-normal-festo", + "10p0-normal-festo", + "10p1-normal-festo", + "10p2-normal-festo", + "10p3-normal-festo", + "10p4-normal-festo", + "10p5-normal-festo", + "10p6-normal-festo", + "10p7-normal-festo", + "10p8-normal-festo", + "10p9-normal-festo" ], "game": "jubeat", "id": "T19d35f0d593be23a3ac", @@ -4310,26 +4298,26 @@ "default": false, "description": "Decimal Level Folders for jubeat Qubell.", "folders": [ - "F19d35f0cf29a67c8360", - "F19d35f0cf294cc1279b", - "F19d35f0cf2971748f7c", - "F19d35f0cf298b31c4a5", - "F19d35f0cf298fa181bc", - "F19d35f0cf29c58db03b", - "F19d35f0cf299a05da71", - "F19d35f0cf294870ff83", - "F19d35f0cf29cb95e1b8", - "F19d35f0cf292993c4aa", - "F19d35f0cf29c186d0e9", - "F19d35f0cf295dd68ea5", - "F19d35f0cf29e0cad1e1", - "F19d35f0cf291d2a123e", - "F19d35f0cf29783af0f5", - "F19d35f0cf297660f928", - "F19d35f0cf293982d6ef", - "F19d35f0cf29e4a22759", - "F19d35f0cf29c7fdbebc", - "F19d35f0cf29789c753d" + "9p0-normal-qubell", + "9p1-normal-qubell", + "9p2-normal-qubell", + "9p3-normal-qubell", + "9p4-normal-qubell", + "9p5-normal-qubell", + "9p6-normal-qubell", + "9p7-normal-qubell", + "9p8-normal-qubell", + "9p9-normal-qubell", + "10p0-normal-qubell", + "10p1-normal-qubell", + "10p2-normal-qubell", + "10p3-normal-qubell", + "10p4-normal-qubell", + "10p5-normal-qubell", + "10p6-normal-qubell", + "10p7-normal-qubell", + "10p8-normal-qubell", + "10p9-normal-qubell" ], "game": "jubeat", "id": "T19d35f0d5933cf45827", @@ -4341,26 +4329,26 @@ "default": false, "description": "Hard Mode Decimal Level Folders for jubeat Ave..", "folders": [ - "F19d35f0cf2933252f6b", - "F19d35f0cf29064b9dbd", - "F19d35f0cf29293a87e7", - "F19d35f0cf29ae2e7d94", - "F19d35f0cf29e69cbb33", - "F19d35f0cf290220bfe0", - "F19d35f0cf294f6e6004", - "F19d35f0cf2977155328", - "F19d35f0cf29517873b8", - "F19d35f0cf293b992bd6", - "F19d35f0cf295396d5b8", - "F19d35f0cf29dac35017", - "F19d35f0cf29193ae0ad", - "F19d35f0cf294340a2f7", - "F19d35f0cf2944d977e7", - "F19d35f0cf293d675f35", - "F19d35f0cf29d8d7aaa9", - "F19d35f0cf29711476d8", - "F19d35f0cf297b56e00a", - "F19d35f0cf294fc1ee41" + "9p0-hard-ave", + "9p1-hard-ave", + "9p2-hard-ave", + "9p3-hard-ave", + "9p4-hard-ave", + "9p5-hard-ave", + "9p6-hard-ave", + "9p7-hard-ave", + "9p8-hard-ave", + "9p9-hard-ave", + "10p0-hard-ave", + "10p1-hard-ave", + "10p2-hard-ave", + "10p3-hard-ave", + "10p4-hard-ave", + "10p5-hard-ave", + "10p6-hard-ave", + "10p7-hard-ave", + "10p8-hard-ave", + "10p9-hard-ave" ], "game": "jubeat", "id": "T19d35f0d5930c69afbb", @@ -4372,26 +4360,26 @@ "default": false, "description": "Hard Mode Decimal Level Folders for jubeat festo Omnimix.", "folders": [ - "F19d35f0cf296c92c785", - "F19d35f0cf293cb319df", - "F19d35f0cf29ee828d52", - "F19d35f0cf299f70f8f3", - "F19d35f0cf2964a0003f", - "F19d35f0cf2962ebeab5", - "F19d35f0cf29fa1a587a", - "F19d35f0cf29cb100b61", - "F19d35f0cf293a04e87f", - "F19d35f0cf2975f8beee", - "F19d35f0cf29d0ebc6a3", - "F19d35f0cf29b5330693", - "F19d35f0cf291d83b829", - "F19d35f0cf291bd8f54c", - "F19d35f0cf294959c28a", - "F19d35f0cf29cad946d6", - "F19d35f0cf293c32884e", - "F19d35f0cf2994481502", - "F19d35f0cf2935cdb69c", - "F19d35f0cf29a24d52e5" + "9p0-hard-festo-omni", + "9p1-hard-festo-omni", + "9p2-hard-festo-omni", + "9p3-hard-festo-omni", + "9p4-hard-festo-omni", + "9p5-hard-festo-omni", + "9p6-hard-festo-omni", + "9p7-hard-festo-omni", + "9p8-hard-festo-omni", + "9p9-hard-festo-omni", + "10p0-hard-festo-omni", + "10p1-hard-festo-omni", + "10p2-hard-festo-omni", + "10p3-hard-festo-omni", + "10p4-hard-festo-omni", + "10p5-hard-festo-omni", + "10p6-hard-festo-omni", + "10p7-hard-festo-omni", + "10p8-hard-festo-omni", + "10p9-hard-festo-omni" ], "game": "jubeat", "id": "T19d35f0d5939a9f098f", @@ -4403,26 +4391,26 @@ "default": false, "description": "Hard Mode Decimal Level Folders for jubeat.", "folders": [ - "F19d35f0cf294af77d0e", - "F19d35f0cf29051549b7", - "F19d35f0cf295d5491b0", - "F19d35f0cf29a9944882", - "F19d35f0cf29a824e8a4", - "F19d35f0cf29fba83d27", - "F19d35f0cf296509137e", - "F19d35f0cf2989f1ed8d", - "F19d35f0cf29fd9dc6c4", - "F19d35f0cf292fbb3ce3", - "F19d35f0cf2989a6b03b", - "F19d35f0cf29351a88e3", - "F19d35f0cf29cae8b723", - "F19d35f0cf298ac35ebc", - "F19d35f0cf29b24a0063", - "F19d35f0cf29fe5b7aeb", - "F19d35f0cf2976cb7dc6", - "F19d35f0cf2930af1b14", - "F19d35f0cf29624d03d0", - "F19d35f0cf296e6798f8" + "9p0-hard-festo", + "9p1-hard-festo", + "9p2-hard-festo", + "9p3-hard-festo", + "9p4-hard-festo", + "9p5-hard-festo", + "9p6-hard-festo", + "9p7-hard-festo", + "9p8-hard-festo", + "9p9-hard-festo", + "10p0-hard-festo", + "10p1-hard-festo", + "10p2-hard-festo", + "10p3-hard-festo", + "10p4-hard-festo", + "10p5-hard-festo", + "10p6-hard-festo", + "10p7-hard-festo", + "10p8-hard-festo", + "10p9-hard-festo" ], "game": "jubeat", "id": "T19d35f0d59391489467", @@ -4434,26 +4422,26 @@ "default": false, "description": "Hard Mode Decimal Level Folders for jubeat Qubell.", "folders": [ - "F19d35f0cf298155b6ea", - "F19d35f0cf29f3841673", - "F19d35f0cf290bf290cf", - "F19d35f0cf2909e0bb38", - "F19d35f0cf29a37057e8", - "F19d35f0cf29db90dc4f", - "F19d35f0cf2970171f06", - "F19d35f0cf29a3f506e1", - "F19d35f0cf29688cfe36", - "F19d35f0cf29400b614b", - "F19d35f0cf29de236314", - "F19d35f0cf2901085f46", - "F19d35f0cf29a6cd53b0", - "F19d35f0cf29a42cb528", - "F19d35f0cf292b428980", - "F19d35f0cf29c60cb18a", - "F19d35f0cf299ab40b6e", - "F19d35f0cf2957996e90", - "F19d35f0cf29a8988a9f", - "F19d35f0cf2962b4f194" + "9p0-hard-qubell", + "9p1-hard-qubell", + "9p2-hard-qubell", + "9p3-hard-qubell", + "9p4-hard-qubell", + "9p5-hard-qubell", + "9p6-hard-qubell", + "9p7-hard-qubell", + "9p8-hard-qubell", + "9p9-hard-qubell", + "10p0-hard-qubell", + "10p1-hard-qubell", + "10p2-hard-qubell", + "10p3-hard-qubell", + "10p4-hard-qubell", + "10p5-hard-qubell", + "10p6-hard-qubell", + "10p7-hard-qubell", + "10p8-hard-qubell", + "10p9-hard-qubell" ], "game": "jubeat", "id": "T19d35f0d593f7d79a98", @@ -4465,16 +4453,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat Ave.. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf295d80889f", - "F19d35f0cf29c67506d4", - "F19d35f0cf296d5b27ae", - "F19d35f0cf29d8e620ef", - "F19d35f0cf29a13ba0b2", - "F19d35f0cf2955a5daad", - "F19d35f0cf29b656cbd9", - "F19d35f0cf2976e8ebb3", - "F19d35f0cf29c5023421", - "F19d35f0cf2925b1babf" + "1-hard-ave", + "2-hard-ave", + "3-hard-ave", + "4-hard-ave", + "5-hard-ave", + "6-hard-ave", + "7-hard-ave", + "8-hard-ave", + "ge-9-lt-10-hard-ave", + "ge-10-lt-11-hard-ave" ], "game": "jubeat", "id": "T19d35f0d5938d6be350", @@ -4486,16 +4474,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat copious. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf2965b42b9e", - "F19d35f0cf29b64688cf", - "F19d35f0cf295b3c604c", - "F19d35f0cf29a7683758", - "F19d35f0cf29e6bd0b93", - "F19d35f0cf29d515a67e", - "F19d35f0cf29a9c7f92b", - "F19d35f0cf29d823ec9c", - "F19d35f0cf291b8695db", - "F19d35f0cf2956ffa9f4" + "1-hard-copious", + "2-hard-copious", + "3-hard-copious", + "4-hard-copious", + "5-hard-copious", + "6-hard-copious", + "7-hard-copious", + "8-hard-copious", + "ge-9-lt-10-hard-copious", + "ge-10-lt-11-hard-copious" ], "game": "jubeat", "id": "T19d35f0d593c968b9eb", @@ -4507,16 +4495,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat festo Omnimix. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29f0deff0d", - "F19d35f0cf29bead21d3", - "F19d35f0cf29d0b1b008", - "F19d35f0cf29a42e89f5", - "F19d35f0cf298852859c", - "F19d35f0cf29936c9187", - "F19d35f0cf291ce6b230", - "F19d35f0cf29dc4a4994", - "F19d35f0cf29585796da", - "F19d35f0cf29d5d641b4" + "1-hard-festo-omni", + "2-hard-festo-omni", + "3-hard-festo-omni", + "4-hard-festo-omni", + "5-hard-festo-omni", + "6-hard-festo-omni", + "7-hard-festo-omni", + "8-hard-festo-omni", + "ge-9-lt-10-hard-festo-omni", + "ge-10-lt-11-hard-festo-omni" ], "game": "jubeat", "id": "T19d35f0d5935dc1e93b", @@ -4528,16 +4516,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat festo. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf299a374441", - "F19d35f0cf29ddd2bb77", - "F19d35f0cf29fbf67121", - "F19d35f0cf29fc71b9e8", - "F19d35f0cf29ac637ab0", - "F19d35f0cf29ad2b1e39", - "F19d35f0cf296aa95508", - "F19d35f0cf29e74dfe7e", - "F19d35f0cf295839fb0b", - "F19d35f0cf29e999d98f" + "1-hard-festo", + "2-hard-festo", + "3-hard-festo", + "4-hard-festo", + "5-hard-festo", + "6-hard-festo", + "7-hard-festo", + "8-hard-festo", + "ge-9-lt-10-hard-festo", + "ge-10-lt-11-hard-festo" ], "game": "jubeat", "id": "T19d35f0d59351182c59", @@ -4549,16 +4537,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf298c6b5714", - "F19d35f0cf29bee68355", - "F19d35f0cf29fe41fc2a", - "F19d35f0cf29c521a57a", - "F19d35f0cf29d93725c2", - "F19d35f0cf292b1b1e47", - "F19d35f0cf296a0865d2", - "F19d35f0cf29df87de13", - "F19d35f0cf29d7601efe", - "F19d35f0cf29f176970e" + "1-hard-jubeat", + "2-hard-jubeat", + "3-hard-jubeat", + "4-hard-jubeat", + "5-hard-jubeat", + "6-hard-jubeat", + "7-hard-jubeat", + "8-hard-jubeat", + "ge-9-lt-10-hard-jubeat", + "ge-10-lt-11-hard-jubeat" ], "game": "jubeat", "id": "T19d35f0d593889634b0", @@ -4570,16 +4558,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat knit. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf2904ab1b77", - "F19d35f0cf29b519fcfb", - "F19d35f0cf299a06ece3", - "F19d35f0cf29008515e7", - "F19d35f0cf29b5980531", - "F19d35f0cf29d72156e4", - "F19d35f0cf29e7fb5689", - "F19d35f0cf2904e491f6", - "F19d35f0cf29ced61144", - "F19d35f0cf29637c65d3" + "1-hard-knit", + "2-hard-knit", + "3-hard-knit", + "4-hard-knit", + "5-hard-knit", + "6-hard-knit", + "7-hard-knit", + "8-hard-knit", + "ge-9-lt-10-hard-knit", + "ge-10-lt-11-hard-knit" ], "game": "jubeat", "id": "T19d35f0d593656a0d40", @@ -4591,16 +4579,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat prop. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29118892ea", - "F19d35f0cf293f82f658", - "F19d35f0cf293c5298d4", - "F19d35f0cf2970e08c82", - "F19d35f0cf29f9c9b3ea", - "F19d35f0cf298027724e", - "F19d35f0cf298220e974", - "F19d35f0cf2930bc7b48", - "F19d35f0cf292fa54f4a", - "F19d35f0cf2930d4d82e" + "1-hard-prop", + "2-hard-prop", + "3-hard-prop", + "4-hard-prop", + "5-hard-prop", + "6-hard-prop", + "7-hard-prop", + "8-hard-prop", + "ge-9-lt-10-hard-prop", + "ge-10-lt-11-hard-prop" ], "game": "jubeat", "id": "T19d35f0d593248dca89", @@ -4612,16 +4600,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat Qubell. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf291f4120f1", - "F19d35f0cf294ceb43fc", - "F19d35f0cf2958d6fa9c", - "F19d35f0cf29328c3943", - "F19d35f0cf29a0bd7227", - "F19d35f0cf29df4cc357", - "F19d35f0cf2995672436", - "F19d35f0cf298faee249", - "F19d35f0cf29f24d0d0e", - "F19d35f0cf298e632cce" + "1-hard-qubell", + "2-hard-qubell", + "3-hard-qubell", + "4-hard-qubell", + "5-hard-qubell", + "6-hard-qubell", + "7-hard-qubell", + "8-hard-qubell", + "ge-9-lt-10-hard-qubell", + "ge-10-lt-11-hard-qubell" ], "game": "jubeat", "id": "T19d35f0d59360b4a090", @@ -4633,16 +4621,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat ripples. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf292496be50", - "F19d35f0cf29245fb140", - "F19d35f0cf29c341816a", - "F19d35f0cf29b540c80f", - "F19d35f0cf29a03e402d", - "F19d35f0cf29a064f071", - "F19d35f0cf2963b474fd", - "F19d35f0cf2983acdf5c", - "F19d35f0cf298007e932", - "F19d35f0cf29c9786920" + "1-hard-ripples", + "2-hard-ripples", + "3-hard-ripples", + "4-hard-ripples", + "5-hard-ripples", + "6-hard-ripples", + "7-hard-ripples", + "8-hard-ripples", + "ge-9-lt-10-hard-ripples", + "ge-10-lt-11-hard-ripples" ], "game": "jubeat", "id": "T19d35f0d5933ac8b6a4", @@ -4654,16 +4642,16 @@ "default": false, "description": "Hard Mode Level Folders for jubeat saucer. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29ad9b7e71", - "F19d35f0cf29155c2164", - "F19d35f0cf29cf904096", - "F19d35f0cf29120e5182", - "F19d35f0cf290a0517b1", - "F19d35f0cf29c8cb1122", - "F19d35f0cf296b271607", - "F19d35f0cf290e7ac8b1", - "F19d35f0cf299995820b", - "F19d35f0cf293be1a690" + "1-hard-saucer", + "2-hard-saucer", + "3-hard-saucer", + "4-hard-saucer", + "5-hard-saucer", + "6-hard-saucer", + "7-hard-saucer", + "8-hard-saucer", + "ge-9-lt-10-hard-saucer", + "ge-10-lt-11-hard-saucer" ], "game": "jubeat", "id": "T19d35f0d593654436a9", @@ -4675,16 +4663,16 @@ "default": true, "description": "Level Folders for jubeat Ave.. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29f1093c57", - "F19d35f0cf29afa95e65", - "F19d35f0cf29209ea07d", - "F19d35f0cf29a34d6746", - "F19d35f0cf299314eaa8", - "F19d35f0cf29a629379b", - "F19d35f0cf295d7a3af1", - "F19d35f0cf29979603d9", - "F19d35f0cf297831990f", - "F19d35f0cf298f97b1ba" + "1-normal-ave", + "2-normal-ave", + "3-normal-ave", + "4-normal-ave", + "5-normal-ave", + "6-normal-ave", + "7-normal-ave", + "8-normal-ave", + "ge-9-lt-10-normal-ave", + "ge-10-lt-11-normal-ave" ], "game": "jubeat", "id": "T19d35f0d593ca072418", @@ -4696,16 +4684,16 @@ "default": false, "description": "Level Folders for jubeat copious. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29270ace72", - "F19d35f0cf29497341b1", - "F19d35f0cf2995e6ba46", - "F19d35f0cf29ce65485f", - "F19d35f0cf29d8117092", - "F19d35f0cf290cfa1293", - "F19d35f0cf29cf9158df", - "F19d35f0cf29392896f1", - "F19d35f0cf292418ce3b", - "F19d35f0cf2926507a2c" + "1-normal-copious", + "2-normal-copious", + "3-normal-copious", + "4-normal-copious", + "5-normal-copious", + "6-normal-copious", + "7-normal-copious", + "8-normal-copious", + "ge-9-lt-10-normal-copious", + "ge-10-lt-11-normal-copious" ], "game": "jubeat", "id": "T19d35f0d5935c2d8445", @@ -4717,16 +4705,16 @@ "default": false, "description": "Level Folders for jubeat festo Omnimix. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29c551d1e6", - "F19d35f0cf2904553324", - "F19d35f0cf2960f53864", - "F19d35f0cf290bb313c7", - "F19d35f0cf29ff3dc895", - "F19d35f0cf29de3540e2", - "F19d35f0cf292a2095fc", - "F19d35f0cf2933dfc4a5", - "F19d35f0cf299268f061", - "F19d35f0cf29934e04e0" + "1-normal-festo-omni", + "2-normal-festo-omni", + "3-normal-festo-omni", + "4-normal-festo-omni", + "5-normal-festo-omni", + "6-normal-festo-omni", + "7-normal-festo-omni", + "8-normal-festo-omni", + "ge-9-lt-10-normal-festo-omni", + "ge-10-lt-11-normal-festo-omni" ], "game": "jubeat", "id": "T19d35f0d593f0964a40", @@ -4738,16 +4726,16 @@ "default": false, "description": "Level Folders for jubeat festo. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29410b6d05", - "F19d35f0cf29a760a26f", - "F19d35f0cf29b07f5cd6", - "F19d35f0cf29f18a480d", - "F19d35f0cf29e03ce45c", - "F19d35f0cf29e74f6c00", - "F19d35f0cf29886c13c8", - "F19d35f0cf292ef6f9ce", - "F19d35f0cf29a2e76a07", - "F19d35f0cf29f66f1371" + "1-normal-festo", + "2-normal-festo", + "3-normal-festo", + "4-normal-festo", + "5-normal-festo", + "6-normal-festo", + "7-normal-festo", + "8-normal-festo", + "ge-9-lt-10-normal-festo", + "ge-10-lt-11-normal-festo" ], "game": "jubeat", "id": "T19d35f0d5937f314d61", @@ -4759,16 +4747,16 @@ "default": false, "description": "Level Folders for jubeat. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29f85b66da", - "F19d35f0cf292824ff94", - "F19d35f0cf29aabfaa84", - "F19d35f0cf2945e5b9ac", - "F19d35f0cf296bcdcf8e", - "F19d35f0cf2906de8ffa", - "F19d35f0cf29948bc40d", - "F19d35f0cf29feff2568", - "F19d35f0cf29f1442287", - "F19d35f0cf29ef607e4e" + "1-normal-jubeat", + "2-normal-jubeat", + "3-normal-jubeat", + "4-normal-jubeat", + "5-normal-jubeat", + "6-normal-jubeat", + "7-normal-jubeat", + "8-normal-jubeat", + "ge-9-lt-10-normal-jubeat", + "ge-10-lt-11-normal-jubeat" ], "game": "jubeat", "id": "T19d35f0d593e67c71b2", @@ -4780,16 +4768,16 @@ "default": false, "description": "Level Folders for jubeat knit. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29295a2d57", - "F19d35f0cf29da12b8c6", - "F19d35f0cf290ceaec34", - "F19d35f0cf29c916bf22", - "F19d35f0cf29ebdb4d11", - "F19d35f0cf29e00fbdec", - "F19d35f0cf2922e6c593", - "F19d35f0cf296d0c6129", - "F19d35f0cf29d56b8c99", - "F19d35f0cf298dc2d4a0" + "1-normal-knit", + "2-normal-knit", + "3-normal-knit", + "4-normal-knit", + "5-normal-knit", + "6-normal-knit", + "7-normal-knit", + "8-normal-knit", + "ge-9-lt-10-normal-knit", + "ge-10-lt-11-normal-knit" ], "game": "jubeat", "id": "T19d35f0d59369f76b99", @@ -4801,16 +4789,16 @@ "default": false, "description": "Level Folders for jubeat prop. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf298367677d", - "F19d35f0cf29aee4a799", - "F19d35f0cf2913c8ed96", - "F19d35f0cf29da1ce82e", - "F19d35f0cf29118a07f6", - "F19d35f0cf29802ea636", - "F19d35f0cf2905c62198", - "F19d35f0cf29dcb1d293", - "F19d35f0cf291dc2221f", - "F19d35f0cf2965e2c950" + "1-normal-prop", + "2-normal-prop", + "3-normal-prop", + "4-normal-prop", + "5-normal-prop", + "6-normal-prop", + "7-normal-prop", + "8-normal-prop", + "ge-9-lt-10-normal-prop", + "ge-10-lt-11-normal-prop" ], "game": "jubeat", "id": "T19d35f0d5931fd4c93e", @@ -4822,16 +4810,16 @@ "default": false, "description": "Level Folders for jubeat Qubell. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29b318008e", - "F19d35f0cf29a0f05da8", - "F19d35f0cf292339fe2d", - "F19d35f0cf2907c96d19", - "F19d35f0cf299954b1fb", - "F19d35f0cf293722a588", - "F19d35f0cf29f0f314cb", - "F19d35f0cf293b1fe0f0", - "F19d35f0cf29574b3719", - "F19d35f0cf291b80737e" + "1-normal-qubell", + "2-normal-qubell", + "3-normal-qubell", + "4-normal-qubell", + "5-normal-qubell", + "6-normal-qubell", + "7-normal-qubell", + "8-normal-qubell", + "ge-9-lt-10-normal-qubell", + "ge-10-lt-11-normal-qubell" ], "game": "jubeat", "id": "T19d35f0d59330804d63", @@ -4843,16 +4831,16 @@ "default": false, "description": "Level Folders for jubeat ripples. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf29c676b16e", - "F19d35f0cf29c97ac247", - "F19d35f0cf29b4664142", - "F19d35f0cf295901e07e", - "F19d35f0cf2965048077", - "F19d35f0cf299e663d64", - "F19d35f0cf2945954b42", - "F19d35f0cf29a94e451c", - "F19d35f0cf2913f84b15", - "F19d35f0cf29f04b6cb0" + "1-normal-ripples", + "2-normal-ripples", + "3-normal-ripples", + "4-normal-ripples", + "5-normal-ripples", + "6-normal-ripples", + "7-normal-ripples", + "8-normal-ripples", + "ge-9-lt-10-normal-ripples", + "ge-10-lt-11-normal-ripples" ], "game": "jubeat", "id": "T19d35f0d5934cf1d460", @@ -4864,16 +4852,16 @@ "default": false, "description": "Level Folders for jubeat saucer. This table does not have separate folders for each decimal.", "folders": [ - "F19d35f0cf291ee0feec", - "F19d35f0cf29511a0fec", - "F19d35f0cf2933cefd27", - "F19d35f0cf29179e6f7b", - "F19d35f0cf29c97310ad", - "F19d35f0cf2964b2aad2", - "F19d35f0cf294641394f", - "F19d35f0cf296f4acc9a", - "F19d35f0cf29c76bcd96", - "F19d35f0cf2982a3d6f3" + "1-normal-saucer", + "2-normal-saucer", + "3-normal-saucer", + "4-normal-saucer", + "5-normal-saucer", + "6-normal-saucer", + "7-normal-saucer", + "8-normal-saucer", + "ge-9-lt-10-normal-saucer", + "ge-10-lt-11-normal-saucer" ], "game": "jubeat", "id": "T19d35f0d594b6d3e27a", @@ -4885,29 +4873,29 @@ "default": true, "description": "Levels for maimai FiNALE.", "folders": [ - "F19d35f0cf2994c9d325", - "F19d35f0cf292185827d", - "F19d35f0cf29e9c3a8f1", - "F19d35f0cf29f2438ec0", - "F19d35f0cf2971f53e19", - "F19d35f0cf29de51d672", - "F19d35f0cf2984fe6606", - "F19d35f0cf298d8176dc", - "F19d35f0cf2911705737", - "F19d35f0cf29e788297e", - "F19d35f0cf290cf23b9a", - "F19d35f0cf29caba52dd", - "F19d35f0cf2933318445", - "F19d35f0cf2991191c6e", - "F19d35f0cf29609a5c1a", - "F19d35f0cf296d0b6cd4", - "F19d35f0cf29f52501de", - "F19d35f0cf29b3b5a57b", - "F19d35f0cf29d80a0b52", - "F19d35f0cf29e29c29bc", - "F19d35f0cf290034beda", - "F19d35f0cf296aa0f627", - "F19d35f0cf29760832b2" + "1-finale", + "2-finale", + "3-finale", + "4-finale", + "5-finale", + "5p-finale", + "6-finale", + "6p-finale", + "7-finale", + "7p-finale", + "8-finale", + "8p-finale", + "9-finale", + "9p-finale", + "10-finale", + "10p-finale", + "11-finale", + "11p-finale", + "12-finale", + "12p-finale", + "13-finale", + "13p-finale", + "14-finale" ], "game": "maimai", "id": "T19d35f0d5942de0387d", @@ -4919,29 +4907,29 @@ "default": false, "description": "Levels for maimai DX in BUDDiES Omnimix.", "folders": [ - "F19d35f0cf2919bba622", - "F19d35f0cf29a7c74ece", - "F19d35f0cf296abcc708", - "F19d35f0cf295bfe384f", - "F19d35f0cf29833fa2ca", - "F19d35f0cf29b6f73176", - "F19d35f0cf296a6db824", - "F19d35f0cf299843567c", - "F19d35f0cf2969e61662", - "F19d35f0cf2937963b90", - "F19d35f0cf2a40bc9340", - "F19d35f0cf2a2ef8ce25", - "F19d35f0cf2910b340fa", - "F19d35f0cf2932431770", - "F19d35f0cf29b8b9efb1", - "F19d35f0cf2902655eb3", - "F19d35f0cf293b1ab725", - "F19d35f0cf29c81507c8", - "F19d35f0cf2975c55ce4", - "F19d35f0cf29b3e87629", - "F19d35f0cf29b1095c06", - "F19d35f0cf2900eb8ba7", - "F19d35f0cf29fb6409eb" + "1-buddies-omni", + "2-buddies-omni", + "3-buddies-omni", + "4-buddies-omni", + "5-buddies-omni", + "6-buddies-omni", + "7-buddies-omni", + "7p-buddies-omni", + "8-buddies-omni", + "8p-buddies-omni", + "9-buddies-omni", + "9p-buddies-omni", + "10-buddies-omni", + "10p-buddies-omni", + "11-buddies-omni", + "11p-buddies-omni", + "12-buddies-omni", + "12p-buddies-omni", + "13-buddies-omni", + "13p-buddies-omni", + "14-buddies-omni", + "14p-buddies-omni", + "15-buddies-omni" ], "game": "maimaidx", "id": "T19d35f0d594dd82d288", @@ -4953,29 +4941,29 @@ "default": false, "description": "Levels for maimai DX in BUDDiES PLUS Omnimix.", "folders": [ - "F19d35f0cf29fe47c6a8", - "F19d35f0cf29fb56a413", - "F19d35f0cf298eea99d9", - "F19d35f0cf295255ed50", - "F19d35f0cf29f23f5970", - "F19d35f0cf29a8794837", - "F19d35f0cf297ec4da59", - "F19d35f0cf299b7afd00", - "F19d35f0cf2943fcbdc4", - "F19d35f0cf29e23e4811", - "F19d35f0cf2abd13605f", - "F19d35f0cf2ab2aa5ab4", - "F19d35f0cf29fb19c168", - "F19d35f0cf29c7320ab7", - "F19d35f0cf29b51e05f1", - "F19d35f0cf29a8a3f273", - "F19d35f0cf29c958607d", - "F19d35f0cf29deb902cb", - "F19d35f0cf294195bfbd", - "F19d35f0cf299dc0bd6d", - "F19d35f0cf29a990377d", - "F19d35f0cf293fcb709f", - "F19d35f0cf298a9339b1" + "1-buddiesplus-omni", + "2-buddiesplus-omni", + "3-buddiesplus-omni", + "4-buddiesplus-omni", + "5-buddiesplus-omni", + "6-buddiesplus-omni", + "7-buddiesplus-omni", + "7p-buddiesplus-omni", + "8-buddiesplus-omni", + "8p-buddiesplus-omni", + "9-buddiesplus-omni", + "9p-buddiesplus-omni", + "10-buddiesplus-omni", + "10p-buddiesplus-omni", + "11-buddiesplus-omni", + "11p-buddiesplus-omni", + "12-buddiesplus-omni", + "12p-buddiesplus-omni", + "13-buddiesplus-omni", + "13p-buddiesplus-omni", + "14-buddiesplus-omni", + "14p-buddiesplus-omni", + "15-buddiesplus-omni" ], "game": "maimaidx", "id": "T19d35f0d5945ad36797", @@ -4987,29 +4975,29 @@ "default": false, "description": "Levels for maimai DX in BUDDiES PLUS.", "folders": [ - "F19d35f0cf2903c521cd", - "F19d35f0cf2901884cdd", - "F19d35f0cf2990e1d7ee", - "F19d35f0cf29b52f1a8a", - "F19d35f0cf29fa8e497e", - "F19d35f0cf293aa72275", - "F19d35f0cf2950de53e1", - "F19d35f0cf2924832b32", - "F19d35f0cf2986b1961a", - "F19d35f0cf290f7bd0e1", - "F19d35f0cf2aa1db2364", - "F19d35f0cf2af9f01f79", - "F19d35f0cf29d10d5d56", - "F19d35f0cf29fa2ce485", - "F19d35f0cf29f4d00db1", - "F19d35f0cf298520786b", - "F19d35f0cf29c27e1310", - "F19d35f0cf29fe7adfe9", - "F19d35f0cf29729478c8", - "F19d35f0cf297771170d", - "F19d35f0cf29f57a8f2f", - "F19d35f0cf295825dfab", - "F19d35f0cf29ae5eaa39" + "1-buddiesplus", + "2-buddiesplus", + "3-buddiesplus", + "4-buddiesplus", + "5-buddiesplus", + "6-buddiesplus", + "7-buddiesplus", + "7p-buddiesplus", + "8-buddiesplus", + "8p-buddiesplus", + "9-buddiesplus", + "9p-buddiesplus", + "10-buddiesplus", + "10p-buddiesplus", + "11-buddiesplus", + "11p-buddiesplus", + "12-buddiesplus", + "12p-buddiesplus", + "13-buddiesplus", + "13p-buddiesplus", + "14-buddiesplus", + "14p-buddiesplus", + "15-buddiesplus" ], "game": "maimaidx", "id": "T19d35f0d5946f88db5f", @@ -5021,29 +5009,29 @@ "default": false, "description": "Levels for maimai DX in BUDDiES.", "folders": [ - "F19d35f0cf2919374455", - "F19d35f0cf29c7757d35", - "F19d35f0cf299ec68d49", - "F19d35f0cf29113e3730", - "F19d35f0cf29b5fe9cd7", - "F19d35f0cf29da103c55", - "F19d35f0cf29f2d69843", - "F19d35f0cf29e7a90b6c", - "F19d35f0cf29ab79438b", - "F19d35f0cf299b369e4a", - "F19d35f0cf2a42aa2407", - "F19d35f0cf2a5f7d1eac", - "F19d35f0cf291db192a4", - "F19d35f0cf294faa2035", - "F19d35f0cf2982c8e37f", - "F19d35f0cf296a70e614", - "F19d35f0cf29f525eeab", - "F19d35f0cf29907e2dac", - "F19d35f0cf2987f2ad62", - "F19d35f0cf29595c2450", - "F19d35f0cf29424c62f6", - "F19d35f0cf2932ab9f35", - "F19d35f0cf293ff24754" + "1-buddies", + "2-buddies", + "3-buddies", + "4-buddies", + "5-buddies", + "6-buddies", + "7-buddies", + "7p-buddies", + "8-buddies", + "8p-buddies", + "9-buddies", + "9p-buddies", + "10-buddies", + "10p-buddies", + "11-buddies", + "11p-buddies", + "12-buddies", + "12p-buddies", + "13-buddies", + "13p-buddies", + "14-buddies", + "14p-buddies", + "15-buddies" ], "game": "maimaidx", "id": "T19d35f0d594c7db5e88", @@ -5055,29 +5043,29 @@ "default": true, "description": "Levels for maimai DX in CiRCLE.", "folders": [ - "F19d35f0cf29c09367bd", - "F19d35f0cf29b6625250", - "F19d35f0cf2932eb35be", - "F19d35f0cf2977240875", - "F19d35f0cf29a1c38c04", - "F19d35f0cf29f8d6de25", - "F19d35f0cf29eb2a9a98", - "F19d35f0cf2945bc639b", - "F19d35f0cf295f0f6537", - "F19d35f0cf29f956be68", - "F19d35f0cf2abfcc0e7f", - "F19d35f0cf2a52e54b98", - "F19d35f0cf296bdb6313", - "F19d35f0cf2975a407f7", - "F19d35f0cf295d8abb87", - "F19d35f0cf297ee2d5bb", - "F19d35f0cf290572a9a6", - "F19d35f0cf296759746b", - "F19d35f0cf29c155ac55", - "F19d35f0cf29d34055dc", - "F19d35f0cf29b75c1d5c", - "F19d35f0cf2970e6f54b", - "F19d35f0cf2995afee5d" + "1-circle", + "2-circle", + "3-circle", + "4-circle", + "5-circle", + "6-circle", + "7-circle", + "7p-circle", + "8-circle", + "8p-circle", + "9-circle", + "9p-circle", + "10-circle", + "10p-circle", + "11-circle", + "11p-circle", + "12-circle", + "12p-circle", + "13-circle", + "13p-circle", + "14-circle", + "14p-circle", + "15-circle" ], "game": "maimaidx", "id": "T19d35f0d594f8fc9bf6", @@ -5089,29 +5077,29 @@ "default": false, "description": "Levels for maimai DX in FESTiVAL PLUS.", "folders": [ - "F19d35f0cf2953f9c0bf", - "F19d35f0cf2942458e6b", - "F19d35f0cf290e84a879", - "F19d35f0cf29e0444821", - "F19d35f0cf2913e6055b", - "F19d35f0cf2957b7d6e4", - "F19d35f0cf294214c547", - "F19d35f0cf294061fa46", - "F19d35f0cf296dcfc505", - "F19d35f0cf29b4501a02", - "F19d35f0cf2aca4b37a3", - "F19d35f0cf2a82630f9e", - "F19d35f0cf2972edbf33", - "F19d35f0cf294dd4f764", - "F19d35f0cf2993b5631d", - "F19d35f0cf29a008ab12", - "F19d35f0cf2989c32742", - "F19d35f0cf296045496c", - "F19d35f0cf299cea8761", - "F19d35f0cf29f2bfe07a", - "F19d35f0cf2941565619", - "F19d35f0cf29f50f80d1", - "F19d35f0cf29919c9826" + "1-festivalplus", + "2-festivalplus", + "3-festivalplus", + "4-festivalplus", + "5-festivalplus", + "6-festivalplus", + "7-festivalplus", + "7p-festivalplus", + "8-festivalplus", + "8p-festivalplus", + "9-festivalplus", + "9p-festivalplus", + "10-festivalplus", + "10p-festivalplus", + "11-festivalplus", + "11p-festivalplus", + "12-festivalplus", + "12p-festivalplus", + "13-festivalplus", + "13p-festivalplus", + "14-festivalplus", + "14p-festivalplus", + "15-festivalplus" ], "game": "maimaidx", "id": "T19d35f0d5940f969e51", @@ -5123,29 +5111,29 @@ "default": false, "description": "Levels for maimai DX in FESTiVAL.", "folders": [ - "F19d35f0cf2997ca1b5e", - "F19d35f0cf29111fab26", - "F19d35f0cf2950233462", - "F19d35f0cf29b8682193", - "F19d35f0cf2938b33ad2", - "F19d35f0cf29f4fafd00", - "F19d35f0cf29333f4ede", - "F19d35f0cf29038f4c91", - "F19d35f0cf29856de80b", - "F19d35f0cf291e165ed2", - "F19d35f0cf2a79c46b25", - "F19d35f0cf2a83a6ff11", - "F19d35f0cf291aa90dcb", - "F19d35f0cf296d12045e", - "F19d35f0cf2956ef85cb", - "F19d35f0cf29c23c6c73", - "F19d35f0cf2965814ce4", - "F19d35f0cf2947e9e8ce", - "F19d35f0cf29ad74d401", - "F19d35f0cf293118cff7", - "F19d35f0cf2920cfe063", - "F19d35f0cf29c68f1784", - "F19d35f0cf2932803e70" + "1-festival", + "2-festival", + "3-festival", + "4-festival", + "5-festival", + "6-festival", + "7-festival", + "7p-festival", + "8-festival", + "8p-festival", + "9-festival", + "9p-festival", + "10-festival", + "10p-festival", + "11-festival", + "11p-festival", + "12-festival", + "12p-festival", + "13-festival", + "13p-festival", + "14-festival", + "14p-festival", + "15-festival" ], "game": "maimaidx", "id": "T19d35f0d594265bddd0", @@ -5157,29 +5145,29 @@ "default": false, "description": "Levels for maimai DX in PRiSM Omnimix.", "folders": [ - "F19d35f0cf29f0e1c761", - "F19d35f0cf2977051c48", - "F19d35f0cf292f2a4fcf", - "F19d35f0cf296801bad0", - "F19d35f0cf29c87232bc", - "F19d35f0cf29e5f733e9", - "F19d35f0cf29a15910aa", - "F19d35f0cf29cf302983", - "F19d35f0cf29fa132af0", - "F19d35f0cf29514fbb72", - "F19d35f0cf2ab7f15e74", - "F19d35f0cf2a3119af2c", - "F19d35f0cf29822f69e5", - "F19d35f0cf293b7f2ed7", - "F19d35f0cf292a7d00bf", - "F19d35f0cf29119cb92a", - "F19d35f0cf294e7dea57", - "F19d35f0cf295c59096e", - "F19d35f0cf29336fa0b9", - "F19d35f0cf29c41b87a4", - "F19d35f0cf2905764244", - "F19d35f0cf298190e14f", - "F19d35f0cf296f28d16e" + "1-prism-omni", + "2-prism-omni", + "3-prism-omni", + "4-prism-omni", + "5-prism-omni", + "6-prism-omni", + "7-prism-omni", + "7p-prism-omni", + "8-prism-omni", + "8p-prism-omni", + "9-prism-omni", + "9p-prism-omni", + "10-prism-omni", + "10p-prism-omni", + "11-prism-omni", + "11p-prism-omni", + "12-prism-omni", + "12p-prism-omni", + "13-prism-omni", + "13p-prism-omni", + "14-prism-omni", + "14p-prism-omni", + "15-prism-omni" ], "game": "maimaidx", "id": "T19d35f0d59484ad6b45", @@ -5191,29 +5179,29 @@ "default": false, "description": "Levels for maimai DX in PRiSM PLUS Omnimix.", "folders": [ - "F19d35f0cf295a7d4a34", - "F19d35f0cf29732bfcae", - "F19d35f0cf297b8a386a", - "F19d35f0cf290c8b1b1e", - "F19d35f0cf29aafbd7ce", - "F19d35f0cf29163ee10c", - "F19d35f0cf29f24dffdd", - "F19d35f0cf2921fa72e8", - "F19d35f0cf29528b9079", - "F19d35f0cf29c667d3e9", - "F19d35f0cf2a66d22d9c", - "F19d35f0cf2ac9ea28aa", - "F19d35f0cf29cda3cf8a", - "F19d35f0cf299dc46983", - "F19d35f0cf2921753da3", - "F19d35f0cf2912c754e9", - "F19d35f0cf290076ee83", - "F19d35f0cf2957c8d6c5", - "F19d35f0cf295bf6946c", - "F19d35f0cf29ec917100", - "F19d35f0cf29465d60b7", - "F19d35f0cf29209c47e7", - "F19d35f0cf29660a2f8e" + "1-prismplus-omni", + "2-prismplus-omni", + "3-prismplus-omni", + "4-prismplus-omni", + "5-prismplus-omni", + "6-prismplus-omni", + "7-prismplus-omni", + "7p-prismplus-omni", + "8-prismplus-omni", + "8p-prismplus-omni", + "9-prismplus-omni", + "9p-prismplus-omni", + "10-prismplus-omni", + "10p-prismplus-omni", + "11-prismplus-omni", + "11p-prismplus-omni", + "12-prismplus-omni", + "12p-prismplus-omni", + "13-prismplus-omni", + "13p-prismplus-omni", + "14-prismplus-omni", + "14p-prismplus-omni", + "15-prismplus-omni" ], "game": "maimaidx", "id": "T19d35f0d59418b03a70", @@ -5225,29 +5213,29 @@ "default": false, "description": "Levels for maimai DX in PRiSM PLUS.", "folders": [ - "F19d35f0cf29f1de7196", - "F19d35f0cf290fd5f8c1", - "F19d35f0cf29812a5e09", - "F19d35f0cf29185917af", - "F19d35f0cf29a071cf6f", - "F19d35f0cf29033750bf", - "F19d35f0cf29b9d60038", - "F19d35f0cf2999cfe3c0", - "F19d35f0cf292579765d", - "F19d35f0cf29d64e6936", - "F19d35f0cf2a0c199d73", - "F19d35f0cf2a03fc5f5e", - "F19d35f0cf29be4779b6", - "F19d35f0cf29bc4aea3f", - "F19d35f0cf294ae4be67", - "F19d35f0cf29fe01256b", - "F19d35f0cf29353536bc", - "F19d35f0cf296526cbae", - "F19d35f0cf29258f8fc1", - "F19d35f0cf291041c104", - "F19d35f0cf2933206f50", - "F19d35f0cf29e3ce32c4", - "F19d35f0cf2926333cc2" + "1-prismplus", + "2-prismplus", + "3-prismplus", + "4-prismplus", + "5-prismplus", + "6-prismplus", + "7-prismplus", + "7p-prismplus", + "8-prismplus", + "8p-prismplus", + "9-prismplus", + "9p-prismplus", + "10-prismplus", + "10p-prismplus", + "11-prismplus", + "11p-prismplus", + "12-prismplus", + "12p-prismplus", + "13-prismplus", + "13p-prismplus", + "14-prismplus", + "14p-prismplus", + "15-prismplus" ], "game": "maimaidx", "id": "T19d35f0d59491359df4", @@ -5259,29 +5247,29 @@ "default": false, "description": "Levels for maimai DX in PRiSM.", "folders": [ - "F19d35f0cf29397a5874", - "F19d35f0cf29c49d1224", - "F19d35f0cf29d1ea8396", - "F19d35f0cf291ee94a06", - "F19d35f0cf29bf6c3dbb", - "F19d35f0cf29222f1f85", - "F19d35f0cf298db3c81a", - "F19d35f0cf29427e5c53", - "F19d35f0cf292d59ac33", - "F19d35f0cf2a15244cae", - "F19d35f0cf2a88c0fb8e", - "F19d35f0cf2a37fdbde9", - "F19d35f0cf293b7c45bd", - "F19d35f0cf295c6bc7b2", - "F19d35f0cf29f5af52c6", - "F19d35f0cf290c47737b", - "F19d35f0cf29199bdf44", - "F19d35f0cf29810e7206", - "F19d35f0cf293c5344e5", - "F19d35f0cf29eb180ab6", - "F19d35f0cf29c4d04aeb", - "F19d35f0cf297825e40e", - "F19d35f0cf292b2808a4" + "1-prism", + "2-prism", + "3-prism", + "4-prism", + "5-prism", + "6-prism", + "7-prism", + "7p-prism", + "8-prism", + "8p-prism", + "9-prism", + "9p-prism", + "10-prism", + "10p-prism", + "11-prism", + "11p-prism", + "12-prism", + "12p-prism", + "13-prism", + "13p-prism", + "14-prism", + "14p-prism", + "15-prism" ], "game": "maimaidx", "id": "T19d35f0d59436bf83bb", @@ -5293,29 +5281,29 @@ "default": false, "description": "Levels for maimai DX in UNiVERSE PLUS.", "folders": [ - "F19d35f0cf296a6da89e", - "F19d35f0cf29e8d69e46", - "F19d35f0cf29e76f0560", - "F19d35f0cf292637cc51", - "F19d35f0cf2980bb5a76", - "F19d35f0cf298a830cb2", - "F19d35f0cf296fb2bf3b", - "F19d35f0cf29821650cc", - "F19d35f0cf29d4766cd1", - "F19d35f0cf2a1cc34d45", - "F19d35f0cf2acbe34804", - "F19d35f0cf2a2f0578c2", - "F19d35f0cf294df0744c", - "F19d35f0cf29bebbeff3", - "F19d35f0cf295fc92891", - "F19d35f0cf29a0affe10", - "F19d35f0cf29c48085ba", - "F19d35f0cf29eb0d473d", - "F19d35f0cf29e98d4bc3", - "F19d35f0cf290850db9d", - "F19d35f0cf29c51f4f15", - "F19d35f0cf2988393ee1", - "F19d35f0cf29865d3981" + "1-universeplus", + "2-universeplus", + "3-universeplus", + "4-universeplus", + "5-universeplus", + "6-universeplus", + "7-universeplus", + "7p-universeplus", + "8-universeplus", + "8p-universeplus", + "9-universeplus", + "9p-universeplus", + "10-universeplus", + "10p-universeplus", + "11-universeplus", + "11p-universeplus", + "12-universeplus", + "12p-universeplus", + "13-universeplus", + "13p-universeplus", + "14-universeplus", + "14p-universeplus", + "15-universeplus" ], "game": "maimaidx", "id": "T19d35f0d59471e4a1eb", @@ -5327,22 +5315,22 @@ "default": true, "description": "Levels for MÚSECA in 1 + 1/2 Rev. B.", "folders": [ - "F19d35f0cf2a386d08b2", - "F19d35f0cf2a64be3fba", - "F19d35f0cf2a827a7d97", - "F19d35f0cf2ad7624da1", - "F19d35f0cf2a7fafef13", - "F19d35f0cf2a117bffde", - "F19d35f0cf2ab398c79a", - "F19d35f0cf2a66280a02", - "F19d35f0cf2ad6722ecb", - "F19d35f0cf2ae9c66857", - "F19d35f0cf2a5fbfe8af", - "F19d35f0cf2a5399c930", - "F19d35f0cf2a76473f85", - "F19d35f0cf2ab229bd49", - "F19d35f0cf2a9bac65d9", - "F19d35f0cf2af0957f2f" + "1-1-5-b", + "2-1-5-b", + "3-1-5-b", + "4-1-5-b", + "5-1-5-b", + "6-1-5-b", + "7-1-5-b", + "8-1-5-b", + "9-1-5-b", + "10-1-5-b", + "11-1-5-b", + "12-1-5-b", + "13-1-5-b", + "14-1-5-b", + "15-1-5-b", + "16-1-5-b" ], "game": "museca", "id": "T19d35f0d5949a6c53ec", @@ -5354,22 +5342,22 @@ "default": false, "description": "Levels for MÚSECA in 1 + 1/2.", "folders": [ - "F19d35f0cf2a9c482eaa", - "F19d35f0cf2a03b59188", - "F19d35f0cf2a9bdeed92", - "F19d35f0cf2a3a96be6b", - "F19d35f0cf2ac2b7fe93", - "F19d35f0cf2aa18659a8", - "F19d35f0cf2a78ab538c", - "F19d35f0cf2adb42d2da", - "F19d35f0cf2a4eb1b32e", - "F19d35f0cf2a994d95f9", - "F19d35f0cf2a44a35917", - "F19d35f0cf2af40fa4cf", - "F19d35f0cf2a0514b9ae", - "F19d35f0cf2a9aa85e0a", - "F19d35f0cf2ab92db35d", - "F19d35f0cf2a0ab17ba0" + "1-1-5", + "2-1-5", + "3-1-5", + "4-1-5", + "5-1-5", + "6-1-5", + "7-1-5", + "8-1-5", + "9-1-5", + "10-1-5", + "11-1-5", + "12-1-5", + "13-1-5", + "14-1-5", + "15-1-5", + "16-1-5" ], "game": "museca", "id": "T19d35f0d594b0502622", @@ -5381,31 +5369,31 @@ "default": false, "description": "Levels for O.N.G.E.K.I. in bright MEMORY Act.2 Omnimix.", "folders": [ - "F19d35f0cf2a6ec70b74", - "F19d35f0cf2afcb9c701", - "F19d35f0cf2a205364e7", - "F19d35f0cf2a3fd07dd1", - "F19d35f0cf2aeed1ca4e", - "F19d35f0cf2a511a6556", - "F19d35f0cf2a4af342e9", - "F19d35f0cf2a0780461d", - "F19d35f0cf2acc862a29", - "F19d35f0cf2a808aef44", - "F19d35f0cf2a82bc5ca0", - "F19d35f0cf2a7ddfb62f", - "F19d35f0cf2a97d5b345", - "F19d35f0cf2aed1acd75", - "F19d35f0cf2a01b11e67", - "F19d35f0cf2a13e0a747", - "F19d35f0cf2a16012464", - "F19d35f0cf2ad202dfe2", - "F19d35f0cf2a34f803d7", - "F19d35f0cf2ad63fd2be", - "F19d35f0cf2a877802db", - "F19d35f0cf2acc262b06", - "F19d35f0cf2a86615583", - "F19d35f0cf2a627a466a", - "F19d35f0cf2a938c2ef5" + "0-brightmemory2omni", + "1-brightmemory2omni", + "2-brightmemory2omni", + "3-brightmemory2omni", + "4-brightmemory2omni", + "5-brightmemory2omni", + "6-brightmemory2omni", + "7-brightmemory2omni", + "7p-brightmemory2omni", + "8-brightmemory2omni", + "8p-brightmemory2omni", + "9-brightmemory2omni", + "9p-brightmemory2omni", + "10-brightmemory2omni", + "10p-brightmemory2omni", + "11-brightmemory2omni", + "11p-brightmemory2omni", + "12-brightmemory2omni", + "12p-brightmemory2omni", + "13-brightmemory2omni", + "13p-brightmemory2omni", + "14-brightmemory2omni", + "14p-brightmemory2omni", + "15-brightmemory2omni", + "15p-brightmemory2omni" ], "game": "ongeki", "id": "T19d35f0d5940f720333", @@ -5417,11 +5405,11 @@ "default": false, "description": "Difficulties for O.N.G.E.K.I. in bright MEMORY Act.2 Omnimix.", "folders": [ - "F19d35f0cf2a698af3f2", - "F19d35f0cf2aa233767a", - "F19d35f0cf2ad70b6c96", - "F19d35f0cf2a30d3ea61", - "F19d35f0cf2a63fb93a9" + "brightmemory2omni-basic", + "brightmemory2omni-advanced", + "brightmemory2omni-expert", + "brightmemory2omni-master", + "brightmemory2omni-lunatic" ], "game": "ongeki", "id": "T19d35f0d5940a0ec4b0", @@ -5433,31 +5421,31 @@ "default": false, "description": "Levels for O.N.G.E.K.I. in bright MEMORY Act.3 Omnimix.", "folders": [ - "F19d35f0cf2a9d8ac26a", - "F19d35f0cf2a3ef6fe38", - "F19d35f0cf2abebce6e5", - "F19d35f0cf2a758afa6c", - "F19d35f0cf2a2e9531de", - "F19d35f0cf2af091f74b", - "F19d35f0cf2ab98118d2", - "F19d35f0cf2a5f8089c6", - "F19d35f0cf2a5abdbef4", - "F19d35f0cf2a325683af", - "F19d35f0cf2af99a1abf", - "F19d35f0cf2a639302f6", - "F19d35f0cf2a4bee8195", - "F19d35f0cf2a1647d2b3", - "F19d35f0cf2a5455c46e", - "F19d35f0cf2a84c87002", - "F19d35f0cf2a7d3e7ca9", - "F19d35f0cf2a32277868", - "F19d35f0cf2ae60c7a83", - "F19d35f0cf2a1a38037b", - "F19d35f0cf2a7b3db360", - "F19d35f0cf2a17f6044a", - "F19d35f0cf2a8cd0417c", - "F19d35f0cf2a0cdeeac9", - "F19d35f0cf2a7c814cb2" + "0-brightmemory3omni", + "1-brightmemory3omni", + "2-brightmemory3omni", + "3-brightmemory3omni", + "4-brightmemory3omni", + "5-brightmemory3omni", + "6-brightmemory3omni", + "7-brightmemory3omni", + "7p-brightmemory3omni", + "8-brightmemory3omni", + "8p-brightmemory3omni", + "9-brightmemory3omni", + "9p-brightmemory3omni", + "10-brightmemory3omni", + "10p-brightmemory3omni", + "11-brightmemory3omni", + "11p-brightmemory3omni", + "12-brightmemory3omni", + "12p-brightmemory3omni", + "13-brightmemory3omni", + "13p-brightmemory3omni", + "14-brightmemory3omni", + "14p-brightmemory3omni", + "15-brightmemory3omni", + "15p-brightmemory3omni" ], "game": "ongeki", "id": "T19d35f0d59478c1a928", @@ -5469,12 +5457,12 @@ "default": false, "description": "Difficulties for O.N.G.E.K.I. in bright MEMORY Act.3 Omnimix.", "folders": [ - "F19d35f0cf2ac0ccdb05", - "F19d35f0cf2a2517224c", - "F19d35f0cf2a7d25ff07", - "F19d35f0cf2a8fd9d199", - "F19d35f0cf2ab96e2e15", - "F19d35f0cf2a327475e2" + "brightmemory3omni-basic", + "brightmemory3omni-advanced", + "brightmemory3omni-expert", + "brightmemory3omni-master", + "brightmemory3omni-lunatic", + "brightmemory3omni-lunatic-remaster" ], "game": "ongeki", "id": "T19d35f0d594844feb44", @@ -5486,31 +5474,31 @@ "default": false, "description": "Levels for O.N.G.E.K.I. in bright MEMORY Act.3.", "folders": [ - "F19d35f0cf2a56cbd48e", - "F19d35f0cf2a205905c1", - "F19d35f0cf2a73933ef8", - "F19d35f0cf2a0e7c6539", - "F19d35f0cf2a0d58f4e5", - "F19d35f0cf2a4b6fdaaa", - "F19d35f0cf2a7f25fa2f", - "F19d35f0cf2a5a05ac14", - "F19d35f0cf2a7af90d79", - "F19d35f0cf2ab0d39086", - "F19d35f0cf2a18ca48bd", - "F19d35f0cf2ad96e4764", - "F19d35f0cf2a2a013b38", - "F19d35f0cf2ac9a1ddf4", - "F19d35f0cf2a3f9c3f55", - "F19d35f0cf2a5f4022dd", - "F19d35f0cf2a4921cd21", - "F19d35f0cf2a32296ab0", - "F19d35f0cf2a27ac593e", - "F19d35f0cf2aa3126c59", - "F19d35f0cf2a3308b790", - "F19d35f0cf2abe47e100", - "F19d35f0cf2a066589ce", - "F19d35f0cf2a181b0476", - "F19d35f0cf2af0f8f671" + "0-brightmemory3", + "1-brightmemory3", + "2-brightmemory3", + "3-brightmemory3", + "4-brightmemory3", + "5-brightmemory3", + "6-brightmemory3", + "7-brightmemory3", + "7p-brightmemory3", + "8-brightmemory3", + "8p-brightmemory3", + "9-brightmemory3", + "9p-brightmemory3", + "10-brightmemory3", + "10p-brightmemory3", + "11-brightmemory3", + "11p-brightmemory3", + "12-brightmemory3", + "12p-brightmemory3", + "13-brightmemory3", + "13p-brightmemory3", + "14-brightmemory3", + "14p-brightmemory3", + "15-brightmemory3", + "15p-brightmemory3" ], "game": "ongeki", "id": "T19d35f0d594101a1c41", @@ -5522,12 +5510,12 @@ "default": false, "description": "Difficulties for O.N.G.E.K.I. in bright MEMORY Act.3.", "folders": [ - "F19d35f0cf2a664c2b6a", - "F19d35f0cf2abcdb47d7", - "F19d35f0cf2a7d17aadf", - "F19d35f0cf2abd25a5f2", - "F19d35f0cf2a8797b916", - "F19d35f0cf2ab786e59a" + "brightmemory3-basic", + "brightmemory3-advanced", + "brightmemory3-expert", + "brightmemory3-master", + "brightmemory3-lunatic", + "brightmemory3-lunatic-remaster" ], "game": "ongeki", "id": "T19d35f0d594438ae257", @@ -5539,31 +5527,31 @@ "default": false, "description": "Levels for O.N.G.E.K.I. in Re:Fresh Omnimix.", "folders": [ - "F19d35f0cf2a097333b4", - "F19d35f0cf2af22ffa6c", - "F19d35f0cf2a7bae3bee", - "F19d35f0cf2aafd6da8d", - "F19d35f0cf2ab1cfb282", - "F19d35f0cf2a84f606cd", - "F19d35f0cf2a2aa64c1b", - "F19d35f0cf2a199da62f", - "F19d35f0cf2a03ecf01a", - "F19d35f0cf2ac7b685ce", - "F19d35f0cf2a1039e3ab", - "F19d35f0cf2a92e70122", - "F19d35f0cf2abe78b5e9", - "F19d35f0cf2afb5717d2", - "F19d35f0cf2a310096c2", - "F19d35f0cf2a6a9e4956", - "F19d35f0cf2a047fa4c7", - "F19d35f0cf2a9c916cd2", - "F19d35f0cf2a3dc2cdf9", - "F19d35f0cf2a4122578c", - "F19d35f0cf2ad22e2a04", - "F19d35f0cf2afb6a92b1", - "F19d35f0cf2a726dad0f", - "F19d35f0cf2ab08cee24", - "F19d35f0cf2abfa76aae" + "0-refreshomni", + "1-refreshomni", + "2-refreshomni", + "3-refreshomni", + "4-refreshomni", + "5-refreshomni", + "6-refreshomni", + "7-refreshomni", + "7p-refreshomni", + "8-refreshomni", + "8p-refreshomni", + "9-refreshomni", + "9p-refreshomni", + "10-refreshomni", + "10p-refreshomni", + "11-refreshomni", + "11p-refreshomni", + "12-refreshomni", + "12p-refreshomni", + "13-refreshomni", + "13p-refreshomni", + "14-refreshomni", + "14p-refreshomni", + "15-refreshomni", + "15p-refreshomni" ], "game": "ongeki", "id": "T19d35f0d594a374ff23", @@ -5575,12 +5563,12 @@ "default": false, "description": "Difficulties for O.N.G.E.K.I. in Re:Fresh Omnimix.", "folders": [ - "F19d35f0cf2a8bf3f8ef", - "F19d35f0cf2ad80dbc4c", - "F19d35f0cf2a979d37c3", - "F19d35f0cf2a90610250", - "F19d35f0cf2ab6ca24a3", - "F19d35f0cf2aa97efe91" + "refreshomni-basic", + "refreshomni-advanced", + "refreshomni-expert", + "refreshomni-master", + "refreshomni-lunatic", + "refreshomni-lunatic-remaster" ], "game": "ongeki", "id": "T19d35f0d594996cd082", @@ -5592,31 +5580,31 @@ "default": true, "description": "Levels for O.N.G.E.K.I. in Re:Fresh.", "folders": [ - "F19d35f0cf2a26ad5a15", - "F19d35f0cf2a5bf9c4dc", - "F19d35f0cf2a51e22457", - "F19d35f0cf2a13cd093d", - "F19d35f0cf2a9b2e5cf8", - "F19d35f0cf2ac52219d0", - "F19d35f0cf2a62272a84", - "F19d35f0cf2a12b1bc97", - "F19d35f0cf2a9271410c", - "F19d35f0cf2a3e4190c2", - "F19d35f0cf2a1a77959d", - "F19d35f0cf2a77afaa57", - "F19d35f0cf2a19b12a47", - "F19d35f0cf2a288180e2", - "F19d35f0cf2a86fbf5ad", - "F19d35f0cf2a87cb1851", - "F19d35f0cf2aaf1a56fb", - "F19d35f0cf2a476f46bf", - "F19d35f0cf2a9b3f96cb", - "F19d35f0cf2ac4a94a34", - "F19d35f0cf2a0a787121", - "F19d35f0cf2a7f6e4423", - "F19d35f0cf2a7462c44b", - "F19d35f0cf2a72996e0a", - "F19d35f0cf2a7fb89a88" + "0-refresh", + "1-refresh", + "2-refresh", + "3-refresh", + "4-refresh", + "5-refresh", + "6-refresh", + "7-refresh", + "7p-refresh", + "8-refresh", + "8p-refresh", + "9-refresh", + "9p-refresh", + "10-refresh", + "10p-refresh", + "11-refresh", + "11p-refresh", + "12-refresh", + "12p-refresh", + "13-refresh", + "13p-refresh", + "14-refresh", + "14p-refresh", + "15-refresh", + "15p-refresh" ], "game": "ongeki", "id": "T19d35f0d5942edb68de", @@ -5628,12 +5616,12 @@ "default": false, "description": "Difficulties for O.N.G.E.K.I. in Re:Fresh.", "folders": [ - "F19d35f0cf2af76882cb", - "F19d35f0cf2ab62c23d4", - "F19d35f0cf2aeec96913", - "F19d35f0cf2a1a55f426", - "F19d35f0cf2aa17a39b1", - "F19d35f0cf2a28977612" + "refresh-basic", + "refresh-advanced", + "refresh-expert", + "refresh-master", + "refresh-lunatic", + "refresh-lunatic-remaster" ], "game": "ongeki", "id": "T19d35f0d5941ed3780c", @@ -5645,26 +5633,26 @@ "default": true, "description": "The Insane PMS Table. This table starts around pop'n level 46.", "folders": [ - "F19d35f0cf2ab06c4680", - "F19d35f0cf2aa15bcd44", - "F19d35f0cf2a647ab0ba", - "F19d35f0cf2a60c4119b", - "F19d35f0cf2afc0cc0fd", - "F19d35f0cf2ac433885b", - "F19d35f0cf2a8290a465", - "F19d35f0cf2a20c9a9b7", - "F19d35f0cf2a5b7f9334", - "F19d35f0cf2a0872dce3", - "F19d35f0cf2aa1fca42e", - "F19d35f0cf2a0cb92469", - "F19d35f0cf2a5c4c11e6", - "F19d35f0cf2afd750feb", - "F19d35f0cf2a51c32105", - "F19d35f0cf2a694245a7", - "F19d35f0cf2aacf65b8b", - "F19d35f0cf2a9a4155c9", - "F19d35f0cf2a66ad2e78", - "F19d35f0cf2a77535243" + "tf-pmscircle-1", + "tf-pmscircle-2", + "tf-pmscircle-3", + "tf-pmscircle-4", + "tf-pmscircle-5", + "tf-pmscircle-6", + "tf-pmscircle-7", + "tf-pmscircle-8", + "tf-pmscircle-9", + "tf-pmscircle-1-0", + "tf-pmscircle-1-1", + "tf-pmscircle-1-2", + "tf-pmscircle-1-3", + "tf-pmscircle-1-4", + "tf-pmscircle-1-5", + "tf-pmscircle-1-6", + "tf-pmscircle-1-7", + "tf-pmscircle-1-8", + "tf-pmscircle-1-9", + "tf-pmscircle-excl-i" ], "game": "pms-controller", "id": "T19d35f0d59408d60d8f", @@ -5676,30 +5664,30 @@ "default": false, "description": "The Insane PMS Database. This contains almost every PMS file ever created. This table starts around pop'n level 46.", "folders": [ - "F19d35f0cf2ac6cd8240", - "F19d35f0cf2a69f92816", - "F19d35f0cf2a4ac5cc27", - "F19d35f0cf2a8943ca36", - "F19d35f0cf2aaede1a01", - "F19d35f0cf2a62433ac2", - "F19d35f0cf2af99cfe8d", - "F19d35f0cf2a37c9022f", - "F19d35f0cf2a096b9781", - "F19d35f0cf2a2181b308", - "F19d35f0cf2a872f9bf6", - "F19d35f0cf2ad722907e", - "F19d35f0cf2a01b7fb55", - "F19d35f0cf2aa52c668e", - "F19d35f0cf2aa175fad4", - "F19d35f0cf2a0a51a7ab", - "F19d35f0cf2a1f314e00", - "F19d35f0cf2a6e36d0fa", - "F19d35f0cf2a0da90d1c", - "F19d35f0cf2a0fed7ae0", - "F19d35f0cf2afe96afaf", - "F19d35f0cf2a3a318c04", - "F19d35f0cf2a86a5d35a", - "F19d35f0cf2ae3035f25" + "tf-pmspstar-1", + "tf-pmspstar-2", + "tf-pmspstar-3", + "tf-pmspstar-4", + "tf-pmspstar-5", + "tf-pmspstar-6", + "tf-pmspstar-7", + "tf-pmspstar-8", + "tf-pmspstar-9", + "tf-pmspstar-1-0", + "tf-pmspstar-1-1", + "tf-pmspstar-1-2", + "tf-pmspstar-1-3", + "tf-pmspstar-1-4", + "tf-pmspstar-1-5", + "tf-pmspstar-1-6", + "tf-pmspstar-1-7", + "tf-pmspstar-1-8", + "tf-pmspstar-1-9", + "tf-pmspstar-2-0", + "tf-pmspstar-excl-i", + "tf-pmspstar-uff1f", + "tf-pmspstar-u25c6", + "tf-pmspstar-uff5e" ], "game": "pms-controller", "id": "T19d35f0d594e1fdbc77", @@ -5711,53 +5699,53 @@ "default": false, "description": "The Normal PMS Database. This contains almost every PMS file ever created, and is equivalent to pop'n levels 1-45.", "folders": [ - "F19d35f0cf2aa411b6b6", - "F19d35f0cf2a8b9c6c4d", - "F19d35f0cf2a90478c2d", - "F19d35f0cf2a294900d9", - "F19d35f0cf2ab516ea29", - "F19d35f0cf2a90d49225", - "F19d35f0cf2a2102e249", - "F19d35f0cf2aafb7ad68", - "F19d35f0cf2a65071ae2", - "F19d35f0cf2a27c48b55", - "F19d35f0cf2a7ba2671b", - "F19d35f0cf2ac05c2f79", - "F19d35f0cf2a395ec87d", - "F19d35f0cf2a0d603f55", - "F19d35f0cf2adab19a33", - "F19d35f0cf2a8dc2b00a", - "F19d35f0cf2a90c85f31", - "F19d35f0cf2a10d0870e", - "F19d35f0cf2ac413f7f6", - "F19d35f0cf2ab3fbe7fe", - "F19d35f0cf2ad8fbd7c9", - "F19d35f0cf2ae87487cf", - "F19d35f0cf2a434ff117", - "F19d35f0cf2ad283fab5", - "F19d35f0cf2a861a5fa2", - "F19d35f0cf2a97bfaaae", - "F19d35f0cf2a8897ad90", - "F19d35f0cf2ac4162ae3", - "F19d35f0cf2afd28d20f", - "F19d35f0cf2a9241c7f3", - "F19d35f0cf2a7851114d", - "F19d35f0cf2a761cc01a", - "F19d35f0cf2af5a73106", - "F19d35f0cf2ae3010e89", - "F19d35f0cf2a1af24f06", - "F19d35f0cf2a580c0f38", - "F19d35f0cf2a000edc1c", - "F19d35f0cf2a6eb258ed", - "F19d35f0cf2a4145d452", - "F19d35f0cf2a6d9a39da", - "F19d35f0cf2a02381443", - "F19d35f0cf2a7dcaf3c9", - "F19d35f0cf2a86416ce7", - "F19d35f0cf2ad3529dbb", - "F19d35f0cf2a398cf5c1", - "F19d35f0cf2ac314dd57", - "F19d35f0cf2a0f38052a" + "tf-pmsplv-1", + "tf-pmsplv-2", + "tf-pmsplv-3", + "tf-pmsplv-4", + "tf-pmsplv-5", + "tf-pmsplv-6", + "tf-pmsplv-7", + "tf-pmsplv-8", + "tf-pmsplv-9", + "tf-pmsplv-1-0", + "tf-pmsplv-1-1", + "tf-pmsplv-1-2", + "tf-pmsplv-1-3", + "tf-pmsplv-1-4", + "tf-pmsplv-1-5", + "tf-pmsplv-1-6", + "tf-pmsplv-1-7", + "tf-pmsplv-1-8", + "tf-pmsplv-1-9", + "tf-pmsplv-2-0", + "tf-pmsplv-2-1", + "tf-pmsplv-2-2", + "tf-pmsplv-2-3", + "tf-pmsplv-2-4", + "tf-pmsplv-2-5", + "tf-pmsplv-2-6", + "tf-pmsplv-2-7", + "tf-pmsplv-2-8", + "tf-pmsplv-2-9", + "tf-pmsplv-3-0", + "tf-pmsplv-3-1", + "tf-pmsplv-3-2", + "tf-pmsplv-3-3", + "tf-pmsplv-3-4", + "tf-pmsplv-3-5", + "tf-pmsplv-3-6", + "tf-pmsplv-3-7", + "tf-pmsplv-3-8", + "tf-pmsplv-3-9", + "tf-pmsplv-4-0", + "tf-pmsplv-4-1", + "tf-pmsplv-4-2", + "tf-pmsplv-4-3", + "tf-pmsplv-4-4", + "tf-pmsplv-4-5", + "tf-pmsplv-4-5-p", + "tf-pmsplv-q" ], "game": "pms-controller", "id": "T19d35f0d5949124e3b1", @@ -5769,26 +5757,26 @@ "default": true, "description": "The Insane PMS Table. This table starts around pop'n level 46.", "folders": [ - "F19d35f0cf2aa3b5f2ea", - "F19d35f0cf2ac488a0ce", - "F19d35f0cf2a0d4797c9", - "F19d35f0cf2af109cbda", - "F19d35f0cf2ae8e08379", - "F19d35f0cf2ab769c713", - "F19d35f0cf2af4886085", - "F19d35f0cf2a3dfb3660", - "F19d35f0cf2ade1f4318", - "F19d35f0cf2a5895bf1b", - "F19d35f0cf2a813dcb0f", - "F19d35f0cf2ab7fe3e8e", - "F19d35f0cf2ae60dc5cf", - "F19d35f0cf2a64573017", - "F19d35f0cf2acd34725a", - "F19d35f0cf2ac9e37b28", - "F19d35f0cf2ac4ea33a2", - "F19d35f0cf2add57a1c9", - "F19d35f0cf2a398e4a10", - "F19d35f0cf2aeb35144d" + "tf-pmscircle-1", + "tf-pmscircle-2", + "tf-pmscircle-3", + "tf-pmscircle-4", + "tf-pmscircle-5", + "tf-pmscircle-6", + "tf-pmscircle-7", + "tf-pmscircle-8", + "tf-pmscircle-9", + "tf-pmscircle-1-0", + "tf-pmscircle-1-1", + "tf-pmscircle-1-2", + "tf-pmscircle-1-3", + "tf-pmscircle-1-4", + "tf-pmscircle-1-5", + "tf-pmscircle-1-6", + "tf-pmscircle-1-7", + "tf-pmscircle-1-8", + "tf-pmscircle-1-9", + "tf-pmscircle-excl-i" ], "game": "pms-keyboard", "id": "T19d35f0d59413b93adf", @@ -5800,30 +5788,30 @@ "default": false, "description": "The Insane PMS Database. This contains almost every PMS file ever created. This table starts around pop'n level 46.", "folders": [ - "F19d35f0cf2a8fb040c1", - "F19d35f0cf2acb96fd0c", - "F19d35f0cf2a54a41eaf", - "F19d35f0cf2af0f275bf", - "F19d35f0cf2a21d88434", - "F19d35f0cf2a51ebe2c2", - "F19d35f0cf2afd00fcbb", - "F19d35f0cf2a5fa5c83d", - "F19d35f0cf2adf34a088", - "F19d35f0cf2af0e333f0", - "F19d35f0cf2add5e6e3e", - "F19d35f0cf2a9e0b8c50", - "F19d35f0cf2a2fa205ab", - "F19d35f0cf2ae93d22a8", - "F19d35f0cf2afc49efb2", - "F19d35f0cf2a98695eb1", - "F19d35f0cf2a38d8dc80", - "F19d35f0cf2a84df8bb0", - "F19d35f0cf2a1d4ddebe", - "F19d35f0cf2a3340cf83", - "F19d35f0cf2a433df495", - "F19d35f0cf2a6042af24", - "F19d35f0cf2a8cf82050", - "F19d35f0cf2a05d2ce8c" + "tf-pmspstar-1", + "tf-pmspstar-2", + "tf-pmspstar-3", + "tf-pmspstar-4", + "tf-pmspstar-5", + "tf-pmspstar-6", + "tf-pmspstar-7", + "tf-pmspstar-8", + "tf-pmspstar-9", + "tf-pmspstar-1-0", + "tf-pmspstar-1-1", + "tf-pmspstar-1-2", + "tf-pmspstar-1-3", + "tf-pmspstar-1-4", + "tf-pmspstar-1-5", + "tf-pmspstar-1-6", + "tf-pmspstar-1-7", + "tf-pmspstar-1-8", + "tf-pmspstar-1-9", + "tf-pmspstar-2-0", + "tf-pmspstar-excl-i", + "tf-pmspstar-uff1f", + "tf-pmspstar-u25c6", + "tf-pmspstar-uff5e" ], "game": "pms-keyboard", "id": "T19d35f0d594b12e0448", @@ -5835,53 +5823,53 @@ "default": false, "description": "The Normal PMS Database. This contains almost every PMS file ever created, and is equivalent to pop'n levels 1-45.", "folders": [ - "F19d35f0cf2aae88384d", - "F19d35f0cf2af382a28a", - "F19d35f0cf2a6f9c5166", - "F19d35f0cf2a6a3f786e", - "F19d35f0cf2adc1d03fa", - "F19d35f0cf2a56d2178e", - "F19d35f0cf2abb14ce10", - "F19d35f0cf2a67a005ed", - "F19d35f0cf2a5cf9ea34", - "F19d35f0cf2a0ec8e15b", - "F19d35f0cf2a27f6a4f9", - "F19d35f0cf2a7ebd48de", - "F19d35f0cf2acdbb8f87", - "F19d35f0cf2a77cc95b1", - "F19d35f0cf2a59181470", - "F19d35f0cf2a2defbfaa", - "F19d35f0cf2a32a9387c", - "F19d35f0cf2a34dfb173", - "F19d35f0cf2a1789b4c3", - "F19d35f0cf2a260ef658", - "F19d35f0cf2ae2e6a0a8", - "F19d35f0cf2ac7158f3f", - "F19d35f0cf2a6b416fe3", - "F19d35f0cf2a78d3d4d3", - "F19d35f0cf2ab29440d3", - "F19d35f0cf2a4ffb3333", - "F19d35f0cf2a12f254bf", - "F19d35f0cf2af868cfbb", - "F19d35f0cf2ac5ca5c91", - "F19d35f0cf2a25c9a459", - "F19d35f0cf2afbdc332c", - "F19d35f0cf2abec6c814", - "F19d35f0cf2aabdaaa63", - "F19d35f0cf2af7cc9e57", - "F19d35f0cf2ad21876de", - "F19d35f0cf2a5cd6b6af", - "F19d35f0cf2a92c2db25", - "F19d35f0cf2aee9eb925", - "F19d35f0cf2a854ff0b9", - "F19d35f0cf2a7d9cf3a8", - "F19d35f0cf2ab8ea5d1a", - "F19d35f0cf2aaa16e731", - "F19d35f0cf2abcce35b3", - "F19d35f0cf2a6324ec94", - "F19d35f0cf2a867c48e0", - "F19d35f0cf2a9e3ff276", - "F19d35f0cf2a9f4a8783" + "tf-pmsplv-1", + "tf-pmsplv-2", + "tf-pmsplv-3", + "tf-pmsplv-4", + "tf-pmsplv-5", + "tf-pmsplv-6", + "tf-pmsplv-7", + "tf-pmsplv-8", + "tf-pmsplv-9", + "tf-pmsplv-1-0", + "tf-pmsplv-1-1", + "tf-pmsplv-1-2", + "tf-pmsplv-1-3", + "tf-pmsplv-1-4", + "tf-pmsplv-1-5", + "tf-pmsplv-1-6", + "tf-pmsplv-1-7", + "tf-pmsplv-1-8", + "tf-pmsplv-1-9", + "tf-pmsplv-2-0", + "tf-pmsplv-2-1", + "tf-pmsplv-2-2", + "tf-pmsplv-2-3", + "tf-pmsplv-2-4", + "tf-pmsplv-2-5", + "tf-pmsplv-2-6", + "tf-pmsplv-2-7", + "tf-pmsplv-2-8", + "tf-pmsplv-2-9", + "tf-pmsplv-3-0", + "tf-pmsplv-3-1", + "tf-pmsplv-3-2", + "tf-pmsplv-3-3", + "tf-pmsplv-3-4", + "tf-pmsplv-3-5", + "tf-pmsplv-3-6", + "tf-pmsplv-3-7", + "tf-pmsplv-3-8", + "tf-pmsplv-3-9", + "tf-pmsplv-4-0", + "tf-pmsplv-4-1", + "tf-pmsplv-4-2", + "tf-pmsplv-4-3", + "tf-pmsplv-4-4", + "tf-pmsplv-4-5", + "tf-pmsplv-4-5-p", + "tf-pmsplv-q" ], "game": "pms-keyboard", "id": "T19d35f0d594b3a4f78e", @@ -5893,56 +5881,56 @@ "default": false, "description": "All pop'n Kaimei Riddles levels individually.", "folders": [ - "F19d35f0cf2a8d99bbcc", - "F19d35f0cf2a65d1c125", - "F19d35f0cf2ac3d63e74", - "F19d35f0cf2ad37780fb", - "F19d35f0cf2ae697f9ff", - "F19d35f0cf2ab929dc80", - "F19d35f0cf2ac5a7acdc", - "F19d35f0cf2aa285aa9f", - "F19d35f0cf2a9fda006a", - "F19d35f0cf2aee3d38a1", - "F19d35f0cf2a009c762d", - "F19d35f0cf2a2e7ab45b", - "F19d35f0cf2ad240bc82", - "F19d35f0cf2a2c660f41", - "F19d35f0cf2a8fa8aa41", - "F19d35f0cf2a2ab64a05", - "F19d35f0cf2a3ddca401", - "F19d35f0cf2ac19a8a0e", - "F19d35f0cf2afe12628d", - "F19d35f0cf2afc54b488", - "F19d35f0cf2aaaaf96c4", - "F19d35f0cf2a8033ddc6", - "F19d35f0cf2a9dd85142", - "F19d35f0cf2aa326af5b", - "F19d35f0cf2a0841e4a5", - "F19d35f0cf2a14b84aa4", - "F19d35f0cf2a171ef1e7", - "F19d35f0cf2a31b78c0b", - "F19d35f0cf2ad5863482", - "F19d35f0cf2a8b77af3e", - "F19d35f0cf2a0c3e0c4e", - "F19d35f0cf2a775e12ff", - "F19d35f0cf2abcb7fb72", - "F19d35f0cf2abb8e1889", - "F19d35f0cf2a417132a1", - "F19d35f0cf2a522de128", - "F19d35f0cf2a5823e812", - "F19d35f0cf2aa7aff75d", - "F19d35f0cf2a05f74462", - "F19d35f0cf2ae5012983", - "F19d35f0cf2a0ca09c76", - "F19d35f0cf2a7488f775", - "F19d35f0cf2ae112a6d6", - "F19d35f0cf2ad573312c", - "F19d35f0cf2af8f23285", - "F19d35f0cf2a42396486", - "F19d35f0cf2ab14491ad", - "F19d35f0cf2a9a48a71e", - "F19d35f0cf2a790622d6", - "F19d35f0cf2af7e6fc1f" + "1-kaimei", + "2-kaimei", + "3-kaimei", + "4-kaimei", + "5-kaimei", + "6-kaimei", + "7-kaimei", + "8-kaimei", + "9-kaimei", + "10-kaimei", + "11-kaimei", + "12-kaimei", + "13-kaimei", + "14-kaimei", + "15-kaimei", + "16-kaimei", + "17-kaimei", + "18-kaimei", + "19-kaimei", + "20-kaimei", + "21-kaimei", + "22-kaimei", + "23-kaimei", + "24-kaimei", + "25-kaimei", + "26-kaimei", + "27-kaimei", + "28-kaimei", + "29-kaimei", + "30-kaimei", + "31-kaimei", + "32-kaimei", + "33-kaimei", + "34-kaimei", + "35-kaimei", + "36-kaimei", + "37-kaimei", + "38-kaimei", + "39-kaimei", + "40-kaimei", + "41-kaimei", + "42-kaimei", + "43-kaimei", + "44-kaimei", + "45-kaimei", + "46-kaimei", + "47-kaimei", + "48-kaimei", + "49-kaimei", + "50-kaimei" ], "game": "popn", "id": "T19d35f0d59410584b69", @@ -5954,30 +5942,30 @@ "default": true, "description": "All pop'n kaimei levels, with some folders joined together.", "folders": [ - "F19d35f0cf2ac9be574c", - "F19d35f0cf2ac7f59287", - "F19d35f0cf2a3d606c0a", - "F19d35f0cf2a8b77af3e", - "F19d35f0cf2a0c3e0c4e", - "F19d35f0cf2a775e12ff", - "F19d35f0cf2abcb7fb72", - "F19d35f0cf2abb8e1889", - "F19d35f0cf2a417132a1", - "F19d35f0cf2a522de128", - "F19d35f0cf2a5823e812", - "F19d35f0cf2aa7aff75d", - "F19d35f0cf2a05f74462", - "F19d35f0cf2ae5012983", - "F19d35f0cf2a0ca09c76", - "F19d35f0cf2a7488f775", - "F19d35f0cf2ae112a6d6", - "F19d35f0cf2ad573312c", - "F19d35f0cf2af8f23285", - "F19d35f0cf2a42396486", - "F19d35f0cf2ab14491ad", - "F19d35f0cf2a9a48a71e", - "F19d35f0cf2a790622d6", - "F19d35f0cf2af7e6fc1f" + "ge-1-le-9-kaimei", + "ge-10-le-19-kaimei", + "ge-20-le-29-kaimei", + "30-kaimei", + "31-kaimei", + "32-kaimei", + "33-kaimei", + "34-kaimei", + "35-kaimei", + "36-kaimei", + "37-kaimei", + "38-kaimei", + "39-kaimei", + "40-kaimei", + "41-kaimei", + "42-kaimei", + "43-kaimei", + "44-kaimei", + "45-kaimei", + "46-kaimei", + "47-kaimei", + "48-kaimei", + "49-kaimei", + "50-kaimei" ], "game": "popn", "id": "T19d35f0d594bcb4e793", @@ -5989,30 +5977,30 @@ "default": false, "description": "All pop'n peace levels, with some folders joined together.", "folders": [ - "F19d35f0cf2ade89b429", - "F19d35f0cf2a282c46db", - "F19d35f0cf2aea031487", - "F19d35f0cf2a497520b9", - "F19d35f0cf2ad78c5284", - "F19d35f0cf2ad123de43", - "F19d35f0cf2a98a9e22d", - "F19d35f0cf2afd0498de", - "F19d35f0cf2af66bf84a", - "F19d35f0cf2a075904d9", - "F19d35f0cf2a76a2915d", - "F19d35f0cf2a7a153860", - "F19d35f0cf2a81f9d089", - "F19d35f0cf2ab948e8e5", - "F19d35f0cf2a08236f32", - "F19d35f0cf2aeccd8ea8", - "F19d35f0cf2a5b7b0990", - "F19d35f0cf2ae9dda89c", - "F19d35f0cf2a209d9016", - "F19d35f0cf2a2016a626", - "F19d35f0cf2a76c39fe3", - "F19d35f0cf2ac2b583b2", - "F19d35f0cf2a52b43002", - "F19d35f0cf2ad4447633" + "ge-1-le-9-peace", + "ge-10-le-19-peace", + "ge-20-le-29-peace", + "30-peace", + "31-peace", + "32-peace", + "33-peace", + "34-peace", + "35-peace", + "36-peace", + "37-peace", + "38-peace", + "39-peace", + "40-peace", + "41-peace", + "42-peace", + "43-peace", + "44-peace", + "45-peace", + "46-peace", + "47-peace", + "48-peace", + "49-peace", + "50-peace" ], "game": "popn", "id": "T19d35f0d5941d10bc23", @@ -6024,56 +6012,56 @@ "default": false, "description": "All pop'n peace levels individually.", "folders": [ - "F19d35f0cf2ab7636c87", - "F19d35f0cf2a9daf25f2", - "F19d35f0cf2ad4d7542d", - "F19d35f0cf2a6fb92b71", - "F19d35f0cf2a928c8bc6", - "F19d35f0cf2a978b667e", - "F19d35f0cf2a8479c593", - "F19d35f0cf2a2d6a64bd", - "F19d35f0cf2ac171c58a", - "F19d35f0cf2a7772a89e", - "F19d35f0cf2a11c8a812", - "F19d35f0cf2aebe3fe0c", - "F19d35f0cf2af368f771", - "F19d35f0cf2a26bb1352", - "F19d35f0cf2aab2ae7fd", - "F19d35f0cf2a38657cfa", - "F19d35f0cf2a5f696660", - "F19d35f0cf2a1726732f", - "F19d35f0cf2a3912cd2b", - "F19d35f0cf2a4d0ed63b", - "F19d35f0cf2a84660f4a", - "F19d35f0cf2ae55ff0ba", - "F19d35f0cf2adf5b48a3", - "F19d35f0cf2a18898a06", - "F19d35f0cf2ab78294e9", - "F19d35f0cf2af4394a3d", - "F19d35f0cf2ac2473cf9", - "F19d35f0cf2a432283e6", - "F19d35f0cf2a650f6306", - "F19d35f0cf2a497520b9", - "F19d35f0cf2ad78c5284", - "F19d35f0cf2ad123de43", - "F19d35f0cf2a98a9e22d", - "F19d35f0cf2afd0498de", - "F19d35f0cf2af66bf84a", - "F19d35f0cf2a075904d9", - "F19d35f0cf2a76a2915d", - "F19d35f0cf2a7a153860", - "F19d35f0cf2a81f9d089", - "F19d35f0cf2ab948e8e5", - "F19d35f0cf2a08236f32", - "F19d35f0cf2aeccd8ea8", - "F19d35f0cf2a5b7b0990", - "F19d35f0cf2ae9dda89c", - "F19d35f0cf2a209d9016", - "F19d35f0cf2a2016a626", - "F19d35f0cf2a76c39fe3", - "F19d35f0cf2ac2b583b2", - "F19d35f0cf2a52b43002", - "F19d35f0cf2ad4447633" + "1-peace", + "2-peace", + "3-peace", + "4-peace", + "5-peace", + "6-peace", + "7-peace", + "8-peace", + "9-peace", + "10-peace", + "11-peace", + "12-peace", + "13-peace", + "14-peace", + "15-peace", + "16-peace", + "17-peace", + "18-peace", + "19-peace", + "20-peace", + "21-peace", + "22-peace", + "23-peace", + "24-peace", + "25-peace", + "26-peace", + "27-peace", + "28-peace", + "29-peace", + "30-peace", + "31-peace", + "32-peace", + "33-peace", + "34-peace", + "35-peace", + "36-peace", + "37-peace", + "38-peace", + "39-peace", + "40-peace", + "41-peace", + "42-peace", + "43-peace", + "44-peace", + "45-peace", + "46-peace", + "47-peace", + "48-peace", + "49-peace", + "50-peace" ], "game": "popn", "id": "T19d35f0d594e9c0a8d4", @@ -6085,56 +6073,56 @@ "default": false, "description": "All pop'n Unilab levels individually.", "folders": [ - "F19d35f0cf2ae336f676", - "F19d35f0cf2ac25d8b83", - "F19d35f0cf2a72296013", - "F19d35f0cf2af85d2810", - "F19d35f0cf2ad366bb11", - "F19d35f0cf2a0dd44c14", - "F19d35f0cf2a9ad14a32", - "F19d35f0cf2acf58dd71", - "F19d35f0cf2a5cdf4226", - "F19d35f0cf2af1c29b7b", - "F19d35f0cf2afaaea89a", - "F19d35f0cf2ad01231b2", - "F19d35f0cf2a6d86132e", - "F19d35f0cf2ab7014e01", - "F19d35f0cf2a1d0709fe", - "F19d35f0cf2ade8e1084", - "F19d35f0cf2aa10fa7dd", - "F19d35f0cf2abd2fcb17", - "F19d35f0cf2aeb766ddb", - "F19d35f0cf2ada325d1c", - "F19d35f0cf2ae49a2e6f", - "F19d35f0cf2a63de420a", - "F19d35f0cf2a517a0b6b", - "F19d35f0cf2a85afd1b9", - "F19d35f0cf2a5125a9d9", - "F19d35f0cf2a52ef1d24", - "F19d35f0cf2ab0e0ae7a", - "F19d35f0cf2a37604886", - "F19d35f0cf2a85e779fd", - "F19d35f0cf2a1f0afeb2", - "F19d35f0cf2a2571ccd5", - "F19d35f0cf2acfc1115f", - "F19d35f0cf2ab7f45c9b", - "F19d35f0cf2a521840b3", - "F19d35f0cf2ae9aade1c", - "F19d35f0cf2ae1d0efbe", - "F19d35f0cf2a3553022d", - "F19d35f0cf2ae24d1e8e", - "F19d35f0cf2a468337eb", - "F19d35f0cf2a1c2cd8c6", - "F19d35f0cf2a05d0fbf6", - "F19d35f0cf2a7219cac5", - "F19d35f0cf2a3fc19178", - "F19d35f0cf2a2910aa44", - "F19d35f0cf2a69a71019", - "F19d35f0cf2ac8a723f2", - "F19d35f0cf2a467b1189", - "F19d35f0cf2acc832d83", - "F19d35f0cf2a1980fd61", - "F19d35f0cf2a5c244470" + "1-unilab", + "2-unilab", + "3-unilab", + "4-unilab", + "5-unilab", + "6-unilab", + "7-unilab", + "8-unilab", + "9-unilab", + "10-unilab", + "11-unilab", + "12-unilab", + "13-unilab", + "14-unilab", + "15-unilab", + "16-unilab", + "17-unilab", + "18-unilab", + "19-unilab", + "20-unilab", + "21-unilab", + "22-unilab", + "23-unilab", + "24-unilab", + "25-unilab", + "26-unilab", + "27-unilab", + "28-unilab", + "29-unilab", + "30-unilab", + "31-unilab", + "32-unilab", + "33-unilab", + "34-unilab", + "35-unilab", + "36-unilab", + "37-unilab", + "38-unilab", + "39-unilab", + "40-unilab", + "41-unilab", + "42-unilab", + "43-unilab", + "44-unilab", + "45-unilab", + "46-unilab", + "47-unilab", + "48-unilab", + "49-unilab", + "50-unilab" ], "game": "popn", "id": "T19d35f0d594b464c6f8", @@ -6146,29 +6134,29 @@ "default": false, "description": "All pop'n Unilab levels, with some folders joined together.", "folders": [ - "F19d35f0cf2af39f152b", - "F19d35f0cf2a685fdde0", - "F19d35f0cf2adceb97dd", - "F19d35f0cf2a2571ccd5", - "F19d35f0cf2acfc1115f", - "F19d35f0cf2ab7f45c9b", - "F19d35f0cf2a521840b3", - "F19d35f0cf2ae9aade1c", - "F19d35f0cf2ae1d0efbe", - "F19d35f0cf2a3553022d", - "F19d35f0cf2ae24d1e8e", - "F19d35f0cf2a468337eb", - "F19d35f0cf2a1c2cd8c6", - "F19d35f0cf2a05d0fbf6", - "F19d35f0cf2a7219cac5", - "F19d35f0cf2a3fc19178", - "F19d35f0cf2a2910aa44", - "F19d35f0cf2a69a71019", - "F19d35f0cf2ac8a723f2", - "F19d35f0cf2a467b1189", - "F19d35f0cf2acc832d83", - "F19d35f0cf2a1980fd61", - "F19d35f0cf2a5c244470" + "ge-1-le-9-unilab", + "ge-10-le-19-unilab", + "ge-20-le-29-unilab", + "31-unilab", + "32-unilab", + "33-unilab", + "34-unilab", + "35-unilab", + "36-unilab", + "37-unilab", + "38-unilab", + "39-unilab", + "40-unilab", + "41-unilab", + "42-unilab", + "43-unilab", + "44-unilab", + "45-unilab", + "46-unilab", + "47-unilab", + "48-unilab", + "49-unilab", + "50-unilab" ], "game": "popn", "id": "T19d35f0d594cd41af3e", @@ -6180,26 +6168,26 @@ "default": true, "description": "Levels for SOUND VOLTEX in EXCEED GEAR.", "folders": [ - "F19d35f0cf2a1c41096d", - "F19d35f0cf2aa76aeab2", - "F19d35f0cf2af5eac9fe", - "F19d35f0cf2a8d8c5779", - "F19d35f0cf2a8a932d9e", - "F19d35f0cf2a61b4d1ad", - "F19d35f0cf2a9e7aa557", - "F19d35f0cf2aae3d204b", - "F19d35f0cf2a7e037f48", - "F19d35f0cf2a03cfff8b", - "F19d35f0cf2ab3a1dce3", - "F19d35f0cf2ac17241fd", - "F19d35f0cf2acd6a27e5", - "F19d35f0cf2a597982ce", - "F19d35f0cf2ab3530fb4", - "F19d35f0cf2a193b41f8", - "F19d35f0cf2a69b98cad", - "F19d35f0cf2a4b184f28", - "F19d35f0cf2afd13b38c", - "F19d35f0cf2a89b2f040" + "1-exceed", + "2-exceed", + "3-exceed", + "4-exceed", + "5-exceed", + "6-exceed", + "7-exceed", + "8-exceed", + "9-exceed", + "10-exceed", + "11-exceed", + "12-exceed", + "13-exceed", + "14-exceed", + "15-exceed", + "16-exceed", + "17-exceed", + "18-exceed", + "19-exceed", + "20-exceed" ], "game": "sdvx", "id": "T19d35f0d5941334d297", @@ -6211,26 +6199,26 @@ "default": false, "description": "Levels for SOUND VOLTEX in HEAVENLY HAVEN.", "folders": [ - "F19d35f0cf2a507db006", - "F19d35f0cf2a50f94ab8", - "F19d35f0cf2a51b481c7", - "F19d35f0cf2ac603249c", - "F19d35f0cf2a32b6a74c", - "F19d35f0cf2ab5af4bc8", - "F19d35f0cf2af1dee2a6", - "F19d35f0cf2aed0420a9", - "F19d35f0cf2a7a159360", - "F19d35f0cf2a6df2fc81", - "F19d35f0cf2a265767aa", - "F19d35f0cf2acee05563", - "F19d35f0cf2a62130514", - "F19d35f0cf2a162b211e", - "F19d35f0cf2a089a0b83", - "F19d35f0cf2aa6def4a9", - "F19d35f0cf2a8bb3aac0", - "F19d35f0cf2aa6d83768", - "F19d35f0cf2ad4b47106", - "F19d35f0cf2a27423b6d" + "1-heaven", + "2-heaven", + "3-heaven", + "4-heaven", + "5-heaven", + "6-heaven", + "7-heaven", + "8-heaven", + "9-heaven", + "10-heaven", + "11-heaven", + "12-heaven", + "13-heaven", + "14-heaven", + "15-heaven", + "16-heaven", + "17-heaven", + "18-heaven", + "19-heaven", + "20-heaven" ], "game": "sdvx", "id": "T19d35f0d594810890a9", @@ -6242,26 +6230,26 @@ "default": false, "description": "Levels for SOUND VOLTEX in Konaste.", "folders": [ - "F19d35f0cf2a91e177ac", - "F19d35f0cf2a88e1cd31", - "F19d35f0cf2a2d21228e", - "F19d35f0cf2a00d0b014", - "F19d35f0cf2acf9a30bc", - "F19d35f0cf2a2e7573f5", - "F19d35f0cf2afc4d0864", - "F19d35f0cf2abe555f75", - "F19d35f0cf2ab6aaee32", - "F19d35f0cf2abb5a01b9", - "F19d35f0cf2a8be1b46c", - "F19d35f0cf2af3e52e24", - "F19d35f0cf2ab215e704", - "F19d35f0cf2a73b2ab38", - "F19d35f0cf2a881c6a5b", - "F19d35f0cf2a7bf9915f", - "F19d35f0cf2a3de4345e", - "F19d35f0cf2a369ed46b", - "F19d35f0cf2a427d2e98", - "F19d35f0cf2ab8c1c334" + "1-konaste", + "2-konaste", + "3-konaste", + "4-konaste", + "5-konaste", + "6-konaste", + "7-konaste", + "8-konaste", + "9-konaste", + "10-konaste", + "11-konaste", + "12-konaste", + "13-konaste", + "14-konaste", + "15-konaste", + "16-konaste", + "17-konaste", + "18-konaste", + "19-konaste", + "20-konaste" ], "game": "sdvx", "id": "T19d35f0d59457f0939e", @@ -6273,26 +6261,26 @@ "default": false, "description": "Levels for SOUND VOLTEX in VIVID WAVE.", "folders": [ - "F19d35f0cf2ae1f2b212", - "F19d35f0cf2a22330d01", - "F19d35f0cf2a06fc5680", - "F19d35f0cf2a22e402eb", - "F19d35f0cf2abaec1504", - "F19d35f0cf2a1c419e6a", - "F19d35f0cf2a59757ac7", - "F19d35f0cf2ad2b8d882", - "F19d35f0cf2a6fafd1ff", - "F19d35f0cf2a6012f137", - "F19d35f0cf2a79f306a2", - "F19d35f0cf2acaf7328c", - "F19d35f0cf2a8394786a", - "F19d35f0cf2a25154803", - "F19d35f0cf2a942955fb", - "F19d35f0cf2ae339891a", - "F19d35f0cf2a37d435a6", - "F19d35f0cf2aabcf9980", - "F19d35f0cf2a22f19a1e", - "F19d35f0cf2a9108b4c8" + "1-vivid", + "2-vivid", + "3-vivid", + "4-vivid", + "5-vivid", + "6-vivid", + "7-vivid", + "8-vivid", + "9-vivid", + "10-vivid", + "11-vivid", + "12-vivid", + "13-vivid", + "14-vivid", + "15-vivid", + "16-vivid", + "17-vivid", + "18-vivid", + "19-vivid", + "20-vivid" ], "game": "sdvx", "id": "T19d35f0d5945a2ecec3", @@ -6304,21 +6292,21 @@ "default": false, "description": "The insane KSM difficulty table, based on scoring difficulty.", "folders": [ - "F19d35f0cf2ad92bd764", - "F19d35f0cf2ab7e76319", - "F19d35f0cf2a5109bbf0", - "F19d35f0cf2a54eec24a", - "F19d35f0cf2a4a875365", - "F19d35f0cf2aaaae6b4c", - "F19d35f0cf2a7088dafa", - "F19d35f0cf2aeed1c3d3", - "F19d35f0cf2a7ccb77d1", - "F19d35f0cf2a9cffd0fb", - "F19d35f0cf2a0577b8e7", - "F19d35f0cf2a777ef3b7", - "F19d35f0cf2a27b33589", - "F19d35f0cf2a6dfd1a8a", - "F19d35f0cf2a6a492f2e" + "os0", + "os1", + "os2", + "os3", + "os4", + "os5", + "os6", + "os7", + "os8", + "os9", + "os10", + "os11", + "os12", + "os13", + "os16" ], "game": "usc-controller", "id": "T19d35f0d59464bfffc5", @@ -6330,26 +6318,26 @@ "default": true, "description": "Level Folders for SDVX converts inside USC.", "folders": [ - "F19d35f0cf2a93014cff", - "F19d35f0cf2a25741c4f", - "F19d35f0cf2a5b06ea29", - "F19d35f0cf2ae3c32a58", - "F19d35f0cf2add506b13", - "F19d35f0cf2a700cb454", - "F19d35f0cf2abee81434", - "F19d35f0cf2ab83c62c3", - "F19d35f0cf2a05179dcb", - "F19d35f0cf2ab345bda7", - "F19d35f0cf2a2f148591", - "F19d35f0cf2a288a774d", - "F19d35f0cf2a66a54a06", - "F19d35f0cf2a4918ba9c", - "F19d35f0cf2ad88bc79a", - "F19d35f0cf2ab00d9934", - "F19d35f0cf2abb61b86f", - "F19d35f0cf2ae36c78fc", - "F19d35f0cf2ae6dbb961", - "F19d35f0cf2a0f9bdb13" + "official-1", + "official-2", + "official-3", + "official-4", + "official-5", + "official-6", + "official-7", + "official-8", + "official-9", + "official-10", + "official-11", + "official-12", + "official-13", + "official-14", + "official-15", + "official-16", + "official-17", + "official-18", + "official-19", + "official-20" ], "game": "usc-controller", "id": "T19d35f0d59472b6c287", @@ -6361,27 +6349,27 @@ "default": false, "description": "The normal KSM difficulty table, based on scoring difficulty.", "folders": [ - "F19d35f0cf2a2d523963", - "F19d35f0cf2a40917f97", - "F19d35f0cf2ad9271b55", - "F19d35f0cf2a3eab554f", - "F19d35f0cf2a4a38de81", - "F19d35f0cf2a46a6cee2", - "F19d35f0cf2a1767db76", - "F19d35f0cf2a1921345c", - "F19d35f0cf2ae018ca40", - "F19d35f0cf2a13c28e93", - "F19d35f0cf2a118d6a59", - "F19d35f0cf2a2e1e62b1", - "F19d35f0cf2a340b64c0", - "F19d35f0cf2ae8177476", - "F19d35f0cf2a439101ac", - "F19d35f0cf2a0d4cd8db", - "F19d35f0cf2ac84e4878", - "F19d35f0cf2a9d0cf489", - "F19d35f0cf2a72d01aaa", - "F19d35f0cf2ad53d651e", - "F19d35f0cf2a09ee57be" + "us0s", + "us1", + "us2", + "us3", + "us4", + "us5", + "us6", + "us7", + "us8", + "us9", + "us10", + "us11", + "us12", + "us13", + "us14", + "us15", + "us16", + "us17", + "us17p", + "us18", + "us18p" ], "game": "usc-controller", "id": "T19d35f0d5946c347a49", @@ -6393,21 +6381,21 @@ "default": false, "description": "The insane KSM difficulty table, based on scoring difficulty.", "folders": [ - "F19d35f0cf2a1ee52d31", - "F19d35f0cf2a51d80244", - "F19d35f0cf2a6317ab1c", - "F19d35f0cf2a26ec44f2", - "F19d35f0cf2a161a3604", - "F19d35f0cf2a8a4319e7", - "F19d35f0cf2a93e2bf53", - "F19d35f0cf2a924e3654", - "F19d35f0cf2a2833a7d4", - "F19d35f0cf2a7bfcb116", - "F19d35f0cf2a112d785d", - "F19d35f0cf2a8f403133", - "F19d35f0cf2a45dbab38", - "F19d35f0cf2a2f9c921b", - "F19d35f0cf2ad68115de" + "os0", + "os1", + "os2", + "os3", + "os4", + "os5", + "os6", + "os7", + "os8", + "os9", + "os10", + "os11", + "os12", + "os13", + "os16" ], "game": "usc-keyboard", "id": "T19d35f0d5945d4fdec2", @@ -6419,26 +6407,26 @@ "default": true, "description": "Level Folders for SDVX converts inside USC.", "folders": [ - "F19d35f0cf2a494a51a7", - "F19d35f0cf2aec8eec40", - "F19d35f0cf2a6986b4c2", - "F19d35f0cf2a641d263f", - "F19d35f0cf2a94577d9d", - "F19d35f0cf2a0151df25", - "F19d35f0cf2a301fde6b", - "F19d35f0cf2a421508c2", - "F19d35f0cf2a0591bc66", - "F19d35f0cf2accda905d", - "F19d35f0cf2ab2453f55", - "F19d35f0cf2ac9fce14e", - "F19d35f0cf2a2dfe5ccf", - "F19d35f0cf2abb04848b", - "F19d35f0cf2a61ac1d74", - "F19d35f0cf2a67f2f664", - "F19d35f0cf2aa66890f7", - "F19d35f0cf2a94bb11b3", - "F19d35f0cf2a19486c46", - "F19d35f0cf2ab33ed8d7" + "official-1", + "official-2", + "official-3", + "official-4", + "official-5", + "official-6", + "official-7", + "official-8", + "official-9", + "official-10", + "official-11", + "official-12", + "official-13", + "official-14", + "official-15", + "official-16", + "official-17", + "official-18", + "official-19", + "official-20" ], "game": "usc-keyboard", "id": "T19d35f0d594d908e214", @@ -6450,27 +6438,27 @@ "default": false, "description": "The normal KSM difficulty table, based on scoring difficulty.", "folders": [ - "F19d35f0cf2a99fb26f7", - "F19d35f0cf2a246980fa", - "F19d35f0cf2a03a6a201", - "F19d35f0cf2a9757f7e8", - "F19d35f0cf2adf03220e", - "F19d35f0cf2a6bee4672", - "F19d35f0cf2a63bf2880", - "F19d35f0cf2a5511cd6d", - "F19d35f0cf2aac144e9b", - "F19d35f0cf2ad250ad82", - "F19d35f0cf2af8c743f7", - "F19d35f0cf2a0040c72b", - "F19d35f0cf2a3357571e", - "F19d35f0cf2a9052fe4f", - "F19d35f0cf2aa7ec3b93", - "F19d35f0cf2a3afbfd77", - "F19d35f0cf2a30ff9e38", - "F19d35f0cf2a9a961325", - "F19d35f0cf2a95303548", - "F19d35f0cf2a6dc675a9", - "F19d35f0cf2aa702af10" + "us0s", + "us1", + "us2", + "us3", + "us4", + "us5", + "us6", + "us7", + "us8", + "us9", + "us10", + "us11", + "us12", + "us13", + "us14", + "us15", + "us16", + "us17", + "us17p", + "us18", + "us18p" ], "game": "usc-keyboard", "id": "T19d35f0d59416da4053", @@ -6482,29 +6470,29 @@ "default": false, "description": "Levels for WACCA in PLUS.", "folders": [ - "F19d35f0cf2ab23c8869", - "F19d35f0cf2adf3500c7", - "F19d35f0cf2a5f4a7f7e", - "F19d35f0cf2aa24d60c7", - "F19d35f0cf2a64bbd7aa", - "F19d35f0cf2a78789073", - "F19d35f0cf2a40f1db26", - "F19d35f0cf2a8775dfd9", - "F19d35f0cf2ac27ee87f", - "F19d35f0cf2a91339523", - "F19d35f0cf2aeef0f356", - "F19d35f0cf2ad46ca3f8", - "F19d35f0cf2a06c6ddaf", - "F19d35f0cf2a126edad4", - "F19d35f0cf2a5de8848a", - "F19d35f0cf2a4e7b5867", - "F19d35f0cf2a39b4a5e6", - "F19d35f0cf2ae15a6de3", - "F19d35f0cf2a0e06ec75", - "F19d35f0cf2a2b240da0", - "F19d35f0cf2a3ee2cddd", - "F19d35f0cf2ae20b1917", - "F19d35f0cf2a7f23c6f0" + "1-plus", + "2-plus", + "3-plus", + "4-plus", + "5-plus", + "5p-plus", + "6-plus", + "6p-plus", + "7-plus", + "7p-plus", + "8-plus", + "8p-plus", + "9-plus", + "9p-plus", + "10-plus", + "10p-plus", + "11-plus", + "11p-plus", + "12-plus", + "12p-plus", + "13-plus", + "13p-plus", + "14-15-plus" ], "game": "wacca", "id": "T19d35f0d59443a18ba3", @@ -6516,29 +6504,29 @@ "default": true, "description": "Levels for WACCA's Reverse version.", "folders": [ - "F19d35f0cf2a17a27e48", - "F19d35f0cf2af6460775", - "F19d35f0cf2aaa3d976b", - "F19d35f0cf2a802c5f9a", - "F19d35f0cf2a00b84606", - "F19d35f0cf2a1c283146", - "F19d35f0cf2ae68616df", - "F19d35f0cf2afe36db1a", - "F19d35f0cf2a6aaa3a6c", - "F19d35f0cf2af7146281", - "F19d35f0cf2ac1789f07", - "F19d35f0cf2ab2b70c93", - "F19d35f0cf2af1958fb9", - "F19d35f0cf2aa3662dc3", - "F19d35f0cf2a7b1b88c1", - "F19d35f0cf2a9327b4f4", - "F19d35f0cf2a0fcfef71", - "F19d35f0cf2a192253b9", - "F19d35f0cf2a170a9fe9", - "F19d35f0cf2a3516f1b2", - "F19d35f0cf2a800c8a45", - "F19d35f0cf2a9b9019bc", - "F19d35f0cf2a0903866b" + "1-reverse", + "2-reverse", + "3-reverse", + "4-reverse", + "5-reverse", + "5p-reverse", + "6-reverse", + "6p-reverse", + "7-reverse", + "7p-reverse", + "8-reverse", + "8p-reverse", + "9-reverse", + "9p-reverse", + "10-reverse", + "10p-reverse", + "11-reverse", + "11p-reverse", + "12-reverse", + "12p-reverse", + "13-reverse", + "13p-reverse", + "14-15-reverse" ], "game": "wacca", "id": "T19d35f0d594d54f2dcb", diff --git a/dev/aliases.fish b/dev/aliases.fish index 3e032b224..770190c3e 100644 --- a/dev/aliases.fish +++ b/dev/aliases.fish @@ -3,7 +3,6 @@ defnew g=git defnew j=just defnew c=cargo -defnew p=pnpm defnew gp=git push defnew gsw=git switch diff --git a/dev/functions.fish b/dev/functions.fish index ccd672314..b5f7682e2 100644 --- a/dev/functions.fish +++ b/dev/functions.fish @@ -35,11 +35,6 @@ function cmd end function fish_greeting - # check $PATH is correct - if not contains ~/.local/pnpm $PATH - fish_add_path ~/.local/pnpm - end - if test (random 0 100) -lt 10; and test -d /host-pc/dl # entropy perl /tachi/dev/eye.pl end diff --git a/dev/observability/grafana/dashboards/nodejs-application.json b/dev/observability/grafana/dashboards/nodejs-application.json index 057fadcc0..ab669ab98 100644 --- a/dev/observability/grafana/dashboards/nodejs-application.json +++ b/dev/observability/grafana/dashboards/nodejs-application.json @@ -1,897 +1,866 @@ { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "description": "node.js prometheus client basic metrics", - "editable": true, - "gnetId": 11159, - "graphTooltip": 0, - "id": null, - "iteration": 1573392431370, - "links": [], - "panels": [ - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 7, - "w": 10, - "x": 0, - "y": 0 - }, - "id": 6, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "max": true, - "min": true, - "show": true, - "total": false, - "values": true - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "paceLength": 10, - "percentage": false, - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "irate(process_cpu_user_seconds_total{instance=~\"$instance\"}[2m]) * 100", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "User CPU - {{instance}}", - "refId": "A" - }, - { - "expr": "irate(process_cpu_system_seconds_total{instance=~\"$instance\"}[2m]) * 100", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Sys CPU - {{instance}}", - "refId": "B" - } - ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Process CPU Usage", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "percent", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 7, - "w": 9, - "x": 10, - "y": 0 - }, - "id": 8, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "max": true, - "min": true, - "show": true, - "total": false, - "values": true - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "paceLength": 10, - "percentage": false, - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "nodejs_eventloop_lag_seconds{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{instance}}", - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Event Loop Lag", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "s", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "cacheTimeout": null, - "colorBackground": false, - "colorValue": false, - "colors": [ - "#299c46", - "rgba(237, 129, 40, 0.89)", - "#d44a3a" - ], - "datasource": "Prometheus", - "format": "none", - "gauge": { - "maxValue": 100, - "minValue": 0, - "show": false, - "thresholdLabels": false, - "thresholdMarkers": true - }, - "gridPos": { - "h": 3, - "w": 5, - "x": 19, - "y": 0 - }, - "id": 2, - "interval": "", - "links": [], - "mappingType": 1, - "mappingTypes": [ - { - "name": "value to text", - "value": 1 - }, - { - "name": "range to text", - "value": 2 - } - ], - "maxDataPoints": 100, - "nullPointMode": "connected", - "nullText": null, - "postfix": "", - "postfixFontSize": "50%", - "prefix": "", - "prefixFontSize": "50%", - "rangeMaps": [ - { - "from": "null", - "text": "N/A", - "to": "null" - } - ], - "sparkline": { - "fillColor": "rgba(31, 118, 189, 0.18)", - "full": false, - "lineColor": "rgb(31, 120, 193)", - "show": false - }, - "tableColumn": "__name__", - "targets": [ - { - "expr": "sum(nodejs_version_info{instance=~\"$instance\"}) by (version)", - "format": "time_series", - "instant": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{version}}", - "refId": "A" - } - ], - "thresholds": "", - "timeFrom": null, - "timeShift": null, - "title": "Node.js Version", - "type": "singlestat", - "valueFontSize": "80%", - "valueMaps": [ - { - "op": "=", - "text": "N/A", - "value": "null" - } - ], - "valueName": "name" - }, - { - "cacheTimeout": null, - "colorBackground": false, - "colorValue": false, - "colors": [ - "#299c46", - "rgba(237, 129, 40, 0.89)", - "#d44a3a" - ], - "datasource": "Prometheus", - "format": "none", - "gauge": { - "maxValue": 100, - "minValue": 0, - "show": false, - "thresholdLabels": false, - "thresholdMarkers": true - }, - "gridPos": { - "h": 4, - "w": 5, - "x": 19, - "y": 3 - }, - "id": 4, - "interval": null, - "links": [], - "mappingType": 1, - "mappingTypes": [ - { - "name": "value to text", - "value": 1 - }, - { - "name": "range to text", - "value": 2 - } - ], - "maxDataPoints": 100, - "nullPointMode": "connected", - "nullText": null, - "postfix": "", - "postfixFontSize": "50%", - "prefix": "", - "prefixFontSize": "50%", - "rangeMaps": [ - { - "from": "null", - "text": "N/A", - "to": "null" - } - ], - "sparkline": { - "fillColor": "rgba(31, 118, 189, 0.18)", - "full": false, - "lineColor": "#F2495C", - "show": true - }, - "tableColumn": "", - "targets": [ - { - "expr": "sum(changes(process_start_time_seconds{instance=~\"$instance\"}[1m]))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{instance}}", - "refId": "A" - } - ], - "thresholds": "", - "timeFrom": null, - "timeShift": null, - "title": "Process Restart Times", - "type": "singlestat", - "valueFontSize": "80%", - "valueMaps": [ - { - "op": "=", - "text": "N/A", - "value": "null" - } - ], - "valueName": "current" - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 7, - "w": 16, - "x": 0, - "y": 7 - }, - "id": 7, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "max": true, - "min": true, - "rightSide": true, - "show": true, - "total": false, - "values": true - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "paceLength": 10, - "percentage": false, - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "process_resident_memory_bytes{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Process Memory - {{instance}}", - "refId": "A" - }, - { - "expr": "nodejs_heap_size_total_bytes{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Heap Total - {{instance}}", - "refId": "B" - }, - { - "expr": "nodejs_heap_size_used_bytes{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Heap Used - {{instance}}", - "refId": "C" - }, - { - "expr": "nodejs_external_memory_bytes{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "External Memory - {{instance}}", - "refId": "D" - } - ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Process Memory Usage", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "bytes", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 7, - "w": 8, - "x": 16, - "y": 7 - }, - "id": 9, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "max": true, - "min": true, - "show": true, - "total": false, - "values": true - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "paceLength": 10, - "percentage": false, - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "nodejs_active_handles_total{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Active Handler - {{instance}}", - "refId": "A" - }, - { - "expr": "nodejs_active_requests_total{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Active Request - {{instance}}", - "refId": "B" - } - ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Active Handlers/Requests Total", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 14 - }, - "id": 10, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "max": true, - "min": true, - "rightSide": false, - "show": true, - "total": false, - "values": true - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "paceLength": 10, - "percentage": false, - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "nodejs_heap_space_size_total_bytes{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Heap Total - {{instance}} - {{space}}", - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Heap Total Detail", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "bytes", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 14 - }, - "id": 11, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "max": true, - "min": true, - "rightSide": false, - "show": true, - "total": false, - "values": true - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "paceLength": 10, - "percentage": false, - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "nodejs_heap_space_size_used_bytes{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Heap Used - {{instance}} - {{space}}", - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Heap Used Detail", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "bytes", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 14 - }, - "id": 12, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "max": true, - "min": true, - "rightSide": false, - "show": true, - "total": false, - "values": true - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "paceLength": 10, - "percentage": false, - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "nodejs_heap_space_size_available_bytes{instance=~\"$instance\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Heap Used - {{instance}} - {{space}}", - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Heap Available Detail", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "bytes", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - } - ], - "schemaVersion": 18, - "style": "dark", - "tags": [ - "nodejs" - ], - "templating": { - "list": [ - { - "allValue": null, - "current": {}, - "datasource": "Prometheus", - "definition": "label_values(nodejs_version_info, instance)", - "hide": 0, - "includeAll": true, - "label": "instance", - "multi": true, - "name": "instance", - "options": [], - "query": "label_values(nodejs_version_info, instance)", - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "tagValuesQuery": "", - "tags": [], - "tagsQuery": "", - "type": "query", - "useTags": false - } - ] - }, - "time": { - "from": "now-1h", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "timezone": "", - "title": "NodeJS Application Dashboard", - "uid": "PTSqcpJWk", - "version": 4 + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "node.js prometheus client basic metrics", + "editable": true, + "gnetId": 11159, + "graphTooltip": 0, + "id": null, + "iteration": 1573392431370, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 10, + "x": 0, + "y": 0 + }, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(process_cpu_user_seconds_total{instance=~\"$instance\"}[2m]) * 100", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "User CPU - {{instance}}", + "refId": "A" + }, + { + "expr": "irate(process_cpu_system_seconds_total{instance=~\"$instance\"}[2m]) * 100", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Sys CPU - {{instance}}", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Process CPU Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 9, + "x": 10, + "y": 0 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_eventloop_lag_seconds{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Event Loop Lag", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"], + "datasource": "Prometheus", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 5, + "x": 19, + "y": 0 + }, + "id": 2, + "interval": "", + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "__name__", + "targets": [ + { + "expr": "sum(nodejs_version_info{instance=~\"$instance\"}) by (version)", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{version}}", + "refId": "A" + } + ], + "thresholds": "", + "timeFrom": null, + "timeShift": null, + "title": "Node.js Version", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "name" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"], + "datasource": "Prometheus", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 19, + "y": 3 + }, + "id": 4, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "#F2495C", + "show": true + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum(changes(process_start_time_seconds{instance=~\"$instance\"}[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "thresholds": "", + "timeFrom": null, + "timeShift": null, + "title": "Process Restart Times", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 7 + }, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_resident_memory_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Process Memory - {{instance}}", + "refId": "A" + }, + { + "expr": "nodejs_heap_size_total_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Total - {{instance}}", + "refId": "B" + }, + { + "expr": "nodejs_heap_size_used_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Used - {{instance}}", + "refId": "C" + }, + { + "expr": "nodejs_external_memory_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "External Memory - {{instance}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Process Memory Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 7 + }, + "id": 9, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_active_handles_total{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Active Handler - {{instance}}", + "refId": "A" + }, + { + "expr": "nodejs_active_requests_total{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Active Request - {{instance}}", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Active Handlers/Requests Total", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 14 + }, + "id": 10, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_heap_space_size_total_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Total - {{instance}} - {{space}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Heap Total Detail", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 14 + }, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_heap_space_size_used_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Used - {{instance}} - {{space}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Heap Used Detail", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 14 + }, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_heap_space_size_available_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Used - {{instance}} - {{space}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Heap Available Detail", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 18, + "style": "dark", + "tags": ["nodejs"], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "definition": "label_values(nodejs_version_info, instance)", + "hide": 0, + "includeAll": true, + "label": "instance", + "multi": true, + "name": "instance", + "options": [], + "query": "label_values(nodejs_version_info, instance)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "", + "title": "NodeJS Application Dashboard", + "uid": "PTSqcpJWk", + "version": 4 } diff --git a/dev/observability/grafana/dashboards/tachi-api.json b/dev/observability/grafana/dashboards/tachi-api.json index b3e4ed53d..bbe592dae 100644 --- a/dev/observability/grafana/dashboards/tachi-api.json +++ b/dev/observability/grafana/dashboards/tachi-api.json @@ -1,1821 +1,1988 @@ { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { "type": "prometheus", "uid": "" }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "description": "Tachi API — endpoint latencies, score import durations, error rates & system health", - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "id": null, - "links": [], - "panels": [ - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, - "id": 100, - "title": "Overview", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#73BF69", "value": null } - ] - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, - "id": 1, - "options": { - "colorMode": "background_solid", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "Request Rate", - "type": "stat", - "targets": [ - { - "expr": "sum(rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", - "legendFormat": "", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#73BF69", "value": null }, - { "color": "#FF9830", "value": 0.01 }, - { "color": "#F2495C", "value": 0.05 } - ] - }, - "unit": "percentunit", - "min": 0, - "max": 1 - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, - "id": 2, - "options": { - "colorMode": "background_solid", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "Error Rate (5xx)", - "type": "stat", - "targets": [ - { - "expr": "sum(rate(http_requests_total{job=\"tachi-api\",status_code=~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", - "legendFormat": "", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#73BF69", "value": null } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, - "id": 3, - "options": { - "colorMode": "background_solid", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "P50 Latency", - "type": "stat", - "targets": [ - { - "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", - "legendFormat": "", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#73BF69", "value": null }, - { "color": "#FF9830", "value": 1 }, - { "color": "#F2495C", "value": 5 } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, - "id": 4, - "options": { - "colorMode": "background_solid", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "P99 Latency", - "type": "stat", - "targets": [ - { - "expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", - "legendFormat": "", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#73BF69", "value": null } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, - "id": 5, - "options": { - "colorMode": "background_solid", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "Imports (last hour)", - "type": "stat", - "targets": [ - { - "expr": "sum(increase(score_import_duration_seconds_count{job=\"tachi-api\"}[1h]))", - "legendFormat": "", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [ - { "options": { "0": { "color": "red", "text": "DOWN" } }, "type": "value" }, - { "options": { "1": { "color": "green", "text": "UP" } }, "type": "value" } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#F2495C", "value": null }, - { "color": "#73BF69", "value": 1 } - ] - } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, - "id": 6, - "options": { - "colorMode": "background_solid", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "API Status", - "type": "stat", - "targets": [ - { - "expr": "up{job=\"tachi-api\"}", - "legendFormat": "", - "refId": "A" - } - ] - }, + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "prometheus", "uid": "" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Tachi API — endpoint latencies, score import durations, error rates & system health", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Overview", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "#73BF69", "value": null }] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "id": 1, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Request Rate", + "type": "stat", + "targets": [ + { + "expr": "sum(rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null }, + { "color": "#FF9830", "value": 0.01 }, + { "color": "#F2495C", "value": 0.05 } + ] + }, + "unit": "percentunit", + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "id": 2, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Error Rate (5xx)", + "type": "stat", + "targets": [ + { + "expr": "sum(rate(http_requests_total{job=\"tachi-api\",status_code=~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "#73BF69", "value": null }] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "id": 3, + "options": { + "colorMode": "background_solid", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "P50 Latency", + "type": "stat", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "#F2495C", "value": 5 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "id": 4, + "options": { + "colorMode": "background_solid", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "P99 Latency", + "type": "stat", + "targets": [ + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "#73BF69", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "id": 5, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Imports (last hour)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(score_import_duration_seconds_count{job=\"tachi-api\"}[1h]))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [ + { "options": { "0": { "color": "red", "text": "DOWN" } }, "type": "value" }, + { "options": { "1": { "color": "green", "text": "UP" } }, "type": "value" } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#F2495C", "value": null }, + { "color": "#73BF69", "value": 1 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "id": 6, + "options": { + "colorMode": "background_solid", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "API Status", + "type": "stat", + "targets": [ + { + "expr": "up{job=\"tachi-api\"}", + "legendFormat": "", + "refId": "A" + } + ] + }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, - "id": 200, - "title": "HTTP Endpoints", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 0, "y": 6 }, - "id": 10, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Endpoint Latency (P95)", - "type": "timeseries", - "targets": [ - { - "expr": "histogram_quantile(0.95, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", - "legendFormat": "{{ path }}", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 80, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 12, "y": 6 }, - "id": 11, - "options": { - "legend": { "calcs": ["mean"], "displayMode": "table", "placement": "bottom", "sortBy": "Mean", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Request Rate by Endpoint", - "type": "timeseries", - "targets": [ - { - "expr": "sum by (path) (rate(http_requests_total{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval]))", - "legendFormat": "{{ path }}", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 0, "y": 15 }, - "id": 12, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Endpoint Latency (P50 vs P99)", - "type": "timeseries", - "targets": [ - { - "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", - "legendFormat": "P50 (all endpoints)", - "refId": "A" - }, - { - "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", - "legendFormat": "P95 (all endpoints)", - "refId": "B" - }, - { - "expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", - "legendFormat": "P99 (all endpoints)", - "refId": "C" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 80, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 12, "y": 15 }, - "id": 13, - "options": { - "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Total", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Errors by Status Code", - "type": "timeseries", - "targets": [ - { - "expr": "sum by (status_code) (rate(http_requests_total{job=\"tachi-api\",status_code=~\"[45]..\"}[$__rate_interval]))", - "legendFormat": "{{ status_code }}", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "custom": { - "align": "auto", - "cellOptions": { "type": "auto" }, - "inspect": false - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "#FF9830", "value": 0.5 }, - { "color": "red", "value": 2 } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "Endpoint" }, - "properties": [ - { "id": "custom.width", "value": 350 }, - { "id": "unit", "value": "string" } - ] - }, - { - "matcher": { "id": "byName", "options": "P95 Latency" }, - "properties": [ - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - }, - { - "matcher": { "id": "byName", "options": "P99 Latency" }, - "properties": [ - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - } - ] - }, - "gridPos": { "h": 9, "w": 24, "x": 0, "y": 24 }, - "id": 14, - "options": { - "cellHeight": "sm", - "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, - "showHeader": true, - "sortBy": [{ "desc": true, "displayName": "P95 Latency" }] - }, - "title": "Slowest Endpoints", - "type": "table", - "transformations": [ - { - "id": "merge", - "options": {} - } - ], - "targets": [ - { - "expr": "topk(20, histogram_quantile(0.95, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m]))))", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "A" - }, - { - "expr": "topk(20, histogram_quantile(0.99, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m]))))", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "B" - }, - { - "expr": "topk(20, sum by (path) (rate(http_requests_total{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m])))", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "C" - } - ], - "transformations": [ - { - "id": "seriesToColumns", - "options": { "byField": "path" } - }, - { - "id": "organize", - "options": { - "excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "job": true, "job 1": true, "job 2": true, "instance": true, "instance 1": true, "instance 2": true }, - "renameByName": { - "path": "Endpoint", - "Value #A": "P95 Latency", - "Value #B": "P99 Latency", - "Value #C": "Req/s" - } - } - } - ] - }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 200, + "title": "HTTP Endpoints", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 6 }, + "id": 10, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Max", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Endpoint Latency (P95)", + "type": "timeseries", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", + "legendFormat": "{{ path }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 6 }, + "id": 11, + "options": { + "legend": { + "calcs": ["mean"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Request Rate by Endpoint", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (path) (rate(http_requests_total{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval]))", + "legendFormat": "{{ path }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 15 }, + "id": 12, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Max", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Endpoint Latency (P50 vs P99)", + "type": "timeseries", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", + "legendFormat": "P50 (all endpoints)", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", + "legendFormat": "P95 (all endpoints)", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", + "legendFormat": "P99 (all endpoints)", + "refId": "C" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 15 }, + "id": 13, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Total", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Errors by Status Code", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (status_code) (rate(http_requests_total{job=\"tachi-api\",status_code=~\"[45]..\"}[$__rate_interval]))", + "legendFormat": "{{ status_code }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#FF9830", "value": 0.5 }, + { "color": "red", "value": 2 } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Endpoint" }, + "properties": [ + { "id": "custom.width", "value": 350 }, + { "id": "unit", "value": "string" } + ] + }, + { + "matcher": { "id": "byName", "options": "P95 Latency" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + }, + { + "matcher": { "id": "byName", "options": "P99 Latency" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 24 }, + "id": 14, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "P95 Latency" }] + }, + "title": "Slowest Endpoints", + "type": "table", + "transformations": [ + { + "id": "merge", + "options": {} + } + ], + "targets": [ + { + "expr": "topk(20, histogram_quantile(0.95, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m]))))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "A" + }, + { + "expr": "topk(20, histogram_quantile(0.99, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m]))))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "B" + }, + { + "expr": "topk(20, sum by (path) (rate(http_requests_total{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m])))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "C" + } + ], + "transformations": [ + { + "id": "seriesToColumns", + "options": { "byField": "path" } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Time 1": true, + "Time 2": true, + "Time 3": true, + "__name__": true, + "__name__ 1": true, + "__name__ 2": true, + "job": true, + "job 1": true, + "job 2": true, + "instance": true, + "instance 1": true, + "instance 2": true + }, + "renameByName": { + "path": "Endpoint", + "Value #A": "P95 Latency", + "Value #B": "P99 Latency", + "Value #C": "Req/s" + } + } + } + ] + }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 }, - "id": 300, - "title": "Score Imports", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { - "mode": "line+area", - "line": { "color": "#FF9830" }, - "area": { "color": "rgba(255, 152, 48, 0.05)" } - } - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "transparent", "value": null }, - { "color": "rgba(255,152,48,0.08)", "value": 60 } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 0, "y": 34 }, - "id": 20, - "options": { - "legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Import Duration by Type (P95)", - "type": "timeseries", - "targets": [ - { - "expr": "histogram_quantile(0.95, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", - "legendFormat": "{{ import_type }}", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 12, "y": 34 }, - "id": 21, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Import Duration by Type (P50 vs P99)", - "type": "timeseries", - "targets": [ - { - "expr": "histogram_quantile(0.50, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", - "legendFormat": "P50 {{ import_type }}", - "refId": "A" - }, - { - "expr": "histogram_quantile(0.99, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", - "legendFormat": "P99 {{ import_type }}", - "refId": "B" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 80, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 0, "y": 43 }, - "id": 22, - "options": { - "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Total", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Import Count by Type", - "type": "timeseries", - "targets": [ - { - "expr": "sum by (import_type) (increase(score_import_duration_seconds_count{job=\"tachi-api\"}[$__rate_interval]))", - "legendFormat": "{{ import_type }}", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "custom": { - "align": "auto", - "cellOptions": { "type": "auto" }, - "inspect": false - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "#FF9830", "value": 30 }, - { "color": "red", "value": 120 } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "Import Type" }, - "properties": [ - { "id": "custom.width", "value": 300 }, - { "id": "unit", "value": "string" } - ] - }, - { - "matcher": { "id": "byName", "options": "P95 Duration" }, - "properties": [ - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - }, - { - "matcher": { "id": "byName", "options": "P99 Duration" }, - "properties": [ - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - }, - { - "matcher": { "id": "byName", "options": "Max Duration" }, - "properties": [ - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - } - ] - }, - "gridPos": { "h": 9, "w": 12, "x": 12, "y": 43 }, - "id": 23, - "options": { - "cellHeight": "sm", - "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, - "showHeader": true, - "sortBy": [{ "desc": true, "displayName": "P95 Duration" }] - }, - "title": "Slow Imports by Type", - "type": "table", - "targets": [ - { - "expr": "histogram_quantile(0.95, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[5m])))", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "A" - }, - { - "expr": "histogram_quantile(0.99, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[5m])))", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "B" - }, - { - "expr": "max by (import_type) (score_import_duration_seconds_sum{job=\"tachi-api\"} / score_import_duration_seconds_count{job=\"tachi-api\"})", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "C" - } - ], - "transformations": [ - { - "id": "seriesToColumns", - "options": { "byField": "import_type" } - }, - { - "id": "organize", - "options": { - "excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "job": true, "job 1": true, "job 2": true, "instance": true, "instance 1": true, "instance 2": true }, - "renameByName": { - "import_type": "Import Type", - "Value #A": "P95 Duration", - "Value #B": "P99 Duration", - "Value #C": "Max Duration" - } - } - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "continuous-GrYlRd" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "fillOpacity": 80, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "lineWidth": 0, - "scaleDistribution": { "type": "symlog", "log": 2 } - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 52 }, - "id": 24, - "options": { - "calculate": false, - "cellGap": 2, - "color": { - "exponent": 0.5, - "fill": "dark-orange", - "mode": "scheme", - "reverse": false, - "scale": "exponential", - "scheme": "RdYlGn", - "steps": 128 - }, - "exemplars": { "color": "rgba(255,0,255,0.7)" }, - "filterValues": { "le": 1e-9 }, - "legend": { "show": true }, - "rowsFrame": { "layout": "auto" }, - "tooltip": { "mode": "single", "yHistogram": true }, - "yAxis": { - "axisPlacement": "left", - "reverse": false, - "unit": "s" - } - }, - "title": "Import Duration Heatmap", - "type": "heatmap", - "targets": [ - { - "expr": "sum by (le) (increase(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval]))", - "format": "heatmap", - "legendFormat": "{{ le }}", - "refId": "A" - } - ] - }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 }, + "id": 300, + "title": "Score Imports", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { + "mode": "line+area", + "line": { "color": "#FF9830" }, + "area": { "color": "rgba(255, 152, 48, 0.05)" } + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "transparent", "value": null }, + { "color": "rgba(255,152,48,0.08)", "value": 60 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 34 }, + "id": 20, + "options": { + "legend": { + "calcs": ["mean", "max", "lastNotNull"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Max", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Import Duration by Type (P95)", + "type": "timeseries", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "{{ import_type }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 34 }, + "id": 21, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Max", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Import Duration by Type (P50 vs P99)", + "type": "timeseries", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "P50 {{ import_type }}", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "P99 {{ import_type }}", + "refId": "B" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 43 }, + "id": 22, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Total", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Import Count by Type", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (import_type) (increase(score_import_duration_seconds_count{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "{{ import_type }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#FF9830", "value": 30 }, + { "color": "red", "value": 120 } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Import Type" }, + "properties": [ + { "id": "custom.width", "value": 300 }, + { "id": "unit", "value": "string" } + ] + }, + { + "matcher": { "id": "byName", "options": "P95 Duration" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + }, + { + "matcher": { "id": "byName", "options": "P99 Duration" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + }, + { + "matcher": { "id": "byName", "options": "Max Duration" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 43 }, + "id": 23, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "P95 Duration" }] + }, + "title": "Slow Imports by Type", + "type": "table", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[5m])))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[5m])))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "B" + }, + { + "expr": "max by (import_type) (score_import_duration_seconds_sum{job=\"tachi-api\"} / score_import_duration_seconds_count{job=\"tachi-api\"})", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "C" + } + ], + "transformations": [ + { + "id": "seriesToColumns", + "options": { "byField": "import_type" } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Time 1": true, + "Time 2": true, + "Time 3": true, + "__name__": true, + "__name__ 1": true, + "__name__ 2": true, + "job": true, + "job 1": true, + "job 2": true, + "instance": true, + "instance 1": true, + "instance 2": true + }, + "renameByName": { + "import_type": "Import Type", + "Value #A": "P95 Duration", + "Value #B": "P99 Duration", + "Value #C": "Max Duration" + } + } + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "continuous-GrYlRd" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 0, + "scaleDistribution": { "type": "symlog", "log": 2 } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 52 }, + "id": 24, + "options": { + "calculate": false, + "cellGap": 2, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "RdYlGn", + "steps": 128 + }, + "exemplars": { "color": "rgba(255,0,255,0.7)" }, + "filterValues": { "le": 1e-9 }, + "legend": { "show": true }, + "rowsFrame": { "layout": "auto" }, + "tooltip": { "mode": "single", "yHistogram": true }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + }, + "title": "Import Duration Heatmap", + "type": "heatmap", + "targets": [ + { + "expr": "sum by (le) (increase(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval]))", + "format": "heatmap", + "legendFormat": "{{ le }}", + "refId": "A" + } + ] + }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 60 }, - "id": 600, - "title": "Actions", - "type": "row" - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "#73BF69", "value": null }] - } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 0, "y": 61 }, - "id": 50, - "options": { - "colorMode": "background_solid", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "Actions (last hour)", - "type": "stat", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT COUNT(*) AS \"actions\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", - "format": "table", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#F2495C", "value": null }, - { "color": "#FF9830", "value": 0.90 }, - { "color": "#73BF69", "value": 0.98 } - ] - }, - "unit": "percentunit", - "min": 0, - "max": 1 - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 6, "y": 61 }, - "id": 51, - "options": { - "colorMode": "background_solid", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "Success Rate", - "type": "stat", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT COALESCE(COUNT(*) FILTER (WHERE result = 'GOOD')::float / NULLIF(COUNT(*), 0), 1) AS \"rate\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", - "format": "table", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#73BF69", "value": null }, - { "color": "#FF9830", "value": 1 }, - { "color": "#F2495C", "value": 5 } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 12, "y": 61 }, - "id": 52, - "options": { - "colorMode": "background_solid", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "Avg Duration", - "type": "stat", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (ts_end - ts_start))), 0) AS \"duration\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", - "format": "table", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "#73BF69", "value": null }, - { "color": "#FF9830", "value": 1 }, - { "color": "#F2495C", "value": 5 } - ] - } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 18, "y": 61 }, - "id": 53, - "options": { - "colorMode": "background_solid", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true - }, - "title": "Errors (THROW)", - "type": "stat", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT COUNT(*) AS \"errors\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour' AND result = 'THROW'", - "format": "table", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 80, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "GOOD" }, - "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] - }, - { - "matcher": { "id": "byName", "options": "BAD" }, - "properties": [{ "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }] - }, - { - "matcher": { "id": "byName", "options": "THROW" }, - "properties": [{ "id": "color", "value": { "fixedColor": "#F2495C", "mode": "fixed" } }] - } - ] - }, - "gridPos": { "h": 9, "w": 12, "x": 0, "y": 65 }, - "id": 54, - "options": { - "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Total", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Action Volume by Result", - "type": "timeseries", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), result AS metric, COUNT(*)::float AS value FROM action WHERE $__timeFilter(ts_start) GROUP BY 1, 2 ORDER BY 1", - "format": "time_series", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 12, "y": 65 }, - "id": 55, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Action Duration (P50 / P95 / P99)", - "type": "timeseries", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), percentile_cont(0.50) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P50\", percentile_cont(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P95\", percentile_cont(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P99\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY 1", - "format": "time_series", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { - "mode": "line+area", - "line": { "color": "#F2495C" }, - "area": { "color": "rgba(242, 73, 92, 0.05)" } - } - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "transparent", "value": null }, - { "color": "rgba(242,73,92,0.08)", "value": 0.05 } - ] - }, - "unit": "percentunit", - "min": 0, - "max": 1 - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 0, "y": 74 }, - "id": 56, - "options": { - "legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom" }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Failure Rate Over Time", - "type": "timeseries", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), COUNT(*) FILTER (WHERE result != 'GOOD')::float / NULLIF(COUNT(*), 0)::float AS \"Failure Rate\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY 1", - "format": "time_series", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 80, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 9, "w": 12, "x": 12, "y": 74 }, - "id": 57, - "options": { - "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Total", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Actions by Kind", - "type": "timeseries", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), app || '/' || kind AS metric, COUNT(*)::float AS value FROM action WHERE $__timeFilter(ts_start) GROUP BY 1, 2 ORDER BY 1", - "format": "time_series", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "custom": { - "align": "auto", - "cellOptions": { "type": "auto" }, - "inspect": false - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "#FF9830", "value": 1 }, - { "color": "red", "value": 5 } - ] - } - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "Result" }, - "properties": [ - { "id": "custom.width", "value": 80 }, - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - }, - { - "matcher": { "id": "byName", "options": "Duration (s)" }, - "properties": [ - { "id": "custom.width", "value": 120 }, - { "id": "unit", "value": "s" }, - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - }, - { - "matcher": { "id": "byName", "options": "App" }, - "properties": [{ "id": "custom.width", "value": 200 }] - }, - { - "matcher": { "id": "byName", "options": "Kind" }, - "properties": [{ "id": "custom.width", "value": 250 }] - } - ] - }, - "gridPos": { "h": 10, "w": 24, "x": 0, "y": 83 }, - "id": 58, - "options": { - "cellHeight": "sm", - "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, - "showHeader": true, - "sortBy": [{ "desc": true, "displayName": "Time" }] - }, - "title": "Recent Actions", - "type": "table", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT ts_start AS \"Time\", app AS \"App\", kind AS \"Kind\", result AS \"Result\", ROUND(EXTRACT(EPOCH FROM (ts_end - ts_start))::numeric, 3) AS \"Duration (s)\" FROM action WHERE $__timeFilter(ts_start) ORDER BY ts_start DESC LIMIT 50", - "format": "table", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "custom": { - "align": "auto", - "cellOptions": { "type": "auto" }, - "inspect": false - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "#FF9830", "value": 1 }, - { "color": "red", "value": 5 } - ] - } - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "Action" }, - "properties": [ - { "id": "custom.width", "value": 300 }, - { "id": "unit", "value": "string" } - ] - }, - { - "matcher": { "id": "byName", "options": "P95 (s)" }, - "properties": [ - { "id": "unit", "value": "s" }, - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - }, - { - "matcher": { "id": "byName", "options": "P99 (s)" }, - "properties": [ - { "id": "unit", "value": "s" }, - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } - ] - }, - { - "matcher": { "id": "byName", "options": "Throw" }, - "properties": [ - { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } }, - { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } } - ] - } - ] - }, - "gridPos": { "h": 9, "w": 24, "x": 0, "y": 93 }, - "id": 59, - "options": { - "cellHeight": "sm", - "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, - "showHeader": true, - "sortBy": [{ "desc": true, "displayName": "P95 (s)" }] - }, - "title": "Action Performance by Kind", - "type": "table", - "targets": [ - { - "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, - "rawSql": "SELECT app || '/' || kind AS \"Action\", COUNT(*) AS \"Total\", COUNT(*) FILTER (WHERE result = 'GOOD') AS \"Good\", COUNT(*) FILTER (WHERE result = 'BAD') AS \"Bad\", COUNT(*) FILTER (WHERE result = 'THROW') AS \"Throw\", ROUND(AVG(EXTRACT(EPOCH FROM (ts_end - ts_start)))::numeric, 3) AS \"Avg (s)\", ROUND((percentile_cont(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))))::numeric, 3) AS \"P95 (s)\", ROUND((percentile_cont(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))))::numeric, 3) AS \"P99 (s)\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY \"P95 (s)\" DESC LIMIT 20", - "format": "table", - "refId": "A" - } - ] - }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 60 }, + "id": 600, + "title": "Actions", + "type": "row" + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "#73BF69", "value": null }] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 61 }, + "id": 50, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Actions (last hour)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT COUNT(*) AS \"actions\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#F2495C", "value": null }, + { "color": "#FF9830", "value": 0.9 }, + { "color": "#73BF69", "value": 0.98 } + ] + }, + "unit": "percentunit", + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 61 }, + "id": 51, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Success Rate", + "type": "stat", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT COALESCE(COUNT(*) FILTER (WHERE result = 'GOOD')::float / NULLIF(COUNT(*), 0), 1) AS \"rate\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "#F2495C", "value": 5 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 61 }, + "id": 52, + "options": { + "colorMode": "background_solid", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Avg Duration", + "type": "stat", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (ts_end - ts_start))), 0) AS \"duration\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "#F2495C", "value": 5 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 61 }, + "id": 53, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Errors (THROW)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT COUNT(*) AS \"errors\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour' AND result = 'THROW'", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "GOOD" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byName", "options": "BAD" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byName", "options": "THROW" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#F2495C", "mode": "fixed" } } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 65 }, + "id": 54, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Total", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Action Volume by Result", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), result AS metric, COUNT(*)::float AS value FROM action WHERE $__timeFilter(ts_start) GROUP BY 1, 2 ORDER BY 1", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 65 }, + "id": 55, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Max", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Action Duration (P50 / P95 / P99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), percentile_cont(0.50) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P50\", percentile_cont(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P95\", percentile_cont(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P99\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY 1", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { + "mode": "line+area", + "line": { "color": "#F2495C" }, + "area": { "color": "rgba(242, 73, 92, 0.05)" } + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "transparent", "value": null }, + { "color": "rgba(242,73,92,0.08)", "value": 0.05 } + ] + }, + "unit": "percentunit", + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 74 }, + "id": 56, + "options": { + "legend": { + "calcs": ["mean", "max", "lastNotNull"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Failure Rate Over Time", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), COUNT(*) FILTER (WHERE result != 'GOOD')::float / NULLIF(COUNT(*), 0)::float AS \"Failure Rate\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY 1", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 74 }, + "id": 57, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Total", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Actions by Kind", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), app || '/' || kind AS metric, COUNT(*)::float AS value FROM action WHERE $__timeFilter(ts_start) GROUP BY 1, 2 ORDER BY 1", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Result" }, + "properties": [ + { "id": "custom.width", "value": 80 }, + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + }, + { + "matcher": { "id": "byName", "options": "Duration (s)" }, + "properties": [ + { "id": "custom.width", "value": 120 }, + { "id": "unit", "value": "s" }, + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + }, + { + "matcher": { "id": "byName", "options": "App" }, + "properties": [{ "id": "custom.width", "value": 200 }] + }, + { + "matcher": { "id": "byName", "options": "Kind" }, + "properties": [{ "id": "custom.width", "value": 250 }] + } + ] + }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 83 }, + "id": 58, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "Time" }] + }, + "title": "Recent Actions", + "type": "table", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT ts_start AS \"Time\", app AS \"App\", kind AS \"Kind\", result AS \"Result\", ROUND(EXTRACT(EPOCH FROM (ts_end - ts_start))::numeric, 3) AS \"Duration (s)\" FROM action WHERE $__timeFilter(ts_start) ORDER BY ts_start DESC LIMIT 50", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Action" }, + "properties": [ + { "id": "custom.width", "value": 300 }, + { "id": "unit", "value": "string" } + ] + }, + { + "matcher": { "id": "byName", "options": "P95 (s)" }, + "properties": [ + { "id": "unit", "value": "s" }, + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + }, + { + "matcher": { "id": "byName", "options": "P99 (s)" }, + "properties": [ + { "id": "unit", "value": "s" }, + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + } + ] + }, + { + "matcher": { "id": "byName", "options": "Throw" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "mode": "gradient", "type": "color-background" } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 93 }, + "id": 59, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "P95 (s)" }] + }, + "title": "Action Performance by Kind", + "type": "table", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT app || '/' || kind AS \"Action\", COUNT(*) AS \"Total\", COUNT(*) FILTER (WHERE result = 'GOOD') AS \"Good\", COUNT(*) FILTER (WHERE result = 'BAD') AS \"Bad\", COUNT(*) FILTER (WHERE result = 'THROW') AS \"Throw\", ROUND(AVG(EXTRACT(EPOCH FROM (ts_end - ts_start)))::numeric, 3) AS \"Avg (s)\", ROUND((percentile_cont(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))))::numeric, 3) AS \"P95 (s)\", ROUND((percentile_cont(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))))::numeric, 3) AS \"P99 (s)\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY \"P95 (s)\" DESC LIMIT 20", + "format": "table", + "refId": "A" + } + ] + }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 102 }, - "id": 400, - "title": "HTTP Detail", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 70, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 103 }, - "id": 30, - "options": { - "legend": { "calcs": ["mean"], "displayMode": "table", "placement": "bottom", "sortBy": "Mean", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Request Rate by Method", - "type": "timeseries", - "targets": [ - { - "expr": "sum by (method) (rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", - "legendFormat": "{{ method }}", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic-by-name" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 70, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "reqps" - }, - "overrides": [ - { - "matcher": { "id": "byRegexp", "options": "^2.." }, - "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] - }, - { - "matcher": { "id": "byRegexp", "options": "^3.." }, - "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] - }, - { - "matcher": { "id": "byRegexp", "options": "^4.." }, - "properties": [{ "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }] - }, - { - "matcher": { "id": "byRegexp", "options": "^5.." }, - "properties": [{ "id": "color", "value": { "fixedColor": "#F2495C", "mode": "fixed" } }] - } - ] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 103 }, - "id": 31, - "options": { - "legend": { "calcs": ["mean", "sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Mean", "sortDesc": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Request Rate by Status Code", - "type": "timeseries", - "targets": [ - { - "expr": "sum by (status_code) (rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", - "legendFormat": "{{ status_code }}", - "refId": "A" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "continuous-GrYlRd" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "fillOpacity": 80, - "gradientMode": "scheme", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "lineWidth": 0, - "scaleDistribution": { "type": "symlog", "log": 2 } - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 111 }, - "id": 32, - "options": { - "calculate": false, - "cellGap": 2, - "color": { - "exponent": 0.5, - "fill": "dark-orange", - "mode": "scheme", - "reverse": false, - "scale": "exponential", - "scheme": "Turbo", - "steps": 128 - }, - "exemplars": { "color": "rgba(255,0,255,0.7)" }, - "filterValues": { "le": 1e-9 }, - "legend": { "show": true }, - "rowsFrame": { "layout": "auto" }, - "tooltip": { "mode": "single", "yHistogram": true }, - "yAxis": { - "axisPlacement": "left", - "reverse": false, - "unit": "s" - } - }, - "title": "Request Latency Heatmap", - "type": "heatmap", - "targets": [ - { - "expr": "sum by (le) (increase(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval]))", - "format": "heatmap", - "legendFormat": "{{ le }}", - "refId": "A" - } - ] - }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 102 }, + "id": 400, + "title": "HTTP Detail", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 70, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 103 }, + "id": 30, + "options": { + "legend": { + "calcs": ["mean"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Request Rate by Method", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (method) (rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "{{ method }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic-by-name" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 70, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": "^2.." }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byRegexp", "options": "^3.." }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byRegexp", "options": "^4.." }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byRegexp", "options": "^5.." }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#F2495C", "mode": "fixed" } } + ] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 103 }, + "id": 31, + "options": { + "legend": { + "calcs": ["mean", "sum"], + "displayMode": "table", + "placement": "bottom", + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Request Rate by Status Code", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (status_code) (rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "{{ status_code }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "continuous-GrYlRd" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 0, + "scaleDistribution": { "type": "symlog", "log": 2 } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 111 }, + "id": 32, + "options": { + "calculate": false, + "cellGap": 2, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Turbo", + "steps": 128 + }, + "exemplars": { "color": "rgba(255,0,255,0.7)" }, + "filterValues": { "le": 1e-9 }, + "legend": { "show": true }, + "rowsFrame": { "layout": "auto" }, + "tooltip": { "mode": "single", "yHistogram": true }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + }, + "title": "Request Latency Heatmap", + "type": "heatmap", + "targets": [ + { + "expr": "sum by (le) (increase(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval]))", + "format": "heatmap", + "legendFormat": "{{ le }}", + "refId": "A" + } + ] + }, - { - "collapsed": true, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 119 }, - "id": 500, - "title": "System Health (Node.js)", - "type": "row", - "panels": [ - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 120 }, - "id": 40, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Memory Usage", - "type": "timeseries", - "targets": [ - { - "expr": "process_resident_memory_bytes{job=\"tachi-api\"}", - "legendFormat": "RSS", - "refId": "A" - }, - { - "expr": "nodejs_heap_size_total_bytes{job=\"tachi-api\"}", - "legendFormat": "Heap Total", - "refId": "B" - }, - { - "expr": "nodejs_heap_size_used_bytes{job=\"tachi-api\"}", - "legendFormat": "Heap Used", - "refId": "C" - }, - { - "expr": "nodejs_external_memory_bytes{job=\"tachi-api\"}", - "legendFormat": "External", - "refId": "D" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "percentunit", - "min": 0 - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 120 }, - "id": 41, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "CPU Usage", - "type": "timeseries", - "targets": [ - { - "expr": "rate(process_cpu_user_seconds_total{job=\"tachi-api\"}[$__rate_interval])", - "legendFormat": "User", - "refId": "A" - }, - { - "expr": "rate(process_cpu_system_seconds_total{job=\"tachi-api\"}[$__rate_interval])", - "legendFormat": "System", - "refId": "B" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { - "mode": "line", - "line": { "color": "red" } - } - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "red", "value": 0.1 } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 128 }, - "id": 42, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Event Loop Lag", - "type": "timeseries", - "targets": [ - { - "expr": "nodejs_eventloop_lag_seconds{job=\"tachi-api\"}", - "legendFormat": "Event Loop Lag", - "refId": "A" - }, - { - "expr": "nodejs_eventloop_lag_p99_seconds{job=\"tachi-api\"}", - "legendFormat": "P99 Lag", - "refId": "B" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "opacity", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": true, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 128 }, - "id": 43, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "title": "Active Handles & Requests", - "type": "timeseries", - "targets": [ - { - "expr": "nodejs_active_handles_total{job=\"tachi-api\"}", - "legendFormat": "Active Handles", - "refId": "A" - }, - { - "expr": "nodejs_active_requests_total{job=\"tachi-api\"}", - "legendFormat": "Active Requests", - "refId": "B" - } - ] - } - ] - } - ], - "refresh": "30s", - "schemaVersion": 39, - "tags": ["tachi", "api", "performance"], - "templating": { - "list": [ - { - "current": { "selected": false, "text": "Prometheus", "value": "Prometheus" }, - "hide": 0, - "includeAll": false, - "label": "Data Source", - "multi": false, - "name": "datasource", - "options": [], - "query": "prometheus", - "queryValue": "", - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "type": "datasource" - }, - { - "current": { "selected": false, "text": "Postgres", "value": "Postgres" }, - "hide": 0, - "includeAll": false, - "label": "Postgres", - "multi": false, - "name": "pg_datasource", - "options": [], - "query": "postgres", - "queryValue": "", - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "type": "datasource" - } - ] - }, - "time": { "from": "now-3h", "to": "now" }, - "timepicker": {}, - "timezone": "", - "title": "Tachi API", - "uid": "tachi-api-overview", - "version": 1 + { + "collapsed": true, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 119 }, + "id": 500, + "title": "System Health (Node.js)", + "type": "row", + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 120 }, + "id": 40, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Memory Usage", + "type": "timeseries", + "targets": [ + { + "expr": "process_resident_memory_bytes{job=\"tachi-api\"}", + "legendFormat": "RSS", + "refId": "A" + }, + { + "expr": "nodejs_heap_size_total_bytes{job=\"tachi-api\"}", + "legendFormat": "Heap Total", + "refId": "B" + }, + { + "expr": "nodejs_heap_size_used_bytes{job=\"tachi-api\"}", + "legendFormat": "Heap Used", + "refId": "C" + }, + { + "expr": "nodejs_external_memory_bytes{job=\"tachi-api\"}", + "legendFormat": "External", + "refId": "D" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "percentunit", + "min": 0 + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 120 }, + "id": 41, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "CPU Usage", + "type": "timeseries", + "targets": [ + { + "expr": "rate(process_cpu_user_seconds_total{job=\"tachi-api\"}[$__rate_interval])", + "legendFormat": "User", + "refId": "A" + }, + { + "expr": "rate(process_cpu_system_seconds_total{job=\"tachi-api\"}[$__rate_interval])", + "legendFormat": "System", + "refId": "B" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { + "mode": "line", + "line": { "color": "red" } + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.1 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 128 }, + "id": 42, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Event Loop Lag", + "type": "timeseries", + "targets": [ + { + "expr": "nodejs_eventloop_lag_seconds{job=\"tachi-api\"}", + "legendFormat": "Event Loop Lag", + "refId": "A" + }, + { + "expr": "nodejs_eventloop_lag_p99_seconds{job=\"tachi-api\"}", + "legendFormat": "P99 Lag", + "refId": "B" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 128 }, + "id": 43, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Active Handles & Requests", + "type": "timeseries", + "targets": [ + { + "expr": "nodejs_active_handles_total{job=\"tachi-api\"}", + "legendFormat": "Active Handles", + "refId": "A" + }, + { + "expr": "nodejs_active_requests_total{job=\"tachi-api\"}", + "legendFormat": "Active Requests", + "refId": "B" + } + ] + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["tachi", "api", "performance"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "Prometheus", "value": "Prometheus" }, + "hide": 0, + "includeAll": false, + "label": "Data Source", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": { "selected": false, "text": "Postgres", "value": "Postgres" }, + "hide": 0, + "includeAll": false, + "label": "Postgres", + "multi": false, + "name": "pg_datasource", + "options": [], + "query": "postgres", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { "from": "now-3h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Tachi API", + "uid": "tachi-api-overview", + "version": 1 } diff --git a/dev/setup.fish b/dev/setup.fish index 99c3e9a67..880c4733f 100755 --- a/dev/setup.fish +++ b/dev/setup.fish @@ -1,7 +1,6 @@ #!/bin/fish # add a local bin for ts-node -fish_add_path ~/.local/pnpm fish_add_path /tachi/node_modules/.bin source ./dev/fish-plugins/fisher/functions/fisher.fish diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 1d4b62555..124cb3996 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -64,12 +64,22 @@ services: - tachi-minio:/data entrypoint: /usr/bin/minio server /data --console-address=':9001' + # Local SMTP capture + web UI (http://localhost:8025). Server uses tachi-mailpit:1025 via env. + tachi-mailpit: + container_name: tachi-mailpit + image: axllent/mailpit:v1.29.7 + restart: unless-stopped + ports: + - "8025:8025" + - "1025:1025" + tachi-dev: user: "1000:1000" tty: true container_name: tachi-dev depends_on: - tachi-s3 + - tachi-mailpit build: dockerfile: Dockerfile.dev ports: diff --git a/docs/docs/api/routes/gpt-targets.md b/docs/docs/api/routes/gpt-targets.md index 173412ef7..6b154a6da 100644 --- a/docs/docs/api/routes/gpt-targets.md +++ b/docs/docs/api/routes/gpt-targets.md @@ -8,7 +8,7 @@ For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endp ## Retrieve this game's recently achieved targets -`GET /api/v1/games/:game/:playtype/targets/recently-achieved` +`GET /api/v1/games/:gameGroup/:playtype/targets/recently-achieved` !!! info This endpoint returns the 100 most recently achieved goal subscriptions, and 50 most recently achieved quest subscriptions. @@ -69,7 +69,7 @@ GET /api/v1/games/iidx/SP/targets/recently-achieved ## Retrieve this game's recently interacted-with targets -`GET /api/v1/games/:game/:playtype/targets/recently-raised` +`GET /api/v1/games/:gameGroup/:playtype/targets/recently-raised` !!! info This endpoint returns the 100 most recently interacted-with goal subscriptions, and 50 most recently interacted-with quest subscriptions. @@ -135,7 +135,7 @@ GET /api/v1/games/iidx/SP/targets/recently-raised ## Get the most popular goals for this GPT. -`GET /api/v1/games/:game/:playtype/targets/goals/popular` +`GET /api/v1/games/:gameGroup/:playtype/targets/goals/popular` ### Parameters @@ -152,7 +152,7 @@ N/A #### Request ``` -GET /api/v1/games/:game/:playtype/targets/goals/popular +GET /api/v1/games/:gameGroup/:playtype/targets/goals/popular ``` #### Response @@ -174,7 +174,7 @@ GET /api/v1/games/:game/:playtype/targets/goals/popular ## Retrieve information about a specific goal and its subscribers. -`GET /api/v1/games/:game/:playtype/targets/goals/:goalID` +`GET /api/v1/games/:gameGroup/:playtype/targets/goals/:goalID` ### Parameters @@ -193,7 +193,7 @@ None. ## Evaluate a goal upon a user. -`GET /api/v1/games/:game/:playtype/targets/goals/:goalID/evaluate-for` +`GET /api/v1/games/:gameGroup/:playtype/targets/goals/:goalID/evaluate-for` !!! note This endpoint is notably in a bit of a strange position. It can't go under UGPT because @@ -259,7 +259,7 @@ GET /api/v1/games/iidx/SP/targets/goals/some_goal_id/evaluate-for?userID=zkldi ## Search quests for this GPT. -`GET /api/v1/games/:game/:playtype/targets/quests` +`GET /api/v1/games/:gameGroup/:playtype/targets/quests` !!! note You might notice that there's no equivalent endpoint for goals. @@ -284,7 +284,7 @@ You might notice that there's no equivalent endpoint for goals. ## Retrieve information about a specific quest, and who is subscribed to it. -`GET /api/v1/games/:game/:playtype/targets/quests/:questID` +`GET /api/v1/games/:gameGroup/:playtype/targets/quests/:questID` ### Parameters @@ -304,7 +304,7 @@ N/A ## Evaluate a quest for a user, even if they aren't subscribed to it. -`GET /api/v1/games/:game/:playtype/targets/quests/:questID/evaluate-for` +`GET /api/v1/games/:gameGroup/:playtype/targets/quests/:questID/evaluate-for` ### Parameters @@ -337,7 +337,7 @@ N/A ## Search Questlines -`GET /api/v1/games/:game/:playtype/targets/questlines` +`GET /api/v1/games/:gameGroup/:playtype/targets/questlines` ### Parameters @@ -355,7 +355,7 @@ N/A ## Retrieve a questline with a specific ID. -`GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID` +`GET /api/v1/games/:gameGroup/:playtype/targets/questlines/:questlineID` ### Parameters diff --git a/docs/docs/api/routes/gpt.md b/docs/docs/api/routes/gpt.md index 4dcaefbc7..6f49888c4 100644 --- a/docs/docs/api/routes/gpt.md +++ b/docs/docs/api/routes/gpt.md @@ -8,7 +8,7 @@ programmatically, you should see [Game Endpoints](./games.md). ## Retrieve Game:Playtype Configuration. -`GET /api/v1/games/:game/:playtype` +`GET /api/v1/games/:gameGroup/:playtype` ### Parameters @@ -54,7 +54,7 @@ GET /api/v1/games/iidx/SP ## Retrieve the player leaderboard. -`GET /api/v1/games/:game/:playtype/leaderboard` +`GET /api/v1/games/:gameGroup/:playtype/leaderboard` ### Parameters @@ -104,7 +104,7 @@ GET /api/v1/games/iidx/SP/leaderboard ## Retrieve a song and its charts. -`GET /api/v1/games/:game/:playtype/songs/:songID` +`GET /api/v1/games/:gameGroup/:playtype/songs/:songID` ### Parameters @@ -154,7 +154,7 @@ GET /api/v1/games/iidx/SP/songs/1 ## Get popular charts for this game + playtype. -`GET /api/v1/games/:game/:playtype/charts` +`GET /api/v1/games/:gameGroup/:playtype/charts` ### Parameters @@ -227,7 +227,7 @@ GET /api/v1/games/iidx/SP/charts?search=AA ## Retrieve a chart at a specific ID. -`GET /api/v1/games/:game/:playtype/charts/:chartID` +`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID` ### Parameters @@ -270,7 +270,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart_id ## Retrieve playcount for this chart. -`GET /api/v1/games/:game/:playtype/charts/:chartID/playcount` +`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/playcount` ### Parameters @@ -290,7 +290,7 @@ Self-explanatory. ## Retrieve leaderboards for this chart. -`GET /api/v1/games/:game/:playtype/charts/:chartID/pbs` +`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs` ### Parameters @@ -342,7 +342,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart/pbs ## Search for a user's PB on this chart. -`GET /api/v1/games/:game/:playtype/charts/:chartID/pbs/search` +`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs/search` ### Parameters @@ -352,7 +352,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart/pbs ### Response -Same as `/api/v1/games/:game/:playtype/charts/:chartID/pbs`. +Same as `/api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs`. ### Example @@ -362,7 +362,7 @@ See Above. ## Search a GPT's folders. -`GET /api/v1/games/:game/:playtype/folders` +`GET /api/v1/games/:gameGroup/:playtype/folders` ### Parameters @@ -399,7 +399,7 @@ GET /api/v1/games/iidx/SP/folders?search=12 ## Retrieve information on a specific folderID -`GET /api/v1/games/:game/:playtype/folders/:folderID` +`GET /api/v1/games/:gameGroup/:playtype/folders/:folderID` ### Parameters @@ -449,7 +449,7 @@ GET /api/v1/games/iidx/SP/folders/some_folder_id ## Return all the tables for this game -`GET /api/v1/games/:game/:playtype/tables` +`GET /api/v1/games/:gameGroup/:playtype/tables` !!! note Unlike the folders endpoint, this one doesn't have a search parameter. This is because we expect @@ -496,7 +496,7 @@ GET /api/v1/games/bms/7K/tables ## Retrieve folder documents for a specific table. -`GET /api/v1/games/:game/:playtype/tables/:tableID` +`GET /api/v1/games/:gameGroup/:playtype/tables/:tableID` ### Parameters @@ -536,7 +536,7 @@ GET /api/v1/games/bms/7K/tableID/insane ## Retrieve the PB leaderboard for this Game. -`GET /api/v1/games/:game/:playtype/pb-leaderboard` +`GET /api/v1/games/:gameGroup/:playtype/pb-leaderboard` ### Parameters @@ -558,7 +558,7 @@ GET /api/v1/games/bms/7K/tableID/insane ## Get the distribution of players for a provided class. -`GET /api/v1/games/:game/:playtype/player-distribution` +`GET /api/v1/games/:gameGroup/:playtype/player-distribution` ### Parameters @@ -596,7 +596,7 @@ GET /api/v1/games/bms/7K/player-distribution?class=stslDan ``` !!! info -You can find the humanised conversions for these classes in the gptConfig for this GPT. +You can find the humanised conversions for these classes in the gameConfig for this GPT. See [tachi/common](https://github.com/zkldi/Tachi/tree/main/common) for more information. @@ -604,7 +604,7 @@ You can find the humanised conversions for these classes in the gptConfig for th ## Retrieve recent class updates from all users on this game. -`GET /api/v1/games/:game/:playtype/player-distribution` +`GET /api/v1/games/:gameGroup/:playtype/player-distribution` ### Parameters @@ -623,7 +623,7 @@ You can find the humanised conversions for these classes in the gptConfig for th ## Retrieve the most recent highlighted scores for this GPT. -`GET /api/v1/games/:game/:playtype/scores/highlighted` +`GET /api/v1/games/:gameGroup/:playtype/scores/highlighted` ### Parameters diff --git a/docs/docs/api/routes/ugpt-showcase.md b/docs/docs/api/routes/ugpt-showcase.md index 40d608806..90deb2339 100644 --- a/docs/docs/api/routes/ugpt-showcase.md +++ b/docs/docs/api/routes/ugpt-showcase.md @@ -6,7 +6,7 @@ These endpoints are related to the Statistic Showcase feature. ## Evaluate this users set stats -`GET /api/v1/users/:userID/games/:game/:playtype/showcase` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/showcase` ### Parameters @@ -53,7 +53,7 @@ GET /api/v1/users/1/games/iidx/SP/showcase ## Replace a user's stat showcase. -`PATCH /api/v1/users/:userID/games/:game/:playtype/showcase` +`PATCH /api/v1/users/:userID/games/:gameGroup/:playtype/showcase` ### Permissions @@ -101,7 +101,7 @@ PATCH /api/v1/users/1/games/iidx/SP/showcase ## Evaluate a custom stat on this user. -`GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/showcase/custom` ### Parameters diff --git a/docs/docs/api/routes/ugpt-targets.md b/docs/docs/api/routes/ugpt-targets.md index 3d6cb911a..a0a821a0a 100644 --- a/docs/docs/api/routes/ugpt-targets.md +++ b/docs/docs/api/routes/ugpt-targets.md @@ -10,7 +10,7 @@ For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endp ## Retrieve a user's recently achieved targets. -`GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/recently-achieved` ### Parameters @@ -30,7 +30,7 @@ None. ## Retrieve a user's recently raised targets. -`GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/recently-raised` !!! info Recently raised means that the user recently increased their `progress` on the target. It explicitly excludes achieved goals -- only things that they are getting closer to. @@ -53,7 +53,7 @@ None. ## Retrieve this user's quest subscriptions. -`GET /api/v1/users/:userID/games/:game/:playtype/targets/quests` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests` ### Parameters @@ -70,7 +70,7 @@ None. ## Get a user's progress on a specific quest they are subscribed to. -`GET /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID` !!! info If you're looking to evaluate a quest against a user, without knowing whether they're @@ -106,7 +106,7 @@ None. ## Subscribe to a quest. -`PUT /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID` +`PUT /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID` ### Permissions @@ -130,7 +130,7 @@ None. (All relevant info is in the URL.) ## Unsubscribe from a quest. -`DELETE /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID` +`DELETE /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID` ### Permissions diff --git a/docs/docs/api/routes/user-gamept.md b/docs/docs/api/routes/user-gamept.md index 91363dc81..47fc1aa80 100644 --- a/docs/docs/api/routes/user-gamept.md +++ b/docs/docs/api/routes/user-gamept.md @@ -8,7 +8,7 @@ This scenario appears frequently, and is typically shortened to UGPT. ## Get information about a user's plays on a game + playtype. -`GET /api/v1/users/:userID/games/:game/:playtype` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype` ### Parameters @@ -71,7 +71,7 @@ GET /api/v1/users/zkldi/games/iidx/SP ## Search a user's personal bests. -`GET /api/v1/users/:userID/games/:game/:playtype/pbs` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs` ### Parameters @@ -125,7 +125,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs?search=Verfl ## Get a user's best 100 personal bests. -`GET /api/v1/users/:userID/games/:game/:playtype/pbs/best` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/best` This returns the users' best 100 personal bests according to the [Default Rating Algorithm](../../codebase/implementation-details/game-configuration) for this game. @@ -204,7 +204,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs/best?alg=BPI ## Returns all of a users personal bests. -`GET /api/v1/users/:userID/games/:game/:playtype/pbs/all` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/all` ### Parameters @@ -258,7 +258,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs/all ## Get A User's PB for a given chart. -`GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/:chartID` ### Parameters @@ -302,7 +302,7 @@ GET /api/v1/users/1/games/iidx/SP/pbs/some_chart_id ## Search a user's individual scores. -`GET /api/v1/users/:userID/games/:game/:playtype/scores` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/scores` ### Parameters @@ -365,7 +365,7 @@ GET /api/v1/users/zkldimes/iidx/SP/scores?search=Verfl ## Get a user's most recent 100 scores. -`GET /api/v1/users/:userID/games/:game/:playtype/scores/recent` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/scores/recent` ### Parameters @@ -419,7 +419,7 @@ GET /api/v1/users/zkldimes/iidx/SP/scores/recent ## Search a user's sessions. -`GET /api/v1/users/:userID/games/:game/:playtype/sessions` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions` Searches the names of sessions from a given user. This does not search session descriptions, nor does it search @@ -461,7 +461,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions?search=epic%20session ## Get a user's best 100 sessions. -`GET /api/v1/users/:userID/games/:game/:playtype/sessions/best` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/best` Retrieves a user's best 100 sessions according to the game + playtypes default algorithm. The algorithm can @@ -520,7 +520,7 @@ The default rating algorithm for IIDX:SP is `ktRating`. ## Get a user's most recent 100 sessions. -`GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/recent` Retrieves a user's most recent 100 sessions for this game. @@ -540,7 +540,7 @@ None. ## Get a user's most recent session. -`GET /api/v1/users/:userID/games/:game/:playtype/sessions/last` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/last` !!! info This endpoint will return 404 if the user has never had a @@ -576,7 +576,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions/last ## Get a user's most recent 100 highlighted sessions. -`GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/highlighted` Retrieves a user's most recent 100 highlighted sessions for this game. @@ -622,7 +622,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions/highlighted ## Get a user's most played charts. -`GET /api/v1/users/:userID/games/:game/:playtype/most-played` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/most-played` ### Parameters @@ -689,7 +689,7 @@ GET /api/v1/users/zkldimes/iidx/SP/most-played ## Retrieve a leaderboard around a user. -`GET /api/v1/users/:userID/games/:game/:playtype/leaderboard-adjacent` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/leaderboard-adjacent` ### Parameters @@ -768,7 +768,7 @@ GET /api/v1/users/zkldimes/iidx/SP/leaderboard-adjacent ## Retrieve this user's GPT stat history. -`GET /api/v1/users/:userID/games/:game/:playtype/history` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/history` Every day, a user's game stats are snapshotted and saved. This returns the recent ones. @@ -815,7 +815,7 @@ GET /api/v1/users/1/games/iidx/SP/history ## Retrieve this user's GPT settings. -`GET /api/v1/users/:userID/games/:game/:playtype/settings` +`GET /api/v1/users/:userID/games/:gameGroup/:playtype/settings` !!! warning Unlike most other applications, your settings are completely public. GPT Settings only concern @@ -854,7 +854,7 @@ GET /api/v1/users/1/games/iidx/SP/settings ## Modify your UGPT settings. -`PATCH /api/v1/users/:userID/games/:game/:playtype/settings` +`PATCH /api/v1/users/:userID/games/:gameGroup/:playtype/settings` !!! note Although `stats` are part of your settings, they are not modifiable under these endpoints, diff --git a/docs/docs/codebase/setup/config.md b/docs/docs/codebase/setup/config.md index 3a5914851..603816e91 100644 --- a/docs/docs/codebase/setup/config.md +++ b/docs/docs/codebase/setup/config.md @@ -228,30 +228,27 @@ How many unique players have to have played a chart on the beatoraja IR for it t - Type: String Where *this* server is hosted. This is used to -provide callback URLs inside emails. You may stub -it out if emails are unsupported. +provide callback URLs inside emails. ### EMAIL_CONFIG -- Type: EMAIL_CONFIG | undefined. +- Type: EMAIL_CONFIG (required) -Configures how emails will be sent by Tachi. -If not present, email calls will become no-ops, and -certain features (such as resetting passwords) -will be disabled. +SMTP is always configured. Set: -`FROM` determines the email `From` header, and optionally -`SENDMAIL_BIN` can override the location of the `sendmail` -binary. Defaults to `/usr/bin/sendmail`, but some distros -may have it in `sbin`. +- `TACHI_EMAIL_FROM` — `From` header (must match a verified sender when using Postmark). +- `TACHI_EMAIL_AUTH_POSTMARK` — `true` or `false`. When `true`, uses Postmark’s SMTP + endpoint; set `TACHI_EMAIL_AUTH_USER` and `TACHI_EMAIL_AUTH_PASS` to your server API token + (both are the token for Postmark SMTP). +- When Postmark is `false`: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` + (`true` / `false`), and optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` + for servers that require auth (local Mailpit typically needs no auth). -`TRANSPORT_OPS` Passes a set of options to the email transport. For more -information, see the nodemailer documentation for SMTPTransport.Options. +`TRANSPORT_OPS` is derived from these variables and passed to Nodemailer. ```ts interface EMAIL_CONFIG { FROM: string; - SENDMAIL_BIN?: string TRANSPORT_OPS: any; } ``` diff --git a/homepage/README.md b/homepage/README.md index 80fc17246..dd2c8ab38 100644 --- a/homepage/README.md +++ b/homepage/README.md @@ -1,18 +1,3 @@ # Tachi landing page This is the landing page for [https://tachi.ac](https://tachi.ac). - -## Contributing - -Basic landing page with SCSS compile step. - -```shell -pnpm install -pnpm run watch # Refresh dist/ on src/ change -pnpm run compile # Compile site to dist/ - -# then open `dist/index.html` in your browser. -``` - -Tested with Node.js `20.10.0` & pnpm `8.12.0` - Please ensure you are using these versions. - diff --git a/package.json b/package.json index 03ecbf20f..ca31a6cff 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-perfectionist": "^3.9.1", "eslint-plugin-react": "^7.37.4", diff --git a/typescript/bliss/src/actions.ts b/typescript/bliss/src/actions.ts index e208181d5..cb87721a6 100644 --- a/typescript/bliss/src/actions.ts +++ b/typescript/bliss/src/actions.ts @@ -95,9 +95,10 @@ export function MakeActionGuts({ const inputParsed = inputSchema.safeParse(input); if (!inputParsed.success) { - throw new Error( - `Action ${kind} received invalid input: ${inputParsed.error.message}`, - ); + const msg = inputParsed.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; "); + throw new ExpectedErr(400, msg || `Action ${kind} received invalid input.`); } retval = await actionBodyFn(taker, input); diff --git a/typescript/bot/package.json b/typescript/bot/package.json index 596a56e90..6745164f2 100644 --- a/typescript/bot/package.json +++ b/typescript/bot/package.json @@ -7,13 +7,11 @@ "license": "MIT", "scripts": { "build": "tsgo -b", - "start": "pnpm build && node -r ts-node/register -r tsconfig-paths/register js/main.js", - "start-no-build": "node -r ts-node/register -r tsconfig-paths/register js/main.js", "lint": "eslint .", "lint-fix": "eslint . --fix", "typecheck": "tsgo --noEmit", "test": "vitest", - "bench": "vitest bench --passWithNoTests" + "bench": "vitest bench" }, "devDependencies": { "@types/express": "catalog:", diff --git a/typescript/bot/src/actions.ts b/typescript/bot/src/actions.ts index 96a9498af..51df272dc 100644 --- a/typescript/bot/src/actions.ts +++ b/typescript/bot/src/actions.ts @@ -21,7 +21,7 @@ export const ActionSignatures = { session_count: z.number().int(), error_count: z.number().int(), user_id: z.number().int(), - game: z.string(), + games: z.string().array(), }), }, } satisfies Record; diff --git a/typescript/bot/src/actions/sync.test.ts b/typescript/bot/src/actions/sync.test.ts index 5532e4873..2035bfb16 100644 --- a/typescript/bot/src/actions/sync.test.ts +++ b/typescript/bot/src/actions/sync.test.ts @@ -1,4 +1,4 @@ -import type { MONGO_ImportDocument } from "tachi-common"; +import type { ImportDocument } from "tachi-common"; import db from "#services/pg/db"; import { createTestAccount } from "#test-utils/db"; @@ -13,11 +13,12 @@ vi.mock("../utils/api-requests"); const { PerformScoreImport } = await import("../utils/api-requests"); -function makeImportDoc(overrides: Partial = {}): MONGO_ImportDocument { +function makeImportDoc(overrides: Partial = {}): ImportDocument { return { importID: "import-abc-123", userID: 1, - game: "iidx-sp", + gameGroup: "iidx", + games: ["iidx-sp"], scoreIDs: ["s1", "s2"], createdSessions: [{ sessionID: "sess1", type: "Created" }], errors: [], @@ -25,7 +26,7 @@ function makeImportDoc(overrides: Partial = {}): MONGO_Imp goalProgress: [], questProgress: [], ...overrides, - } as unknown as MONGO_ImportDocument; + } as unknown as ImportDocument; } describe("ACTION_Sync", () => { @@ -50,7 +51,7 @@ describe("ACTION_Sync", () => { session_count: 1, error_count: 0, user_id: 1, - game: "iidx-sp", + games: ["iidx-sp"], }); }); @@ -82,7 +83,7 @@ describe("ACTION_Sync", () => { it("throws ExpectedErr when PerformScoreImport returns an error string", async () => { vi.mocked(PerformScoreImport).mockResolvedValue( - "Your API key has expired." as unknown as MONGO_ImportDocument, + "Your API key has expired." as unknown as ImportDocument, ); await expect(ACTION_Sync(taker, syncInput)).rejects.toMatchObject({ @@ -93,7 +94,7 @@ describe("ACTION_Sync", () => { it("the ExpectedErr thrown on failure is an ExpectedErr instance", async () => { vi.mocked(PerformScoreImport).mockResolvedValue( - "Import failed." as unknown as MONGO_ImportDocument, + "Import failed." as unknown as ImportDocument, ); await expect(ACTION_Sync(taker, syncInput)).rejects.toBeInstanceOf(ExpectedErr); @@ -120,9 +121,7 @@ describe("ACTION_Sync", () => { }); it("writes a BAD action row when PerformScoreImport returns an error string", async () => { - vi.mocked(PerformScoreImport).mockResolvedValue( - "Bad import." as unknown as MONGO_ImportDocument, - ); + vi.mocked(PerformScoreImport).mockResolvedValue("Bad import." as unknown as ImportDocument); await expect(ACTION_Sync(taker, syncInput)).rejects.toBeInstanceOf(ExpectedErr); diff --git a/typescript/bot/src/actions/sync.ts b/typescript/bot/src/actions/sync.ts index f1cc47aa7..c939ac689 100644 --- a/typescript/bot/src/actions/sync.ts +++ b/typescript/bot/src/actions/sync.ts @@ -20,7 +20,7 @@ export const ACTION_Sync = MakeAction( session_count: importDoc.createdSessions.length, error_count: importDoc.errors.length, user_id: importDoc.userID, - game: importDoc.game, + games: importDoc.games, }; }, ); diff --git a/typescript/bot/src/config.ts b/typescript/bot/src/config.ts index 1411abbc5..1f7cc4d71 100644 --- a/typescript/bot/src/config.ts +++ b/typescript/bot/src/config.ts @@ -1,4 +1,4 @@ -import type { GameGroup, integer, TachiServerCoreConfig } from "tachi-common"; +import type { GameGroup, integer, TachiServerCoreConfig, V3Game } from "tachi-common"; import { log } from "bliss/log"; import { config } from "dotenv"; @@ -49,7 +49,7 @@ export interface ProcessEnvironment { OAUTH_CLIENT_SECRET: string; DISCORD_TOKEN: string; DISCORD_SERVER_ID: string; - DISCORD_GAME_CHANNELS: Partial>; + DISCORD_GAME_CHANNELS: Partial>; DISCORD_ADMIN_USERS: string[]; DISCORD_APPROVED_ROLE: string | undefined; DISCORD_LIMBO_CHANNEL: string | undefined; @@ -135,7 +135,7 @@ export const ServerConfig = await GetServerConfig(); // General warnings for config misuse. // This warns people if their parent server supports games that they aren't acknowledging. -for (const game of ServerConfig.GAMES) { +for (const game of ServerConfig.GAME_GROUPS) { if (!Object.prototype.hasOwnProperty.call(Env.DISCORD_GAME_CHANNELS, game)) { log.warn( `${ServerConfig.NAME} declares support for ${game}, but no channel is mapped to it. Set DISCORD_GAME_CHANNELS to include it.`, diff --git a/typescript/bot/src/server/server.ts b/typescript/bot/src/server/server.ts index 274914268..e78a406d7 100644 --- a/typescript/bot/src/server/server.ts +++ b/typescript/bot/src/server/server.ts @@ -1,4 +1,4 @@ -import type { MONGO_APITokenDocument, MONGO_UserDocument, WebhookEvents } from "tachi-common"; +import type { APITokenDocument, UserDocument, WebhookEvents } from "tachi-common"; import { log } from "#utils/log"; import { HandleQuestAchievedV1 } from "#webhook-handlers/quest-achieved"; @@ -58,7 +58,7 @@ app.get("/oauth/callback", async (req, res) => { return res.status(400).send("Bad Request."); } - const tokenRes = await TachiServerV1Request( + const tokenRes = await TachiServerV1Request( RequestTypes.POST, "/oauth/token", null, @@ -84,7 +84,7 @@ app.get("/oauth/callback", async (req, res) => { const discordID = req.query.context; const apiToken = tokenRes.body.token!; - const whoamiRes = await TachiServerV1Get("/users/me", apiToken); + const whoamiRes = await TachiServerV1Get("/users/me", apiToken); if (!whoamiRes.success) { log.error({ discordID }, "Failed to request user with token we just got?"); diff --git a/typescript/bot/src/slash-commands/commands/sync.ts b/typescript/bot/src/slash-commands/commands/sync.ts index d6c8c4dd5..fcbbf9e5d 100644 --- a/typescript/bot/src/slash-commands/commands/sync.ts +++ b/typescript/bot/src/slash-commands/commands/sync.ts @@ -66,7 +66,7 @@ const command: SlashCommand = { .addField("Errors", result.error_count.toString(), true) .addField( "Your Profile", - `${Env.TACHI_SERVER_LOCATION}/u/${result.user_id}/games/${result.game}`, + `${Env.TACHI_SERVER_LOCATION}/u/${result.user_id}/games/${result.games[0]}`, ); } catch (e) { if (ExpectedErr.is(e)) { diff --git a/typescript/bot/src/utils/api-requests.ts b/typescript/bot/src/utils/api-requests.ts index 4407ce686..7cf4449ab 100644 --- a/typescript/bot/src/utils/api-requests.ts +++ b/typescript/bot/src/utils/api-requests.ts @@ -1,19 +1,18 @@ import type { CommandInteraction } from "discord.js"; -import type { - GameGroup, - integer, - MONGO_ChartDocument, - MONGO_GoalDocument, - MONGO_ImportDocument, - MONGO_PBScoreDocument, - MONGO_QuestDocument, - MONGO_SongDocument, - MONGO_UserDocument, - Playtype, -} from "tachi-common"; import { Env } from "#config"; import { log } from "#utils/log"; +import { + type ChartDocument, + type GoalDocument, + type ImportDocument, + type integer, + type PBScoreDocument, + type QuestDocument, + type SongDocument, + type UserDocument, + type V3Game, +} from "tachi-common"; import type { ImportDeferred, ImportPollStatus, UGPTStats } from "./return-types"; @@ -21,7 +20,7 @@ import { RequestTypes, TachiServerV1Get, TachiServerV1Request } from "./fetch-ta import { Sleep } from "./misc"; export async function GetUserInfo(userID: string | integer) { - const res = await TachiServerV1Get(`/users/${userID}`, null); + const res = await TachiServerV1Get(`/users/${userID}`, null); if (!res.success) { throw new Error(`Failed to fetch user with userID ${userID}.`); @@ -30,22 +29,19 @@ export async function GetUserInfo(userID: string | integer) { return res.body; } -export async function GetUGPTStats(userID: string | integer, game: GameGroup, playtype: Playtype) { - const res = await TachiServerV1Get( - `/users/${userID}/games/${game}/${playtype}`, - null, - ); +export async function GetUGPTStats(userID: string | integer, game: V3Game) { + const res = await TachiServerV1Get(`/users/${userID}/games/${game}`, null); if (!res.success) { - throw new Error(`Failed to fetch UGPT stats for userID ${userID}, ${game}, ${playtype}.`); + throw new Error(`Failed to fetch UGPT stats for userID ${userID}, ${game}.`); } return res.body; } -export async function GetGoalWithID(goalID: string, game: GameGroup, playtype: Playtype) { - const res = await TachiServerV1Get<{ goal: MONGO_GoalDocument }>( - `/games/${game}/${playtype}/targets/goals/${goalID}`, +export async function GetGoalWithID(goalID: string, game: V3Game) { + const res = await TachiServerV1Get<{ goal: GoalDocument }>( + `/games/${game}/targets/goals/${goalID}`, null, ); @@ -56,9 +52,9 @@ export async function GetGoalWithID(goalID: string, game: GameGroup, playtype: P return res.body.goal; } -export async function GetQuestWithID(questID: string, game: GameGroup, playtype: Playtype) { - const res = await TachiServerV1Get<{ quest: MONGO_QuestDocument }>( - `/games/${game}/${playtype}/targets/quests/${questID}`, +export async function GetQuestWithID(questID: string, game: V3Game) { + const res = await TachiServerV1Get<{ quest: QuestDocument }>( + `/games/${game}/targets/quests/${questID}`, null, ); @@ -69,14 +65,9 @@ export async function GetQuestWithID(questID: string, game: GameGroup, playtype: return res.body.quest; } -export async function GetChartInfoForUser( - userID: string | integer, - chartID: string, - game: GameGroup, - playtype: Playtype, -) { - const res = await TachiServerV1Get<{ chart: MONGO_ChartDocument; song: MONGO_SongDocument }>( - `/games/${game}/${playtype}/charts/${chartID}`, +export async function GetChartInfoForUser(userID: string | integer, chartID: string, game: V3Game) { + const res = await TachiServerV1Get<{ chart: ChartDocument; song: SongDocument }>( + `/games/${game}/charts/${chartID}`, null, ); @@ -84,8 +75,8 @@ export async function GetChartInfoForUser( throw new Error(`Failed to fetch song/chart with chartID ${chartID}.`); } - const pbRes = await TachiServerV1Get<{ chart: MONGO_ChartDocument; pb: MONGO_PBScoreDocument }>( - `/users/${userID}/games/${game}/${playtype}/pbs/${chartID}`, + const pbRes = await TachiServerV1Get<{ chart: ChartDocument; pb: PBScoreDocument }>( + `/users/${userID}/games/${game}/pbs/${chartID}`, null, ); @@ -104,7 +95,7 @@ export async function PerformScoreImport( body: Record, interaction?: CommandInteraction, ) { - const initRes = await TachiServerV1Request( + const initRes = await TachiServerV1Request( RequestTypes.POST, url, authToken, @@ -122,7 +113,7 @@ export async function PerformScoreImport( // this server does not defer imports to a scorequeue if (initRes.statusCode === 200) { - const result = initRes.body as MONGO_ImportDocument; + const result = initRes.body as ImportDocument; return result; } else if (initRes.statusCode === 202) { diff --git a/typescript/bot/src/utils/arg-parsers.ts b/typescript/bot/src/utils/arg-parsers.ts index dc9533e2f..7522406c0 100644 --- a/typescript/bot/src/utils/arg-parsers.ts +++ b/typescript/bot/src/utils/arg-parsers.ts @@ -1,23 +1,21 @@ import type { CommandInteraction } from "discord.js"; import type { PrivDiscordUserMap } from "tachi-db"; -import { type GameGroup, type MONGO_UserDocument, type Playtype } from "tachi-common"; +import { IsValidGame, type UserDocument, type V3Game } from "tachi-common"; import type { Emittable } from "../slash-commands/types"; import { GetUserInfo } from "./api-requests"; -import { ParseGPT } from "./misc"; /** - * Utility parser for getting the game, playtype and requesting user, since this is + * Utility parser for getting the game and requesting user, since this is * a common pattern in the bot. */ -export async function GetGPTAndUser( +export async function GetGameAndUser( interaction: CommandInteraction, requestingUser: PrivDiscordUserMap, ): Promise< - | { content: { game: GameGroup; playtype: Playtype; userDoc: MONGO_UserDocument }; error: null } - | { error: Emittable } + { content: { game: V3Game; userDoc: UserDocument }; error: null } | { error: Emittable } > { const userID = interaction.options.getString("other_user") ?? requestingUser.user_id.toString(); @@ -33,7 +31,11 @@ export async function GetGPTAndUser( return { error: `This user does not exist.` }; } - const { game, playtype } = ParseGPT(interaction.options.getString("game", true)); + const game = interaction.options.getString("game", true); - return { error: null, content: { userDoc, game, playtype } }; + if (!IsValidGame(game)) { + return { error: `Invalid game: ${game}.` }; + } + + return { error: null, content: { userDoc, game } }; } diff --git a/typescript/bot/src/utils/embeds.ts b/typescript/bot/src/utils/embeds.ts index ac8970260..e35979d76 100644 --- a/typescript/bot/src/utils/embeds.ts +++ b/typescript/bot/src/utils/embeds.ts @@ -1,4 +1,4 @@ -import type { integer, MONGO_ImportDocument, MONGO_UserDocument } from "tachi-common"; +import type { ImportDocument, integer, UserDocument } from "tachi-common"; import { MessageEmbed } from "discord.js"; @@ -18,7 +18,7 @@ export function CreateEmbed(userID?: integer) { return embed; } -export function CreateImportEmbed(importDoc: MONGO_ImportDocument) { +export function CreateImportEmbed(importDoc: ImportDocument) { return CreateEmbed() .setTitle( `Imported ${importDoc.scoreIDs.length} ${Pluralise( @@ -30,11 +30,11 @@ export function CreateImportEmbed(importDoc: MONGO_ImportDocument) { .addField("Errors", importDoc.errors.length.toString(), true) .addField( "Your Profile", - `${Env.TACHI_SERVER_LOCATION}/u/${importDoc.userID}/games/${importDoc.game}`, + `${Env.TACHI_SERVER_LOCATION}/u/${importDoc.userID}/games/${importDoc.games[0]}`, ); } -export function CreateUserEmbed(userDoc: MONGO_UserDocument) { +export function CreateUserEmbed(userDoc: UserDocument) { return CreateEmbed() .setTitle(`${userDoc.username} (ID: ${userDoc.id})`) .setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`)) diff --git a/typescript/bot/src/utils/misc.ts b/typescript/bot/src/utils/misc.ts index 9d55c37d6..b7512d920 100644 --- a/typescript/bot/src/utils/misc.ts +++ b/typescript/bot/src/utils/misc.ts @@ -4,15 +4,11 @@ import type { ClassInfo } from "tachi-common/types/game-config-utils"; import _ from "lodash"; import { DateTime } from "luxon"; import { + type ChartDocument, type Classes, - type GameGroup, - type GameGroupConfig, - GetGameGroupConfig, - GetGamePTConfig, - type GPTString, + GetGameConfig, type integer, - type MONGO_ChartDocument, - type Playtype, + type V3Game, } from "tachi-common"; import { Env } from "../config"; @@ -40,22 +36,6 @@ export function Entries(rec: Partial>): Array< return Object.entries(rec) as Array<[K, V]>; } -export function ParseGPT(str: string) { - const spl = str.split(":"); - - const game = spl[0] as GameGroup; - const playtype = spl[1] as Playtype; - - // this is an interesting way of game checking noah - const gameConfig = GetGameGroupConfig(game) as GameGroupConfig | undefined; - - if (!gameConfig?.playtypes.includes(playtype)) { - throw new Error(`Invalid GPT Combination '${str}'.`); - } - - return { game, playtype }; -} - export function UppercaseFirst(str: string) { if (!str[0]) { return ""; @@ -72,24 +52,17 @@ export function FormatDate(ms: number) { return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_HUGE); } -export function FormatClass( - game: GameGroup, - playtype: Playtype, - classSet: Classes[GPTString], - classValue: string, -) { - const gptConfig = GetGamePTConfig(game, playtype); +export function FormatClass(game: V3Game, classSet: Classes[V3Game], classValue: string) { + const gameConfig = GetGameConfig(game); // @ts-expect-error hacky access - const classInfo: ClassInfo = gptConfig.classes[classSet]?.values?.find?.( + const classInfo: ClassInfo = gameConfig.classes[classSet]?.values?.find?.( (k) => k.id === classValue, ); if (!classInfo) { - throw new Error( - `Couldn't find a class at index ${classValue} for ${game} ${playtype} ${classSet}?`, - ); + throw new Error(`Couldn't find a class at index ${classValue} for ${game} ${classSet}?`); } return classInfo.display as string; @@ -98,7 +71,7 @@ export function FormatClass( /** * Given a game, return the discord channel it's associated with. */ -export function GetGameChannel(client: Client, game: GameGroup) { +export function GetGameChannel(client: Client, game: V3Game) { const gameChannelID = Env.DISCORD_GAME_CHANNELS[game]; if (!gameChannelID) { @@ -145,14 +118,8 @@ export function GetLimboChannel(client: Client) { /** * Given a chart and a game, return a link to the site for that chart. */ -export function CreateChartLink(chart: MONGO_ChartDocument, game: GameGroup) { - if (chart.isPrimary) { - return `${Env.TACHI_SERVER_LOCATION}/games/${game}/${chart.playtype}/songs/${ - chart.songID - }/${encodeURIComponent(chart.difficulty)}`; - } - - return `${Env.TACHI_SERVER_LOCATION}/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`; +export function CreateChartLink(chart: ChartDocument) { + return `${Env.TACHI_SERVER_LOCATION}/games/${chart.game}/charts/${chart.chartID}`; } export function ConvertInputIntoGenerousRegex(input: string) { diff --git a/typescript/bot/src/utils/options.ts b/typescript/bot/src/utils/options.ts index b903d4d43..6c4bf8799 100644 --- a/typescript/bot/src/utils/options.ts +++ b/typescript/bot/src/utils/options.ts @@ -1,25 +1,25 @@ import type { SlashCommandStringOption } from "@discordjs/builders"; -import { FormatGameGroup, GetGameGroupConfig } from "tachi-common"; +import { FormatGame, GetGameGroupConfig } from "tachi-common"; import { ServerConfig } from "../config"; /** - * Game Playtype options. Frequently used by things that might need + * Game options. Frequently used by things that might need * game specific listening. */ -const GPTChoices: Array<[string, string]> = []; +const GameChoices: Array<[string, string]> = []; -for (const game of ServerConfig.GAMES) { - const gameConfig = GetGameGroupConfig(game); +for (const gameGroup of ServerConfig.GAME_GROUPS) { + const gameGroupConfig = GetGameGroupConfig(gameGroup); - for (const playtype of gameConfig.playtypes) { - GPTChoices.push([FormatGameGroup(game, playtype), `${game}:${playtype}`]); + for (const game of gameGroupConfig.games) { + GameChoices.push([FormatGame(game), game]); } } -export const GPTOptions = (str: SlashCommandStringOption) => - str.setName("game").setDescription("Pick the relevant game.").addChoices(GPTChoices); +export const GameOptions = (str: SlashCommandStringOption) => + str.setName("game").setDescription("Pick the relevant game.").addChoices(GameChoices); export function MakeRequired(fn: (str: SlashCommandStringOption) => SlashCommandStringOption) { return (str: SlashCommandStringOption) => fn(str).setRequired(true); diff --git a/typescript/bot/src/utils/return-types.ts b/typescript/bot/src/utils/return-types.ts index 2f386af96..eb2909ed0 100644 --- a/typescript/bot/src/utils/return-types.ts +++ b/typescript/bot/src/utils/return-types.ts @@ -1,13 +1,13 @@ import type { APIPermissions, - GPTString, + ChartDocument, + ImportDocument, integer, - MONGO_ChartDocument, - MONGO_ImportDocument, - MONGO_ScoreDocument, - MONGO_SongDocument, - MONGO_UserGameStats, ProfileRatingAlgorithms, + ScoreDocument, + SongDocument, + UserGameStats, + V3Game, } from "tachi-common"; export interface ServerStatus { @@ -25,7 +25,7 @@ export interface ImportDeferred { export type ImportPollStatus = | { - import: MONGO_ImportDocument; + import: ImportDocument; importStatus: "completed"; } | { @@ -36,15 +36,15 @@ export type ImportPollStatus = }; }; -export interface UGPTStats { - gameStats: MONGO_UserGameStats; - firstScore: MONGO_ScoreDocument; - mostRecentScore: MONGO_ScoreDocument; +export interface UGPTStats { + gameStats: UserGameStats; + firstScore: ScoreDocument; + mostRecentScore: ScoreDocument; totalScores: integer; - rankingData: Record; + rankingData: Record; } export interface ChartQueryReturns { - charts: Array<{ __playcount: integer } & MONGO_ChartDocument>; - songs: Array; + charts: Array<{ __playcount: integer } & ChartDocument>; + songs: Array; } diff --git a/typescript/bot/src/webhook-handlers/class-update.ts b/typescript/bot/src/webhook-handlers/class-update.ts index 5f0399d59..1ddc13527 100644 --- a/typescript/bot/src/webhook-handlers/class-update.ts +++ b/typescript/bot/src/webhook-handlers/class-update.ts @@ -1,12 +1,10 @@ import { log } from "#utils/log"; import { type Classes, - FormatGameGroup, - type GameGroup, - GetGamePTConfig, - type GPTString, + FormatGame, + GetGameConfig, type integer, - type Playtype, + type V3Game, type WebhookEventClassUpdateV1, } from "tachi-common"; @@ -20,7 +18,7 @@ import { FormatClass, GetGameChannel } from "../utils/misc"; export async function HandleClassUpdateV1( event: WebhookEventClassUpdateV1["content"], ): Promise { - const { game, playtype } = event; + const { game } = event; let channel; @@ -33,7 +31,7 @@ export async function HandleClassUpdateV1( return 500; } - if (!ShouldRenderUpdate(game, playtype, event.set, event.new)) { + if (!ShouldRenderUpdate(game, event.set, event.new)) { log.info( `Not rendering class update ${event.set}: ${event.old} -> ${event.new} (not relevant).`, ); @@ -42,10 +40,10 @@ export async function HandleClassUpdateV1( const userDoc = await GetUserInfo(event.userID); - const minimumNecessaryScores = GetMinimumScores(game, playtype, event.set); + const minimumNecessaryScores = GetMinimumScores(game, event.set); if (minimumNecessaryScores !== null) { - const { totalScores } = await GetUGPTStats(userDoc.id, game, playtype); + const { totalScores } = await GetUGPTStats(userDoc.id, game); // Do not render if the user hasn't hit the score cap. if (totalScores < minimumNecessaryScores) { @@ -56,19 +54,15 @@ export async function HandleClassUpdateV1( } } - const newClass = FormatClass(game, playtype, event.set, event.new); + const newClass = FormatClass(game, event.set, event.new); const embed = CreateEmbed() - .setTitle( - `${userDoc.username} just achieved ${newClass} in ${FormatGameGroup(game, playtype)}!`, - ) - .setURL(`${Env.TACHI_SERVER_LOCATION}/users/${userDoc.username}/games/${game}/${playtype}`) + .setTitle(`${userDoc.username} just achieved ${newClass} in ${FormatGame(game)}!`) + .setURL(`${Env.TACHI_SERVER_LOCATION}/u/${userDoc.username}/games/${game}`) .setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`)); if (event.old !== null) { - embed.setDescription( - `(This was raised from ${FormatClass(game, playtype, event.set, event.old)}.)`, - ); + embed.setDescription(`(This was raised from ${FormatClass(game, event.set, event.old)}.)`); } await channel.send({ embeds: [embed] }); @@ -79,17 +73,12 @@ export async function HandleClassUpdateV1( /** * Returns Whether this class update is notable enough to be rendered or not. */ -function ShouldRenderUpdate( - game: GameGroup, - playtype: Playtype, - classSet: Classes[GPTString], - classValue: string, -) { - const config = GetGamePTConfig(game, playtype); +function ShouldRenderUpdate(game: V3Game, classSet: Classes[V3Game], classValue: string) { + const config = GetGameConfig(game); const classSpec = config.classes[classSet]; if (classSpec === undefined) { - log.error(`Invalid class ${classSet} for ${game} ${playtype}`); + log.error(`Invalid class ${classSet} for ${game}`); return false; } @@ -103,28 +92,24 @@ function ShouldRenderUpdate( const minimumId = ids.indexOf(classSpec.minimumRelevantValue); if (currentId < 0) { - log.error(`Invalid classValue ${classValue} for ${game} ${playtype}`); + log.error(`Invalid classValue ${classValue} for ${game}`); return false; } if (minimumId < 0) { - log.error(`Invalid minimum classValue ${classValue} for ${game} ${playtype}`); + log.error(`Invalid minimum classValue ${classValue} for ${game}`); return false; } return currentId >= minimumId; } -function GetMinimumScores( - game: GameGroup, - playtype: Playtype, - classSet: Classes[GPTString], -): integer | null { - const config = GetGamePTConfig(game, playtype); +function GetMinimumScores(game: V3Game, classSet: Classes[V3Game]): integer | null { + const config = GetGameConfig(game); const classSpec = config.classes[classSet]; if (classSpec === undefined) { - log.error(`Invalid class ${classSet} for ${game} ${playtype}`); + log.error(`Invalid class ${classSet} for ${game}`); return null; } diff --git a/typescript/bot/src/webhook-handlers/goal-achieved.ts b/typescript/bot/src/webhook-handlers/goal-achieved.ts index 608b87f81..061b8553a 100644 --- a/typescript/bot/src/webhook-handlers/goal-achieved.ts +++ b/typescript/bot/src/webhook-handlers/goal-achieved.ts @@ -2,9 +2,9 @@ import { Env } from "#config"; import { PrependTachiUrl } from "#utils/fetch-tachi"; import { log } from "#utils/log"; import { - GetGameGroupConfig, + FormatGame, + type GoalDocument, type integer, - type MONGO_GoalDocument, type WebhookEventGoalAchievedV1, } from "tachi-common"; @@ -32,19 +32,15 @@ export async function HandleGoalAchievedV1( const userDoc = await GetUserInfo(event.userID); const goalDocuments = await Promise.all( - event.goals.map((e) => GetGoalWithID(e.goalID, game, e.playtype)), + event.goals.map((e) => GetGoalWithID(e.goalID, e.game)), ); - const goalMap = new Map(); + const goalMap = new Map(); for (const goalDoc of goalDocuments) { goalMap.set(goalDoc.goalID, goalDoc); } - const gameConfig = GetGameGroupConfig(game); - - const shouldShowPlaytype = gameConfig.playtypes.length !== 1; - const embed = CreateEmbed(userDoc.id) .setTitle( `${userDoc.username} just achieved ${event.goals.length} ${Pluralise( @@ -66,7 +62,7 @@ export async function HandleGoalAchievedV1( : `${e.old.progressHuman}/${e.old.outOfHuman} -> ${e.new.progressHuman}/${e.new.outOfHuman}`; return { - name: `${goal.name}${shouldShowPlaytype ? ` (${e.playtype})` : ""}`, + name: `${goal.name} (${FormatGame(e.game)})`, value, }; }), diff --git a/typescript/bot/src/webhook-handlers/quest-achieved.ts b/typescript/bot/src/webhook-handlers/quest-achieved.ts index 4b2dcfe1b..30ea32b79 100644 --- a/typescript/bot/src/webhook-handlers/quest-achieved.ts +++ b/typescript/bot/src/webhook-handlers/quest-achieved.ts @@ -1,7 +1,7 @@ import { Env } from "#config"; import { PrependTachiUrl } from "#utils/fetch-tachi"; import { log } from "#utils/log"; -import { GetGameGroupConfig, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common"; +import { FormatGame, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common"; import { client } from "../main"; import { GetQuestWithID, GetUserInfo } from "../utils/api-requests"; @@ -11,7 +11,7 @@ import { GetGameChannel } from "../utils/misc"; export async function HandleQuestAchievedV1( event: WebhookEventQuestAchievedV1["content"], ): Promise { - const { game, playtype } = event; + const { game } = event; let channel; @@ -26,16 +26,13 @@ export async function HandleQuestAchievedV1( const userDoc = await GetUserInfo(event.userID); - const quest = await GetQuestWithID(event.questID, game, playtype); - - const gameConfig = GetGameGroupConfig(game); - const shouldShowPlaytype = gameConfig.playtypes.length > 1 ? ` (${playtype})` : ""; + const quest = await GetQuestWithID(event.questID, game); const embed = CreateEmbed(userDoc.id) .setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`)) .setURL(`${Env.TACHI_SERVER_LOCATION}/u/${userDoc.username}`) .setTitle( - `${userDoc.username} just completed the ${quest.name}${shouldShowPlaytype} quest!`, + `${userDoc.username} just completed the ${quest.name} (${FormatGame(game)}) quest!`, ); await channel.send({ embeds: [embed] }); diff --git a/typescript/bot/vitest.config.ts b/typescript/bot/vitest.config.ts index f5246581c..81b0ad26d 100644 --- a/typescript/bot/vitest.config.ts +++ b/typescript/bot/vitest.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ }, test: { + passWithNoTests: true, + // Static env vars. POSTGRES_URL is set dynamically per-worker in vitest.setup.ts // so each worker gets its own isolated database. env: { @@ -38,7 +40,9 @@ export default defineConfig({ pool: "forks", coverage: { + enabled: true, provider: "v8", + reporter: ["text", "html", "clover", "json", "lcov"], include: ["src/actions/**"], }, }, diff --git a/typescript/bot/vitest.setup.ts b/typescript/bot/vitest.setup.ts index 00c3f7e57..5ed7f1142 100644 --- a/typescript/bot/vitest.setup.ts +++ b/typescript/bot/vitest.setup.ts @@ -11,6 +11,7 @@ */ import crypto from "node:crypto"; +import { allImportTypes } from "tachi-common/constants/import-types"; const WORKER_ID = crypto.randomUUID().slice(0, 8); const WORKER_DB_NAME = `tachi_bot_test_${WORKER_ID}`; @@ -35,10 +36,10 @@ globalThis.fetch = (url: string | Request | URL): Promise => { success: true, body: { NAME: "Test Tachi", - GAMES: [], - VERSIONS: {}, - SUPPORTS_OAUTH2: false, - OAUTH_CLIENT_ID: null, + TYPE: "boku", + SIGNUPS_ENABLED: true, + GAME_GROUPS: ["iidx"], + IMPORT_TYPES: [...allImportTypes], }, }), } as unknown as Response); diff --git a/typescript/client/src/app/pages/ClientFileFlowPage.tsx b/typescript/client/src/app/pages/ClientFileFlowPage.tsx index d0108d6d2..d8c629a12 100644 --- a/typescript/client/src/app/pages/ClientFileFlowPage.tsx +++ b/typescript/client/src/app/pages/ClientFileFlowPage.tsx @@ -8,7 +8,7 @@ import { TachiConfig } from "#lib/config"; import { APIFetchV1 } from "#util/api"; import React, { useContext, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { type MONGO_APITokenDocument, type MONGO_TachiAPIClientDocument } from "tachi-common"; +import { type APITokenDocument, type TachiAPIClientDocument } from "tachi-common"; export default function ClientFileFlowPage() { const { clientID } = useParams<{ clientID: string }>(); @@ -17,7 +17,7 @@ export default function ClientFileFlowPage() { const { user } = useContext(UserContext); - const { data, error } = useApiQuery>( + const { data, error } = useApiQuery>( `/clients/${clientID}`, ); @@ -60,7 +60,7 @@ export default function ClientFileFlowPage() { + + + {ioOpen && ( + + +
+
+ + Input + + +
+
+ → +
+
+ + Output + + +
+
+ + )} - - {action.ip ?? "—"} - - - - - - - - )) + + ); + }) )} diff --git a/typescript/client/src/app/pages/admin/AdminDestructivePage.tsx b/typescript/client/src/app/pages/admin/AdminDestructivePage.tsx index a2e4e461f..24903f1da 100644 --- a/typescript/client/src/app/pages/admin/AdminDestructivePage.tsx +++ b/typescript/client/src/app/pages/admin/AdminDestructivePage.tsx @@ -12,14 +12,14 @@ export default function AdminDestructivePage() { const [deleteSessionId, setDeleteSessionId] = useState(""); const [ugptUserId, setUgptUserId] = useState(""); - const [ugptGame, setUgptGame] = useState(TachiConfig.GAMES[0]); + const [ugptGame, setUgptGame] = useState(TachiConfig.GAME_GROUPS[0]); const ugptGameConfig = useMemo(() => GetGameGroupConfig(ugptGame), [ugptGame]); const [ugptPlaytype, setUgptPlaytype] = useState( - () => GetGameGroupConfig(TachiConfig.GAMES[0]).playtypes[0], + () => GetGameGroupConfig(TachiConfig.GAME_GROUPS[0]).playtypes[0], ); const [destroyChartId, setDestroyChartId] = useState(""); - const [destroyChartGame, setDestroyChartGame] = useState(TachiConfig.GAMES[0]); + const [destroyChartGame, setDestroyChartGame] = useState(TachiConfig.GAME_GROUPS[0]); function confirmDelete(message: string): boolean { return window.confirm(message); @@ -138,7 +138,7 @@ export default function AdminDestructivePage() { }} value={ugptGame} > - {TachiConfig.GAMES.map((g) => ( + {TachiConfig.GAME_GROUPS.map((g) => ( @@ -208,7 +208,7 @@ export default function AdminDestructivePage() { onChange={(e) => setDestroyChartGame(e.target.value as GameGroup)} value={destroyChartGame} > - {TachiConfig.GAMES.map((g) => ( + {TachiConfig.GAME_GROUPS.map((g) => ( diff --git a/typescript/client/src/app/pages/admin/AdminOperationsPage.tsx b/typescript/client/src/app/pages/admin/AdminOperationsPage.tsx index beb71585a..42f705d00 100644 --- a/typescript/client/src/app/pages/admin/AdminOperationsPage.tsx +++ b/typescript/client/src/app/pages/admin/AdminOperationsPage.tsx @@ -3,7 +3,12 @@ import { TachiConfig } from "#lib/config"; import { APIFetchV1 } from "#util/api"; import React, { useState } from "react"; import { Button, Card, Col, Form, Row } from "react-bootstrap"; -import { FormatGameGroup, type GameGroup, GetGameGroupConfig } from "tachi-common"; +import { + FormatGame, + type GameGroup, + GetGameGroupConfig, + LEGACY_GameGroupPTToGame, +} from "tachi-common"; export default function AdminOperationsPage() { useSetSubheader(["Admin", "Operations"]); @@ -46,7 +51,7 @@ export default function AdminOperationsPage() { value={announcementGame === "" ? "" : announcementGame} > - {TachiConfig.GAMES.map((g) => ( + {TachiConfig.GAME_GROUPS.map((g) => ( @@ -63,7 +68,12 @@ export default function AdminOperationsPage() { {announcementGameConfig.playtypes.map((pt) => ( ))} diff --git a/typescript/client/src/app/pages/dashboard/DashboardPage.tsx b/typescript/client/src/app/pages/dashboard/DashboardPage.tsx index 68e4cc643..159f55b40 100644 --- a/typescript/client/src/app/pages/dashboard/DashboardPage.tsx +++ b/typescript/client/src/app/pages/dashboard/DashboardPage.tsx @@ -24,7 +24,7 @@ import React, { useContext, useMemo } from "react"; import Alert from "react-bootstrap/Alert"; import Stack from "react-bootstrap/Stack"; import { Link, Route, Switch } from "react-router-dom"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; import SupportBanner from "./misc/SupportBanner"; @@ -42,7 +42,7 @@ export function DashboardPage() { return ; } -function DashboardLoggedIn({ user }: { user: MONGO_UserDocument }) { +function DashboardLoggedIn({ user }: { user: UserDocument }) { const splash = useMemo(() => RFA(heySplashes), []); return ( @@ -78,7 +78,7 @@ function DashboardLoggedIn({ user }: { user: MONGO_UserDocument }) { ); } -function RecentInfo({ user }: { user: MONGO_UserDocument }) { +function RecentInfo({ user }: { user: UserDocument }) { const { data, error } = useApiQuery(`/users/${user.id}/recent-summary`); if (error) { @@ -91,7 +91,7 @@ function RecentInfo({ user }: { user: MONGO_UserDocument }) { const folderInfoMap = new Map(); for (const folderInfo of data.recentFolderStats) { - folderInfoMap.set(folderInfo.folderID, folderInfo); + folderInfoMap.set(folderInfo.slug, folderInfo); } const goalMap = CreateGoalMap(data.recentGoals); diff --git a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx index fdcb36a2e..c13adb59b 100644 --- a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx +++ b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx @@ -35,31 +35,36 @@ import Stack from "react-bootstrap/Stack"; import { useQuery } from "react-query"; import { Link, Route, Switch } from "react-router-dom"; import { + type ChartDocument, FormatDifficulty, + GameToGameGroup, GetGameGroupConfig, type integer, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, + type PBScoreDocument, + type SongDocument, + type UserDocument, } from "tachi-common"; -// Wrapper around the chart leaderboard UI; `chart` comes from the `/charts/:chartID` route. export default function GPTChartPage({ chart, game, song, - playtype, }: { - chart: MONGO_ChartDocument | null; - song: MONGO_SongDocument; + chart: ChartDocument | null; + song: SongDocument; } & GamePT) { const formatSongTitle = `${song.artist} - ${song.title}`; - const formatDiff = chart ? FormatDifficulty(chart, game) : "Loading..."; + const formatDiff = chart ? FormatDifficulty(chart) : "Loading..."; useSetSubheader( - ["Games", GetGameGroupConfig(game).name, playtype, "Songs", formatSongTitle, formatDiff], - [game, playtype, chart], + [ + "Games", + GetGameGroupConfig(GameToGameGroup(game)).name, + "Songs", + formatSongTitle, + formatDiff, + ], + [game, chart], `${formatSongTitle} (${formatDiff})`, ); @@ -67,7 +72,7 @@ export default function GPTChartPage({ return ; } - return ; + return ; } interface ChartPBData { @@ -81,10 +86,9 @@ function InternalGPTChartPage({ chart, game, song, - playtype, }: { - chart: MONGO_ChartDocument; - song: MONGO_SongDocument; + chart: ChartDocument; + song: SongDocument; } & GamePT) { const { user } = useContext(UserContext); @@ -92,7 +96,7 @@ function InternalGPTChartPage({ ["PBInfo", chart.chartID], async () => { const lRes = await APIFetchV1( - `/games/${game}/${playtype}/charts/${chart.chartID}/pbs`, + `/games/${game}/charts/${chart.chartID}/pbs`, ); if (!lRes.success) { @@ -100,7 +104,7 @@ function InternalGPTChartPage({ } const pRes = await APIFetchV1<{ count: integer }>( - `/games/${game}/${playtype}/charts/${chart.chartID}/playcount`, + `/games/${game}/charts/${chart.chartID}/playcount`, ); if (!pRes.success) { @@ -109,11 +113,11 @@ function InternalGPTChartPage({ if (user) { const nRes = await APIFetchV1( - `/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/leaderboard-adjacent`, + `/users/${user.id}/games/${game}/pbs/${chart.chartID}/leaderboard-adjacent`, ); const rRes = await APIFetchV1( - `/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals`, + `/users/${user.id}/games/${game}/pbs/${chart.chartID}/rivals`, ); const returnValue: ChartPBData = { @@ -163,11 +167,10 @@ function InternalGPTChartPage({ } if (user) { - // Add current user, since there's no guarantee they are returned from either API. userMap.set(user.id, user); } - const base = CreateChartLink(chart, game); + const base = CreateChartLink(chart); return ( <> @@ -203,16 +206,15 @@ function InternalGPTChartPage({
- - + + - + - + - + ( - `/users/${user.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${chart.chartID}`, + `/users/${user.id ?? ""}/games/${game}/targets/on-chart/${chart.chartID}`, undefined, - // force a reload of this data when the user adds a new goal [shouldReload.toString()], ); @@ -287,11 +285,9 @@ function ChartTargetInfo({ data, error, game, - playtype, reqUser: user, song, onGoalSet: () => { - // reload local query, then reload global targets. setShouldReload(shouldReload + 1); reloadTargets(); }, @@ -306,24 +302,23 @@ function ChartLeaderboardTable({ userMap, user: _user, game, - playtype, mode, chart, song, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; data: ChartPBData; mode: "adjacent" | "leaderboard" | "rivals"; - song: MONGO_SongDocument; - user: MONGO_UserDocument | null; - userMap: Map; + song: SongDocument; + user: UserDocument | null; + userMap: Map; } & GamePT) { const { settings } = useLUGPTSettings(); const dataset: PBDataset = useMemo(() => { const ds: PBDataset = []; - let pbs: Array = []; + let pbs: Array = []; if (mode === "leaderboard") { pbs = data.leaderboard.pbs; } else if (mode === "adjacent") { @@ -358,7 +353,6 @@ function ChartLeaderboardTable({ defaultRankingViewMode={mode === "rivals" ? "global-no-switch" : "both-if-self"} game={game} key={mode} - playtype={playtype} showChart={false} showUser /> @@ -371,20 +365,14 @@ function TopShowcase({ userMap, chart, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; data: ChartPBData; - user: MONGO_UserDocument | null; - userMap: Map; + user: UserDocument | null; + userMap: Map; }) { - // We have a couple of conditions. - // User is #1: col-12 #1, - // User has played: col-6 col-6, - // User has not played: col-12 #1, - const bestPlay = data.leaderboard.pbs[0]!; const bestUser = userMap.get(bestPlay.userID)!; - // User hasn't played, or isn't logged in or something. if (user?.id === bestPlay.userID) { return ( @@ -428,28 +416,29 @@ function PlayCard({ name, chart, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; name: string; - pb: MONGO_PBScoreDocument; - user: MONGO_UserDocument; + pb: PBScoreDocument; + user: UserDocument; }) { const { breakpoint: { isLg }, } = useContext(WindowContext); + const pbGame = pb.game; return ( - +
{user.username} @@ -465,7 +454,7 @@ function PlayCard({ - +
diff --git a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartsPage.tsx b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartsPage.tsx index 476d6d3df..de9a52b50 100644 --- a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartsPage.tsx @@ -14,18 +14,19 @@ import { NumericSOV, StrSOV } from "#util/sorts"; import React, { useState } from "react"; import { Col, Row } from "react-bootstrap"; import { - FormatGameGroup, + type ChartDocument, + FormatGame, + GameToGameGroup, GetGameGroupConfig, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, + type SongDocument, } from "tachi-common"; -export default function GPTChartsPage({ game, playtype }: GamePT) { +export default function GPTChartsPage({ game }: GamePT) { useSetSubheader( - ["Games", GetGameGroupConfig(game).name, playtype, "Songs"], - [game, playtype], - `${FormatGameGroup(game, playtype)} Songs`, + ["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Songs"], + [game], + `${FormatGame(game)} Songs`, ); const [search, setSearch] = useState(""); @@ -37,19 +38,19 @@ export default function GPTChartsPage({ game, playtype }: GamePT) { - + ); } -function SearchSongsTable({ game, playtype, search }: { search: string } & GamePT) { +function SearchSongsTable({ game, search }: { search: string } & GamePT) { const params = new URLSearchParams({ search }); const { data, error } = useApiQuery<{ - charts: ({ __playcount: integer } & MONGO_ChartDocument)[]; - songs: MONGO_SongDocument[]; - }>(`/games/${game}/${playtype}/charts${search !== "" ? `?${params.toString()}` : ""}`); + charts: ({ __playcount: integer } & ChartDocument)[]; + songs: SongDocument[]; + }>(`/games/${game}/charts${search !== "" ? `?${params.toString()}` : ""}`); if (error) { return ; @@ -67,7 +68,7 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP dataset.push({ ...chart, __related: { - song: songMap.get(chart.songID)!, + song: songMap.get(chart.song.id)!, }, }); } @@ -76,7 +77,7 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP <> {search === "" && (
-

Displaying the most played charts for {FormatGameGroup(game, playtype)}

+

Displaying the most played charts for {FormatGame(game)}

)} diff --git a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTDevInfo.tsx b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTDevInfo.tsx index da6eee0b4..7c552699e 100644 --- a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTDevInfo.tsx +++ b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTDevInfo.tsx @@ -7,33 +7,30 @@ import { type GamePT } from "#types/react"; import React from "react"; import { type Classes, - FormatGameGroup, + FormatGame, + GameToGameGroup, + GetGameConfig, GetGameGroupConfig, - GetGamePTConfig, - type GPTString, + type V3Game, } from "tachi-common"; -export default function GPTDevInfo({ game, playtype }: GamePT) { +export default function GPTDevInfo({ game }: GamePT) { useSetSubheader( - ["Games", GetGameGroupConfig(game).name, playtype, "Dev Info"], - [game, playtype], - `${FormatGameGroup(game, playtype)} Dev Info`, + ["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Dev Info"], + [game], + `${FormatGame(game)} Dev Info`, ); - const gameConfig = GetGameGroupConfig(game); - const gptConfig = GetGamePTConfig(game, playtype); + const gameGroupConfig = GetGameConfig(game); return ( <> - - - - - + +
- {Object.entries(gptConfig.classes).map(([classSet, conf]) => ( + {Object.entries(gameGroupConfig.classes).map(([classSet, conf]) => (
{conf.values.map((e) => ( @@ -41,10 +38,9 @@ export default function GPTDevInfo({ game, playtype }: GamePT) { {e.id} diff --git a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx index a6021da90..5409fe888 100644 --- a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx @@ -22,16 +22,19 @@ import React, { useState } from "react"; import { Col, Form, Row } from "react-bootstrap"; import { type AnyProfileRatingAlg, - FormatGameGroup, + type Classes, + FormatGame, + GameToGameGroup, + GetGameConfig, GetGameGroupConfig, - GetGamePTConfig, + type V3Game, } from "tachi-common"; -export default function GPTLeaderboardsPage({ game, playtype }: GamePT) { +export default function GPTLeaderboardsPage({ game }: GamePT) { useSetSubheader( - ["Games", GetGameGroupConfig(game).name, playtype, "Leaderboards"], - [game, playtype], - `${FormatGameGroup(game, playtype)} Leaderboards`, + ["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Leaderboards"], + [game], + `${FormatGame(game)} Leaderboards`, ); const [mode, setMode] = useState<"profile" | "score">("profile"); @@ -51,39 +54,35 @@ export default function GPTLeaderboardsPage({ game, playtype }: GamePT) { {mode === "profile" ? ( - + ) : ( - + )} ); } -function ProfileLeaderboard({ game, playtype }: GamePT) { - const gptConfig = GetGamePTConfig(game, playtype); +function ProfileLeaderboard({ game }: GamePT) { + const gameConfig = GetGameConfig(game); - const defaultAlg = useProfileRatingAlg(game, playtype); + const defaultAlg = useProfileRatingAlg(game); const [alg, setAlg] = useState(defaultAlg); const SelectComponent = - Object.keys(gptConfig.profileRatingAlgs).length > 1 ? ( + Object.keys(gameConfig.profileRatingAlgs).length > 1 ? ( setAlg(e.target.value as any)} value={alg}> - {Object.keys(gptConfig.profileRatingAlgs).map((e) => ( + {Object.keys(gameConfig.profileRatingAlgs).map((e) => ( ))} ) : null; const { data, error } = useApiQuery( - `/games/${game}/${playtype}/leaderboard?alg=${alg}&limit=500`, + `/games/${game}/leaderboard?alg=${alg}&limit=500`, ); if (error) { @@ -128,11 +127,13 @@ function ProfileLeaderboard({ game, playtype }: GamePT) { headers={[ ["Ranking", "Rank", NumericSOV((x) => x.__related.index)], ["User", "User", StrSOV((x) => x.__related.user.username)], - ...(Object.keys(gptConfig.profileRatingAlgs) as Array).map( + ...( + Object.keys(gameConfig.profileRatingAlgs) as Array + ).map( (e) => [ - FormatGPTProfileRatingName(game, playtype, e), - FormatGPTProfileRatingName(game, playtype, e), + FormatGPTProfileRatingName(game, e), + FormatGPTProfileRatingName(game, e), NumericSOV((x) => x.ratings[e] ?? -Infinity), ] as Header, ), @@ -141,13 +142,13 @@ function ProfileLeaderboard({ game, playtype }: GamePT) { rowFunction={(r) => ( - + {( - Object.keys(gptConfig.profileRatingAlgs) as Array + Object.keys(gameConfig.profileRatingAlgs) as Array ).map((e) => ( {r.ratings[e] - ? FormatGPTProfileRating(game, playtype, e, r.ratings[e]!) + ? FormatGPTProfileRating(game, e, r.ratings[e]!) : "No Data."} ))} @@ -161,11 +162,10 @@ function ProfileLeaderboard({ game, playtype }: GamePT) { ([k, v]) => v && ( ), ) diff --git a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx index 4ffdec3e1..bcae23271 100644 --- a/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx +++ b/typescript/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx @@ -2,14 +2,14 @@ import Activity from "#components/activity/Activity"; import useSetSubheader from "#components/layout/header/useSetSubheader"; import { type GamePT } from "#types/react"; import React from "react"; -import { FormatGameGroup, GetGameGroupConfig } from "tachi-common"; +import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common"; -export default function GPTMainPage({ game, playtype }: GamePT) { +export default function GPTMainPage({ game }: GamePT) { useSetSubheader( - ["Games", GetGameGroupConfig(game).name, playtype], - [game, playtype], - FormatGameGroup(game, playtype), + ["Games", GetGameGroupConfig(GameToGameGroup(game)).name], + [game], + FormatGame(game), ); - return ; + return ; } diff --git a/typescript/client/src/app/pages/dashboard/import/BatchManualPage.tsx b/typescript/client/src/app/pages/dashboard/import/BatchManualPage.tsx index 808b4f1c6..f90033c89 100644 --- a/typescript/client/src/app/pages/dashboard/import/BatchManualPage.tsx +++ b/typescript/client/src/app/pages/dashboard/import/BatchManualPage.tsx @@ -2,13 +2,13 @@ import ImportFileInfo from "#components/imports/ImportFileInfo"; import useSetSubheader from "#components/layout/header/useSetSubheader"; import { TachiConfig } from "#lib/config"; import { p } from "prudence"; -import React, { useState } from "react"; +import React from "react"; import { type BatchManual, - FormatGameGroup, + FormatGame, FormatPrError, GetGameGroupConfig, - GetGamePTConfig, + LEGACY_GameGroupPTToGame, } from "tachi-common"; import { PR_BATCH_MANUAL } from "tachi-common/lib/schemas"; @@ -23,23 +23,23 @@ export default function BatchManualPage() { parseFunction={(text: string) => { const data: BatchManual = JSON.parse(text); - const gameConfig = GetGameGroupConfig(data.meta.game); + const gameGroupConfig = GetGameGroupConfig(data.meta.game); - if (!gameConfig) { + if (!gameGroupConfig) { throw new Error( - `Invalid game ${data.meta.game}. Expected any of ${TachiConfig.GAMES}.`, + `Invalid game ${data.meta.game}. Expected any of ${TachiConfig.GAME_GROUPS}.`, ); } - const gptConfig = GetGamePTConfig(data.meta.game, data.meta.playtype); - - if (!gptConfig) { + if (!gameGroupConfig.playtypes.includes(data.meta.playtype as any)) { throw new Error( - `Invalid Playtype ${data.meta.playtype}. Expected any of ${gameConfig.playtypes}.`, + `Invalid Playtype ${data.meta.playtype}. Expected any of ${gameGroupConfig.playtypes}.`, ); } - const err = p(data, PR_BATCH_MANUAL(data.meta.game, data.meta.playtype)); + const game = LEGACY_GameGroupPTToGame(data.meta.game, data.meta.playtype); + + const err = p(data, PR_BATCH_MANUAL(game)); if (err) { throw new Error(FormatPrError(err, "Invalid BATCH-MANUAL: ")); @@ -48,7 +48,7 @@ export default function BatchManualPage() { return { valid: true, info: { - Game: FormatGameGroup(data.meta.game, data.meta.playtype), + Game: FormatGame(game), Scores: data.scores.length, }, }; diff --git a/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx b/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx index 1ffed8c04..c024848bb 100644 --- a/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx +++ b/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx @@ -23,10 +23,10 @@ import { GetGameGroupConfig, type ImportTypes, type integer, - type MONGO_UserDocument, + type UserDocument, } from "tachi-common"; -export default function ImportPage({ user }: { user: MONGO_UserDocument }) { +export default function ImportPage({ user }: { user: UserDocument }) { useSetSubheader(["Import Scores"]); const [game, setGame] = useState(null); @@ -66,7 +66,7 @@ export default function ImportPage({ user }: { user: MONGO_UserDocument }) { value={game ?? ""} > - {TachiConfig.GAMES.map((e) => ( + {TachiConfig.GAME_GROUPS.map((e) => ( @@ -89,7 +89,7 @@ function ShowRecentImports() { return ; } -function InnerShowRecentImports({ user }: { user: MONGO_UserDocument }) { +function InnerShowRecentImports({ user }: { user: UserDocument }) { const { data, error } = useApiQuery<{ count: integer; importType: ImportTypes }[]>( `/users/${user.id}/recent-imports`, ); diff --git a/typescript/client/src/app/pages/dashboard/misc/SupportBanner.tsx b/typescript/client/src/app/pages/dashboard/misc/SupportBanner.tsx index b4ed6132b..914b634d3 100644 --- a/typescript/client/src/app/pages/dashboard/misc/SupportBanner.tsx +++ b/typescript/client/src/app/pages/dashboard/misc/SupportBanner.tsx @@ -5,9 +5,9 @@ import useApiQuery from "#components/util/query/useApiQuery"; import { TachiConfig } from "#lib/config"; import { ONE_DAY } from "#util/constants/time"; import React, { useEffect, useState } from "react"; -import { type integer, type MONGO_UserDocument } from "tachi-common"; +import { type integer, type UserDocument } from "tachi-common"; -export default function SupportBanner({ user }: { user: MONGO_UserDocument }) { +export default function SupportBanner({ user }: { user: UserDocument }) { const { data, error } = useApiQuery<{ scores: integer; sessions: integer }>( `/users/${user.id}/stats`, ); diff --git a/typescript/client/src/app/pages/dashboard/search/SearchPage.tsx b/typescript/client/src/app/pages/dashboard/search/SearchPage.tsx index a2f2071e0..d95d61701 100644 --- a/typescript/client/src/app/pages/dashboard/search/SearchPage.tsx +++ b/typescript/client/src/app/pages/dashboard/search/SearchPage.tsx @@ -19,13 +19,12 @@ import React, { useContext, useEffect, useLayoutEffect, useState } from "react"; import { Badge, Col, Form, Row } from "react-bootstrap"; import { Link, useHistory, useLocation } from "react-router-dom"; import { - FormatGameGroup, - type GPTString, + type ChartDocument, + FormatGame, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, - SplitGPT, + type SongDocument, + type UserDocument, + type V3Game, } from "tachi-common"; function parseSearchParams(search: string) { @@ -48,48 +47,40 @@ export default function SearchPage() { const [hasPlayedGame, setHasPlayedGame] = useState(initialHasPlayed); useLayoutEffect(() => { - const { q, hasPlayedGame: h } = parseSearchParams(location.search); - setSearch(q); - setHasPlayedGame(h); + const params = parseSearchParams(location.search); + setSearch(params.q); + setHasPlayedGame(params.hasPlayedGame); }, [location.search]); useEffect(() => { - const next = new URLSearchParams(); - if (search) { - next.set("q", search); + const qs = `?q=${encodeURIComponent(search)}&hasPlayedGame=${hasPlayedGame}`; + if (location.search !== qs) { + history.replace(`/search${qs}`); } - if (!hasPlayedGame) { - next.set("hasPlayedGame", "false"); - } - const nextStr = next.toString(); - const current = new URLSearchParams(history.location.search).toString(); - if (nextStr !== current) { - history.replace({ - pathname: location.pathname, - search: nextStr ? `?${nextStr}` : "", - }); - } - }, [search, hasPlayedGame, history, location.pathname]); + }, [search, hasPlayedGame]); return ( - - {user && ( -
- setHasPlayedGame(e.target.checked)} - /> -
- )} - + + + {user && ( +
+ setHasPlayedGame(e.target.checked)} + /> +
+ )} + +
@@ -100,7 +91,7 @@ export default function SearchPage() { function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; search: string }) { const { data, error } = useTachiSearch(search, hasPlayedGame); - const [mode, setMode] = useState<"users" | GPTString | null>(null); + const [mode, setMode] = useState<"users" | V3Game | null>(null); useEffect(() => { if (data) { @@ -109,7 +100,7 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear .filter((k) => k[1].length > 0); if (thingsWithCharts.length > 0) { - setMode(thingsWithCharts[0][0] as GPTString); + setMode(thingsWithCharts[0][0] as V3Game); } else if (data.users.length > 0) { setMode("users"); } else { @@ -161,13 +152,11 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear return null; } - const gpt = g as GPTString; - - const [game, playtype] = SplitGPT(gpt); + const game = g as V3Game; return ( - - {FormatGameGroup(game, playtype)} + + {FormatGame(game)} {charts.length} @@ -198,7 +187,7 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear {mode === "users" ? ( ) : ( - + )} )} @@ -208,17 +197,15 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear function ChartView({ charts, - gpt, + game, }: { charts: Array<{ - chart: MONGO_ChartDocument; + chart: ChartDocument; playcount: integer; - song: MONGO_SongDocument; + song: SongDocument; }>; - gpt: GPTString; + game: V3Game; }) { - const [game, playtype] = SplitGPT(gpt); - return ( }) { +function UsersView({ users }: { users: Array }) { return ( - -
- {users.map((user) => ( - - -
-
- -
-
-
-

- - {user.username} - -

-
-
- {user.status ?? "I haven't set my status."} -
- {Date.now() - user.lastSeen < ONE_MINUTE * 5 && ( -
- ONLINE -
- )} -
-
-
- - ))} -
+ x.username)], + ["Status", "Status", StrSOV((x) => x.status ?? "")], + ["Last Seen", "Last Seen", NumericSOV((x) => x.lastSeen)], + ]} + rowFunction={(u) => ( + + + + + {u.username} + + + + {u.status ?? "No status."} + + + {u.lastSeen === 0 + ? "Never" + : new Date(u.lastSeen).toLocaleDateString("en-GB", { + timeZone: "UTC", + year: "2-digit", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + })} + + + )} + searchFunctions={{ + username: (x) => x.username, + status: (x) => x.status, + lastSeen: (x) => { + if (x.lastSeen === 0) { + return null; + } + return new Date(x.lastSeen).toLocaleString("en-GB", { + timeZone: "UTC", + timeZoneName: "short", + }); + }, + }} + /> ); } + +function LastSeenCell({ time }: { time: number }) { + if (time === 0) { + return Never; + } + + const now = Date.now(); + + if (now - time < ONE_MINUTE) { + return Just Now; + } + + const d = new Date(time); + + return {d.toLocaleDateString("en-GB")}; +} diff --git a/typescript/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx b/typescript/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx index aab5ee256..dd84c080f 100644 --- a/typescript/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx @@ -7,10 +7,10 @@ import { isCardIDValid } from "#util/misc"; import React, { useState } from "react"; import { Button, Col, Form } from "react-bootstrap"; import { Link } from "react-router-dom"; -import { type MONGO_FervidexSettingsDocument, type MONGO_UserDocument } from "tachi-common"; +import { type FervidexSettingsDocument, type UserDocument } from "tachi-common"; -export default function FervidexIntegrationPage({ reqUser }: { reqUser: MONGO_UserDocument }) { - const { data: settings, error } = useApiQuery( +export default function FervidexIntegrationPage({ reqUser }: { reqUser: UserDocument }) { + const { data: settings, error } = useApiQuery( `/users/${reqUser.id}/integrations/fervidex/settings`, ); @@ -48,12 +48,10 @@ function FervidexForm({ reqUser, settings, }: { - reqUser: MONGO_UserDocument; - settings: MONGO_FervidexSettingsDocument | null; + reqUser: UserDocument; + settings: FervidexSettingsDocument | null; }) { - const [formSettings, setFormSettings] = useState< - Omit - >( + const [formSettings, setFormSettings] = useState>( settings ? { forceStaticImport: settings.forceStaticImport, cards: settings.cards } : { diff --git a/typescript/client/src/app/pages/dashboard/users/KsHookSV6CIntegrationPage.tsx b/typescript/client/src/app/pages/dashboard/users/KsHookSV6CIntegrationPage.tsx index e33b906f2..5ab81bdd0 100644 --- a/typescript/client/src/app/pages/dashboard/users/KsHookSV6CIntegrationPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/KsHookSV6CIntegrationPage.tsx @@ -7,13 +7,13 @@ import React, { useState } from "react"; import { Button, Col, Form } from "react-bootstrap"; import { Link } from "react-router-dom"; import { - type MONGO_FervidexSettingsDocument, - type MONGO_KsHookSettingsDocument, - type MONGO_UserDocument, + type FervidexSettingsDocument, + type KsHookSettingsDocument, + type UserDocument, } from "tachi-common"; -export default function KsHookSV6CIntegrationPage({ reqUser }: { reqUser: MONGO_UserDocument }) { - const { data: settings, error } = useApiQuery( +export default function KsHookSV6CIntegrationPage({ reqUser }: { reqUser: UserDocument }) { + const { data: settings, error } = useApiQuery( `/users/${reqUser.id}/integrations/kshook-sv6c/settings`, ); @@ -51,10 +51,10 @@ function KsHookSV6CForm({ reqUser, settings, }: { - reqUser: MONGO_UserDocument; - settings: MONGO_KsHookSettingsDocument | null; + reqUser: UserDocument; + settings: KsHookSettingsDocument | null; }) { - const [formSettings, setFormSettings] = useState>( + const [formSettings, setFormSettings] = useState>( settings ? { forceStaticImport: settings.forceStaticImport } : { forceStaticImport: false }, ); diff --git a/typescript/client/src/app/pages/dashboard/users/NotificationsPage.tsx b/typescript/client/src/app/pages/dashboard/users/NotificationsPage.tsx index b12419548..06143dc10 100644 --- a/typescript/client/src/app/pages/dashboard/users/NotificationsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/NotificationsPage.tsx @@ -8,7 +8,7 @@ import { UserContext } from "#context/UserContext"; import { APIFetchV1 } from "#util/api"; import React, { useContext, useEffect, useMemo } from "react"; import { Button, Col, Row } from "react-bootstrap"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; export default function NotificationsPage() { const { user } = useContext(UserContext); @@ -22,7 +22,7 @@ export default function NotificationsPage() { return ; } -function NotificationsInnerPage({ user }: { user: MONGO_UserDocument }) { +function NotificationsInnerPage({ user }: { user: UserDocument }) { const { notifications, reload } = useContext(NotificationsContext); const unread = useMemo(() => { diff --git a/typescript/client/src/app/pages/dashboard/users/UserGamesPage.tsx b/typescript/client/src/app/pages/dashboard/users/UserGamesPage.tsx index 9133b97cc..ae86f5600 100644 --- a/typescript/client/src/app/pages/dashboard/users/UserGamesPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/UserGamesPage.tsx @@ -1,9 +1,9 @@ import useSetSubheader from "#components/layout/header/useSetSubheader"; import UGPTProfiles from "#components/user/UGPTProfiles"; import React from "react"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; -export default function UserGamesPage({ reqUser }: { reqUser: MONGO_UserDocument }) { +export default function UserGamesPage({ reqUser }: { reqUser: UserDocument }) { useSetSubheader( ["Users", reqUser.username, "Games"], [reqUser], diff --git a/typescript/client/src/app/pages/dashboard/users/UserImportsPage.tsx b/typescript/client/src/app/pages/dashboard/users/UserImportsPage.tsx index a16536947..fd4707c73 100644 --- a/typescript/client/src/app/pages/dashboard/users/UserImportsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/UserImportsPage.tsx @@ -10,13 +10,9 @@ import SelectLinkButton from "#components/util/SelectLinkButton"; import { type FailedImportDataset, type ImportDataset } from "#types/tables"; import React, { useMemo, useState } from "react"; import { Route, Switch } from "react-router-dom"; -import { - type ImportTrackerFailed, - type MONGO_ImportDocument, - type MONGO_UserDocument, -} from "tachi-common"; +import { type ImportDocument, type ImportTrackerFailed, type UserDocument } from "tachi-common"; -export default function UserImportsPage({ reqUser }: { reqUser: MONGO_UserDocument }) { +export default function UserImportsPage({ reqUser }: { reqUser: UserDocument }) { useSetSubheader( ["Users", reqUser.username, "Imports"], [reqUser], @@ -73,9 +69,9 @@ function ViewRecentImports({ reqUser, }: { params: URLSearchParams; - reqUser: MONGO_UserDocument; + reqUser: UserDocument; }) { - const { data, error } = useApiQuery>( + const { data, error } = useApiQuery>( `/users/${reqUser.id}/imports?${params.toString()}`, ); @@ -102,7 +98,7 @@ function ViewRecentFailedImports({ reqUser, }: { params: URLSearchParams; - reqUser: MONGO_UserDocument; + reqUser: UserDocument; }) { const { data, error } = useApiQuery>( `/users/${reqUser.id}/imports/failed?${params.toString()}`, diff --git a/typescript/client/src/app/pages/dashboard/users/UserIntegrationsPage.tsx b/typescript/client/src/app/pages/dashboard/users/UserIntegrationsPage.tsx index c48a78618..3ec936583 100644 --- a/typescript/client/src/app/pages/dashboard/users/UserIntegrationsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/UserIntegrationsPage.tsx @@ -20,18 +20,18 @@ import { Alert, Button, Col, Form, Modal, Row } from "react-bootstrap"; import { Link, Route, Switch } from "react-router-dom"; import { type APIPermissions, + type APITokenDocument, + type CGCardInfo, type integer, - type MONGO_APITokenDocument, - type MONGO_CGCardInfo, - type MONGO_MytCardInfo, - type MONGO_TachiAPIClientDocument, - type MONGO_UserDocument, + type MytCardInfo, + type TachiAPIClientDocument, + type UserDocument, } from "tachi-common"; import FervidexIntegrationPage from "./FervidexIntegrationPage"; import KsHookSV6CIntegrationPage from "./KsHookSV6CIntegrationPage"; -export default function UserIntegrationsPage({ reqUser }: { reqUser: MONGO_UserDocument }) { +export default function UserIntegrationsPage({ reqUser }: { reqUser: UserDocument }) { useSetSubheader( ["Users", reqUser.username, "Integrations"], [reqUser], @@ -102,9 +102,9 @@ function OAuthClientPage() { } function OAuthClientInfo() { - const { data, error } = useApiQuery("/clients"); + const { data, error } = useApiQuery("/clients"); - const [clients, setClients] = useState([]); + const [clients, setClients] = useState([]); useEffect(() => { setClients(data ?? []); @@ -144,11 +144,7 @@ function OAuthClientInfo() { ); } -function CreateNewOAuthClient({ - setClients, -}: { - setClients: SetState; -}) { +function CreateNewOAuthClient({ setClients }: { setClients: SetState }) { const [show, setShow] = useState(false); const [name, setName] = useState(""); const [redirectUri, setRedirectUri] = useState(""); @@ -310,9 +306,9 @@ function CreateNewOAuthClient({ } interface OAuthClientProps { - client: MONGO_TachiAPIClientDocument; - clients: MONGO_TachiAPIClientDocument[]; - setClients: SetState; + client: TachiAPIClientDocument; + clients: TachiAPIClientDocument[]; + setClients: SetState; } function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) { @@ -392,7 +388,7 @@ function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) {
@@ -170,19 +158,17 @@ function UserHistory({ {mode === "ranking" ? ( <> - {Object.keys(gptConfig.profileRatingAlgs).length > 1 && ( + {Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
- setRating( - e.target.value as keyof MONGO_UserGameStats["ratings"], - ) + setRating(e.target.value as keyof UserGameStats["ratings"]) } value={rating} > - {Object.keys(gptConfig.profileRatingAlgs).map((e) => ( + {Object.keys(gameConfig.profileRatingAlgs).map((e) => ( ))} @@ -225,26 +211,24 @@ function UserHistory({ /> ) : ( <> - {Object.keys(gptConfig.profileRatingAlgs).length > 1 && ( + {Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
- setRating( - e.target.value as keyof MONGO_UserGameStats["ratings"], - ) + setRating(e.target.value as keyof UserGameStats["ratings"]) } value={rating} > - {Object.keys(gptConfig.profileRatingAlgs).map((e) => ( + {Object.keys(gameConfig.profileRatingAlgs).map((e) => ( ))}
)} - + )} @@ -253,14 +237,12 @@ function UserHistory({ function RatingTimeline({ game, - playtype, data, rating, }: { data: UGPTHistory; - game: GameGroup; - playtype: Playtype; - rating: keyof MONGO_UserGameStats["ratings"]; + game: V3Game; + rating: keyof UserGameStats["ratings"]; }) { const ratingDataset = [ { id: rating, data: data.map((e) => ({ x: e.timestamp, y: e.ratings[rating] })) }, @@ -270,13 +252,13 @@ function RatingTimeline({ DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL), - tickValues: 3, // temp + tickValues: 3, }} axisLeft={{ tickSize: 5, tickPadding: 5, tickRotation: 0, - format: (y) => (y ? FormatGPTProfileRating(game, playtype, rating, y) : "N/A"), + format: (y) => (y ? FormatGPTProfileRating(game, rating, y) : "N/A"), }} data={ratingDataset} height="30rem" @@ -285,14 +267,9 @@ function RatingTimeline({
{p.point.data.y - ? FormatGPTProfileRating( - game, - playtype, - rating, - p.point.data.y as number, - ) + ? FormatGPTProfileRating(game, rating, p.point.data.y as number) : "N/A"}{" "} - {FormatGPTProfileRatingName(game, playtype, rating)} + {FormatGPTProfileRatingName(game, rating)}
{MillisToSince(+p.point.data.xFormatted)} @@ -308,13 +285,13 @@ function RankingTimeline({ rating, }: { data: UGPTHistory; - rating: keyof MONGO_UserGameStats["ratings"]; + rating: keyof UserGameStats["ratings"]; }) { return ( DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL), - tickValues: 3, // temp + tickValues: 3, }} axisLeft={{ tickSize: 5, diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx index d4f969efd..ba5da2e52 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx @@ -15,41 +15,45 @@ import React, { useState } from "react"; import { Col, Form, Row } from "react-bootstrap"; import { Route, Switch } from "react-router-dom"; import { - FormatGameGroup, - type GameGroup, + type ChartDocument, + FormatGame, + GameToGameGroup, + GetGameConfig, GetGameGroupConfig, - GetGamePTConfig, - type GPTString, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, + type PBScoreDocument, + type ScoreDocument, type ScoreRatingAlgorithms, + type SongDocument, type UnsuccessfulAPIResponse, + type UserDocument, + type V3Game, } from "tachi-common"; export default function ScoresPage({ reqUser, game, - playtype, }: { - reqUser: MONGO_UserDocument; + reqUser: UserDocument; } & GamePT) { - const gameConfig = GetGameGroupConfig(game); - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - const defaultRating = useScoreRatingAlg(game, playtype); + const defaultRating = useScoreRatingAlg(game); const [alg, setAlg] = useState(defaultRating); useSetSubheader( - ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Scores"], + [ + "Users", + reqUser.username, + "Games", + GetGameGroupConfig(GameToGameGroup(game)).name, + "Scores", + ], [reqUser], - `${reqUser.username}'s ${FormatGameGroup(game, playtype)} Scores`, + `${reqUser.username}'s ${FormatGame(game)} Scores`, ); - const base = useUGPTBase({ reqUser, game, playtype }); + const base = useUGPTBase({ reqUser, game }); return ( @@ -71,39 +75,37 @@ export default function ScoresPage({ - + <> - {Object.keys(gptConfig.scoreRatingAlgs).length > 1 && ( - + {Object.keys(gameConfig.scoreRatingAlgs).length > 1 && ( + )} - - + + - + - + @@ -114,21 +116,20 @@ export default function ScoresPage({ function AlgSelector({ game, - playtype, alg, setAlg, }: { - alg: ScoreRatingAlgorithms[GPTString]; - setAlg: SetState; + alg: ScoreRatingAlgorithms[V3Game]; + setAlg: SetState; } & GamePT) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); return (
Best 100 PBs according to
setAlg(e.target.value as any)} value={alg}> - {Object.keys(gptConfig.scoreRatingAlgs).map((e) => ( + {Object.keys(gameConfig.scoreRatingAlgs).map((e) => ( ))} @@ -136,11 +137,11 @@ function AlgSelector({ ); } -function useFetchPBs(url: string, reqUser: MONGO_UserDocument) { +function useFetchPBs(url: string, reqUser: UserDocument) { const { data, error } = useApiQuery<{ - charts: MONGO_ChartDocument[]; - pbs: MONGO_PBScoreDocument[]; - songs: MONGO_SongDocument[]; + charts: ChartDocument[]; + pbs: PBScoreDocument[]; + songs: SongDocument[]; }>(url); return { @@ -152,15 +153,14 @@ function useFetchPBs(url: string, reqUser: MONGO_UserDocument) { function PBsOverview({ reqUser, game, - playtype, indexCol = true, showPlaycount = false, url, alg, }: { - alg?: ScoreRatingAlgorithms[GPTString]; + alg?: ScoreRatingAlgorithms[V3Game]; indexCol?: boolean; - reqUser: MONGO_UserDocument; + reqUser: UserDocument; showPlaycount?: boolean; url: string; } & GamePT) { @@ -184,27 +184,22 @@ function PBsOverview({ defaultRankingViewMode={preferredRanking} game={game} indexCol={indexCol} - playtype={playtype} showPlaycount={showPlaycount} /> ) : ( - + )}
); } -function FormatData< - D extends MONGO_PBScoreDocument | MONGO_ScoreDocument, - GPT extends GPTString = GPTString, - G extends GameGroup = GameGroup, ->( +function FormatData( d: D[], - songs: MONGO_SongDocument[], - charts: MONGO_ChartDocument[], - reqUser: MONGO_UserDocument, + songs: SongDocument[], + charts: ChartDocument[], + reqUser: UserDocument, ) { const songMap = new Map(); const chartMap = new Map(); @@ -230,11 +225,11 @@ function FormatData< return data; } -function useFetchScores(url: string, reqUser: MONGO_UserDocument) { +function useFetchScores(url: string, reqUser: UserDocument) { const { data, error } = useApiQuery<{ - charts: MONGO_ChartDocument[]; - scores: MONGO_ScoreDocument[]; - songs: MONGO_SongDocument[]; + charts: ChartDocument[]; + scores: ScoreDocument[]; + songs: SongDocument[]; }>(url); return { @@ -246,37 +241,30 @@ function useFetchScores(url: string, reqUser: MONGO_UserDocument) { function PBsSearch({ reqUser, game, - playtype, search, alg, }: { - alg?: ScoreRatingAlgorithms[GPTString]; - reqUser: MONGO_UserDocument; + alg?: ScoreRatingAlgorithms[V3Game]; + reqUser: UserDocument; search: string; } & GamePT) { const { data, error } = useFetchPBs( - `/users/${reqUser.id}/games/${game}/${playtype}/pbs?search=${search}`, + `/users/${reqUser.id}/games/${game}/pbs?search=${search}`, reqUser, ); return ( - + ); } -function ScoresOverview({ reqUser, game, playtype }: UGPT) { +function ScoresOverview({ reqUser, game }: UGPT) { const [search, setSearch] = useState(""); const { data, error } = useFetchScores( - `/users/${reqUser.id}/games/${game}/${playtype}/scores/recent`, + `/users/${reqUser.id}/games/${game}/scores/recent`, reqUser, ); @@ -292,10 +280,10 @@ function ScoresOverview({ reqUser, game, playtype }: UGPT) {
{search === "" ? ( - + ) : ( - + )}
@@ -305,17 +293,16 @@ function ScoresOverview({ reqUser, game, playtype }: UGPT) { function ScoresSearch({ reqUser, game, - playtype, search, -}: { reqUser: MONGO_UserDocument; search: string } & GamePT) { +}: { reqUser: UserDocument; search: string } & GamePT) { const { data, error } = useFetchScores( - `/users/${reqUser.id}/games/${game}/${playtype}/scores?search=${search}`, + `/users/${reqUser.id}/games/${game}/scores?search=${search}`, reqUser, ); return ( - + ); } diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/SessionsPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/SessionsPage.tsx index 3d06076ca..c1818b3a4 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/SessionsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/SessionsPage.tsx @@ -15,35 +15,40 @@ import { NumericSOV } from "#util/sorts"; import React, { useState } from "react"; import { useQuery } from "react-query"; import { - FormatGameGroup, + FormatGame, + GameToGameGroup, GetGameGroupConfig, - type MONGO_SessionDocument, - type MONGO_UserDocument, + type SessionDocument, type SessionScoreInfo, type UnsuccessfulAPIResponse, + type UserDocument, } from "tachi-common"; -export default function SessionsPage({ reqUser, game, playtype }: UGPT) { +export default function SessionsPage({ reqUser, game }: UGPT) { const [sessionSet, setSessionSet] = useState<"best" | "highlighted" | "recent">("best"); const [search, setSearch] = useState(""); - const gameConfig = GetGameGroupConfig(game); - useSetSubheader( - ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Sessions"], + [ + "Users", + reqUser.username, + "Games", + GetGameGroupConfig(GameToGameGroup(game)).name, + "Sessions", + ], [reqUser], - `${reqUser.username}'s ${FormatGameGroup(game, playtype)} Sessions`, + `${reqUser.username}'s ${FormatGame(game)} Sessions`, ); - const baseUrl = `/users/${reqUser.id}/games/${game}/${playtype}/sessions`; + const baseUrl = `/users/${reqUser.id}/games/${game}/sessions`; - const rating = useSessionRatingAlg(game, playtype); + const rating = useSessionRatingAlg(game); const { data, error } = useQuery( `${baseUrl}/${sessionSet}`, async () => { const res = await APIFetchV1< - ({ __scoreInfo: Array } & MONGO_SessionDocument)[] + ({ __scoreInfo: Array } & SessionDocument)[] >(`${baseUrl}/${sessionSet}`); if (!res.success) { @@ -70,7 +75,7 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
@@ -113,12 +118,11 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) { dataset={data!} game={game} indexCol={sessionSet === "best"} - playtype={playtype} reqUser={reqUser} /> ) : ( - + )}
@@ -128,14 +132,13 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) { function SearchSessionsTable({ search, game, - playtype, reqUser, baseUrl, -}: { baseUrl: string; reqUser: MONGO_UserDocument; search: string } & GamePT) { +}: { baseUrl: string; reqUser: UserDocument; search: string } & GamePT) { const { data, error } = useQuery( `${baseUrl}?search=${search}`, async () => { - const res = await APIFetchV1(`${baseUrl}?search=${search}`); + const res = await APIFetchV1(`${baseUrl}?search=${search}`); if (!res.success) { throw res; @@ -152,12 +155,7 @@ function SearchSessionsTable({ return ( - + ); } diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage.tsx index 62d2be8f5..db767b51c 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage.tsx @@ -17,9 +17,9 @@ import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "#util/data"; import React, { useContext, useMemo, useState } from "react"; import { Badge, Button, Col, Row } from "react-bootstrap"; import { Redirect, useParams } from "react-router-dom"; -import { GetGameGroupConfig } from "tachi-common"; +import { GameToGameGroup, GetGameGroupConfig } from "tachi-common"; -export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) { +export default function SpecificSessionPage({ reqUser, game }: UGPT) { const { sessionID } = useParams<{ sessionID: string }>(); const { data, error } = useApiQuery(`/sessions/${sessionID}`); @@ -32,22 +32,20 @@ export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) { return ; } - if ( - data.user.id !== reqUser.id || - game !== data.session.game || - playtype !== data.session.playtype - ) { + const sessionV3Game = data.session.game; + + if (data.user.id !== reqUser.id || game !== sessionV3Game) { return ( ); } - return ; + return ; } -function SessionPage({ data, game, playtype }: { data: SessionReturns } & UGPT) { +function SessionPage({ data, game }: { data: SessionReturns } & UGPT) { const { settings } = useContext(UserSettingsContext); const [sessionData, setSessionData] = useState(data); @@ -60,12 +58,11 @@ function SessionPage({ data, game, playtype }: { data: SessionReturns } & UGPT) "Users", user.username, "Games", - GetGameGroupConfig(game).name, - playtype, + GetGameGroupConfig(GameToGameGroup(game)).name, "Sessions", session.name, ], - [session.name, game, playtype, user], + [session.name, game, user], `${user.username}: ${session.name}`, ); diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx index 0e859e99c..2c20af9cc 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx @@ -30,31 +30,38 @@ import { Alert, Button, Col, Form, Row } from "react-bootstrap"; import { Link } from "react-router-dom"; import { BMS_TABLES, - FormatGameGroup, + FormatGame, + GameToGameGroup, + GetGameConfig, GetGameGroupConfig, - GetGamePTConfig, GetScoreMetrics, - type MONGO_TableDocument, - type MONGO_UGPTSettingsDocument, - type MONGO_UserDocument, type ShowcaseStatDetails, + type TableDocument, + type UGPTSettingsDocument, + type UserDocument, } from "tachi-common"; -export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) { +export default function UGPTSettingsPage({ reqUser, game }: UGPT) { const query = useQueryString(); const [page, setPage] = useState<"manage" | "preferences" | "showcase">( query.get("showcase") ? "showcase" : "preferences", ); - const gameConfig = GetGameGroupConfig(game); + const gameConfig = GetGameConfig(game); useSetSubheader( - ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Settings"], + [ + "Users", + reqUser.username, + "Games", + GetGameGroupConfig(GameToGameGroup(game)).name, + "Settings", + ], [reqUser], - `${reqUser.username}'s ${FormatGameGroup(game, playtype)} Settings`, + `${reqUser.username}'s ${FormatGame(game)} Settings`, ); - const UGPT = { reqUser, game, playtype }; + const UGPT = { reqUser, game }; const { loggedInData } = useContext(UGPTContext); if (!loggedInData) { @@ -139,33 +146,32 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) { function PreferencesForm({ reqUser, game, - playtype, loggedInData, -}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) { +}: { loggedInData: { settings: UGPTSettingsDocument } & UGPTData } & UGPT) { const { setLoggedInData } = useContext(UGPTContext); const settings = loggedInData.settings; - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); const formik = useFormik({ initialValues: { preferredScoreAlg: - settings.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg, + settings.preferences.preferredScoreAlg || gameConfig.defaultScoreRatingAlg, preferredProfileAlg: - settings.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg, + settings.preferences.preferredProfileAlg || gameConfig.defaultProfileRatingAlg, preferredSessionAlg: - settings.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg, + settings.preferences.preferredSessionAlg || gameConfig.defaultSessionRatingAlg, // eslint-disable-next-line @typescript-eslint/no-explicit-any gameSpecific: settings.preferences.gameSpecific as any, defaultTable: settings.preferences.defaultTable, preferredDefaultEnum: - settings.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum, + settings.preferences.preferredDefaultEnum ?? gameConfig.preferredDefaultEnum, preferredRanking: settings.preferences.preferredRanking ?? "global", }, onSubmit: async (values) => { - const rj = await APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}/settings`, + const rj = await APIFetchV1( + `/users/${reqUser.id}/games/${game}/settings`, { method: "PATCH", body: JSON.stringify(values), @@ -180,7 +186,7 @@ function PreferencesForm({ if (rj.success) { setLoggedInData({ ...loggedInData, - settings: deepmerge(settings as MONGO_UGPTSettingsDocument, { + settings: deepmerge(settings as UGPTSettingsDocument, { preferences: values, }), }); @@ -188,8 +194,8 @@ function PreferencesForm({ }, }); - const { data: tables, error } = useApiQuery( - `/games/${game}/${playtype}/tables?showInactive=true`, + const { data: tables, error } = useApiQuery( + `/games/${game}/tables?showInactive=true`, ); if (error) { @@ -208,7 +214,7 @@ function PreferencesForm({ return ( - {Object.keys(gptConfig.scoreRatingAlgs).length > 1 && ( + {Object.keys(gameConfig.scoreRatingAlgs).length > 1 && ( Preferred Score Algorithm - {Object.keys(gptConfig.scoreRatingAlgs).map((e) => ( + {Object.keys(gameConfig.scoreRatingAlgs).map((e) => ( ))} @@ -228,7 +234,7 @@ function PreferencesForm({ )} - {Object.keys(gptConfig.sessionRatingAlgs).length > 1 && ( + {Object.keys(gameConfig.sessionRatingAlgs).length > 1 && ( Preferred Session Algorithm - {Object.keys(gptConfig.sessionRatingAlgs).map((e) => ( + {Object.keys(gameConfig.sessionRatingAlgs).map((e) => ( ))} @@ -248,7 +254,7 @@ function PreferencesForm({ )} - {Object.keys(gptConfig.profileRatingAlgs).length > 1 && ( + {Object.keys(gameConfig.profileRatingAlgs).length > 1 && ( Preferred Profile Algorithm - {Object.keys(gptConfig.profileRatingAlgs).map((e) => ( + {Object.keys(gameConfig.profileRatingAlgs).map((e) => ( ))} @@ -275,7 +281,7 @@ function PreferencesForm({ onChange={formik.handleChange} value={formik.values.preferredDefaultEnum} > - {GetScoreMetrics(gptConfig, "ENUM").map((e) => ( + {GetScoreMetrics(gameConfig, "ENUM").map((e) => ( @@ -322,7 +328,7 @@ function PreferencesForm({ What folders would you like to see by default? - {game === "iidx" && ( + {(game === "iidx-sp" || game === "iidx-dp") && ( <> )} - {(game === "sdvx" || game === "usc") && ( + {(game === "sdvx" || game === "usc-controller" || game === "usc-keyboard") && ( VF6 Target )} - {game === "bms" && ( + {(game === "bms-7k" || game === "bms-14k") && ( Preferred Tables - {BMS_TABLES.filter((e) => e.playtype === playtype).map((e) => ( + {BMS_TABLES.filter((e) => e.game === game).map((e) => ( { const base: Array = formik.values.gameSpecific.displayTables ?? - BMS_TABLES.filter( - (e) => e.playtype === playtype && !e.notDefault, - ).map((e) => e.prefix); + BMS_TABLES.filter((e) => e.game === game && !e.notDefault).map( + (e) => e.prefix, + ); if (event.target.checked) { formik.setFieldValue("gameSpecific.displayTables", [ @@ -429,9 +435,8 @@ function PreferencesForm({ function ShowcaseForm({ reqUser, game, - playtype, loggedInData, -}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) { +}: { loggedInData: { settings: UGPTSettingsDocument } & UGPTData } & UGPT) { const { setLoggedInData } = useContext(UGPTContext); const settings = loggedInData.settings; @@ -440,11 +445,11 @@ function ShowcaseForm({ const [show, setShow] = useState(false); const SaveChanges = async () => { - const r = await APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}/showcase`, + const r = await APIFetchV1( + `/users/${reqUser.id}/games/${game}/showcase`, { method: "PUT", - body: JSON.stringify(stats), + body: JSON.stringify({ showcase: stats }), headers: { "Content-Type": "application/json" }, }, true, @@ -478,13 +483,12 @@ function ShowcaseForm({
)} - + { setStats([...stats, stat]); }} - playtype={playtype} reqUser={reqUser} setShow={setShow} show={show} @@ -498,7 +502,6 @@ function RenderCurrentStats({ setStats, reqUser, game, - playtype, }: { setStats: SetState; stats: ShowcaseStatDetails[]; @@ -519,7 +522,7 @@ function RenderCurrentStats({ {stats.map((e, i) => ( - + @@ -529,7 +532,7 @@ function RenderCurrentStats({ ); } -function ManageAccount({ reqUser, game, playtype }: UGPT) { +function ManageAccount({ reqUser, game }: UGPT) { const [password, setPassword] = useState(""); const [deleting, setDeleting] = useState(false); @@ -549,13 +552,12 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {

Completely Wipe Profile

If you've really messed up, you can wipe your entire profile for{" "} - {FormatGameGroup(game, playtype)}. + {FormatGame(game)}.
It is very important to know that this is NOT REVERSIBLE. Wiping your - profile will COMPLETELY DELETE all of your{" "} - {FormatGameGroup(game, playtype)} scores from our server. We will not be able to - retrieve them. + profile will COMPLETELY DELETE all of your {FormatGame(game)} scores from + our server. We will not be able to retrieve them.
@@ -573,16 +575,13 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) { onClick={async () => { if ( confirm( - `You are really about to delete all of your ${FormatGameGroup( - game, - playtype, - )} scores. This is your last chance to turn back.`, + `You are really about to delete all of your ${FormatGame(game)} scores. This is your last chance to turn back.`, ) ) { setDeleting(true); const res = await APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}`, + `/users/${reqUser.id}/games/${game}`, { method: "DELETE", body: JSON.stringify({ "!password": password }), @@ -597,14 +596,13 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) { setDeleting(false); if (res.success) { - // bye! window.location.href = "/"; } } }} variant="outline-danger" > - Yes, I want to delete my {FormatGameGroup(game, playtype)} account. + Yes, I want to delete my {FormatGame(game)} account. {deleting && ( <> diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderComparePage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderComparePage.tsx index d310bc836..44ba7b0b8 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderComparePage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderComparePage.tsx @@ -21,28 +21,24 @@ import Form from "react-bootstrap/Form"; import Row from "react-bootstrap/Row"; import Stack from "react-bootstrap/Stack"; import { Link } from "react-router-dom"; -import { type MONGO_FolderDocument, type MONGO_UserDocument } from "tachi-common"; +import { type FolderDocument, type UserDocument } from "tachi-common"; export default function RivalCompareFolderPage({ reqUser, game, - playtype, folder, }: { - folder: MONGO_FolderDocument; - reqUser: MONGO_UserDocument; + folder: FolderDocument; + reqUser: UserDocument; } & GamePT) { const { settings } = useLUGPTSettings(); const { user } = useContext(UserContext); - const [selectedUser, setSelectedUser] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); const [show, setShow] = useState(false); - // honestly don't care if this errors or not - const { data, error } = useApiQuery>( - `/users/${settings?.userID}/games/${game}/${playtype}/rivals`, - undefined, - // [settings?.rivals.join(",")] + const { data, error } = useApiQuery>( + `/users/${settings?.userID}/games/${game}/rivals`, ); if (!data && !error) { @@ -51,9 +47,6 @@ export default function RivalCompareFolderPage({ let suggestUsers = data ?? []; - // if the user viewing this page is logged in and *is not* - // the person this page is for - // show them in the suggestions. if (user && user.id !== reqUser.id && data) { suggestUsers = [...suggestUsers, user]; } @@ -65,7 +58,7 @@ export default function RivalCompareFolderPage({ <> {suggestUsers.map((e) => ( - +
Looking for goal recommendations?{" "} - - Check out {FormatGameGroup(game, playtype)}'s Quests - - . + Check out {FormatGame(game)}'s Quests.
- + {show && ( { await APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/add-goal`, + `/users/${reqUser.id}/games/${game}/targets/goals/add-goal`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -91,7 +88,6 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) { refetchGoals(); reloadTargets(); }} - playtype={playtype} setShow={setShow} show={show} /> @@ -101,7 +97,7 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) { dataset={dataset} onDelete={async (goalID) => { await APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/${goalID}`, + `/users/${reqUser.id}/games/${game}/targets/goals/${goalID}`, { method: "DELETE", }, diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/UGPTQuestsPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/UGPTQuestsPage.tsx index 68fb2db26..a6339cb87 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/UGPTQuestsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/UGPTQuestsPage.tsx @@ -12,19 +12,19 @@ import React, { useMemo, useState } from "react"; import { Col, Row } from "react-bootstrap"; import { Link } from "react-router-dom"; import { - type MONGO_GoalDocument, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, + type GoalDocument, + type QuestDocument, + type QuestSubscriptionDocument, } from "tachi-common"; -export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) { +export default function UGPTQuestsPage({ reqUser, game }: UGPT) { const [show, setShow] = useState<"achieved" | "all" | "unachieved">("all"); const { data, error } = useApiQuery<{ - goals: Array; - quests: Array; - questSubs: Array; - }>(`/users/${reqUser.id}/games/${game}/${playtype}/targets/quests`); + goals: Array; + quests: Array; + questSubs: Array; + }>(`/users/${reqUser.id}/games/${game}/targets/quests`); const questsToShow = useMemo(() => { if (!data || error) { @@ -33,27 +33,22 @@ export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) { const questSubMap = CreateQuestSubMap(data.questSubs); - // slice and sort based on progress let base = data.quests.slice(0).sort( NumericSOV((quest) => { const sub = questSubMap.get(quest.questID); if (!sub) { - return -Infinity; // shouldn't happen + return -Infinity; } - // sink achieved things below goals in progress if (sub.achieved) { return -100; } - // since this is always ostensibly positive, the magic numbers -99 and - // -100 should be fine. return sub.progress / GetGoalIDsFromQuest(quest).length; }, true), ); - // filter on "show" setting switch (show) { case "all": break; @@ -98,7 +93,7 @@ export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
Looks like you have no quests set.
- Go set some! + Go set some!
)} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/utils/UGPTUtilsPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/utils/UGPTUtilsPage.tsx index 04d803a3f..a9d9b63b0 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/utils/UGPTUtilsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/utils/UGPTUtilsPage.tsx @@ -8,34 +8,39 @@ import { type UGPT } from "#types/react"; import React, { useContext } from "react"; import { Col, Row } from "react-bootstrap"; import { Link, Route, Switch } from "react-router-dom"; -import { FormatGameGroup, GetGameGroupConfig } from "tachi-common"; +import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common"; -export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) { - const gameConfig = GetGameGroupConfig(game); +export default function UGPTUtilsPage({ reqUser, game }: UGPT) { const { user } = useContext(UserContext); const isViewingOwnProfile = user?.id === reqUser.id; - const utils = GetGPTUtils(game, playtype); - const pageName = GetGPTUtilsName(game, playtype, isViewingOwnProfile); + const utils = GetGPTUtils(game); + const pageName = GetGPTUtilsName(game, isViewingOwnProfile); useSetSubheader( - ["Users", reqUser.username, "Games", gameConfig.name, playtype, pageName ?? "Utils"], - [reqUser, game, playtype], - `${reqUser.username}'s ${FormatGameGroup(game, playtype)} ${pageName ?? "Utils"}`, + [ + "Users", + reqUser.username, + "Games", + GetGameGroupConfig(GameToGameGroup(game)).name, + pageName ?? "Utils", + ], + [reqUser, game], + `${reqUser.username}'s ${FormatGame(game)} ${pageName ?? "Utils"}`, ); return ( - + {utils.map((util) => ( View @@ -53,14 +58,14 @@ export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) { < Back to all tools... @@ -71,7 +76,7 @@ export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) { - {tool.component({ reqUser, game, playtype })} + {tool.component({ reqUser, game })} ))} diff --git a/typescript/client/src/app/pages/dashboard/utils/QuestEditor.tsx b/typescript/client/src/app/pages/dashboard/utils/QuestEditor.tsx index 6807bde36..5fcc3d00c 100644 --- a/typescript/client/src/app/pages/dashboard/utils/QuestEditor.tsx +++ b/typescript/client/src/app/pages/dashboard/utils/QuestEditor.tsx @@ -17,7 +17,7 @@ const LOCAL_QUEST_KEY = "LOCAL_QUESTS"; const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = { json: [ { - game: p.isIn(TachiConfig.GAMES), + game: p.isIn(TachiConfig.GAME_GROUPS), playtype: (self, parent) => { const gameConfig = GetGameGroupConfig(parent.game as any); diff --git a/typescript/client/src/app/routes/GameRoutes.tsx b/typescript/client/src/app/routes/GameRoutes.tsx index 7a4a5324e..6c039ac2e 100644 --- a/typescript/client/src/app/routes/GameRoutes.tsx +++ b/typescript/client/src/app/routes/GameRoutes.tsx @@ -3,7 +3,6 @@ import GPTChartsPage from "#app/pages/dashboard/games/_game/_playtype/GPTChartsP import GPTDevInfo from "#app/pages/dashboard/games/_game/_playtype/GPTDevInfo"; import GPTLeaderboardsPage from "#app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage"; import GPTMainPage from "#app/pages/dashboard/games/_game/_playtype/GPTMainPage"; -import PlaytypeSelect from "#app/pages/dashboard/games/_game/PlaytypeSelect"; import { ErrorPage } from "#app/pages/ErrorPage"; import ChartInfoFormat from "#components/game/charts/ChartInfoFormat"; import { GPTBottomNav } from "#components/game/GPTHeader"; @@ -28,7 +27,7 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; import { type SongsReturn } from "#types/api-returns"; import { type GamePT, type SetState } from "#types/react"; import { ToCDNURL } from "#util/api"; -import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts"; +import { IsSupportedGame } from "#util/asserts"; import { ChangeOpacity } from "#util/color-opacity"; import { CreateChartLink } from "#util/data"; import { SelectRightChart } from "#util/misc"; @@ -37,109 +36,97 @@ import React, { useContext, useEffect, useState } from "react"; import { Col, Row } from "react-bootstrap"; import { Redirect, Route, Switch, useParams } from "react-router-dom"; import { + type ChartDocument, COLOUR_SET, FormatDifficulty, - type GameGroup, - GetGameGroupConfig, - GetGamePTConfig, - GetGPTString, - type MONGO_ChartDocument, - type MONGO_SongDocument, + type GamesForGroup, + GameToGameGroup, + GetGameConfig, + type SongDocument, + type V3Game, } from "tachi-common"; import { type FixedDifficulties } from "tachi-common/types/game-config-utils"; export default function GameRoutes() { - const { game } = useParams<{ game: string }>(); + const { game: gameParam } = useParams<{ game: string }>(); const { setBackground } = useContext(BackgroundContext); useEffect(() => { - setBackground(ToCDNURL(`/game-banners/${game}`)); - }, [game, setBackground]); - - if (!IsSupportedGame(game)) { - return ; - } - - const gameConfig = GetGameGroupConfig(game); + setBackground(ToCDNURL(`/game-banners/${gameParam}`)); + }, [gameParam, setBackground]); + // Support legacy URLs like /games/iidx/SP → redirect to /games/iidx-sp return ( - - {gameConfig.playtypes.length === 1 ? ( - - ) : ( - - )} - - - - - - - - - - - - + + ); } -function GamePlaytypeRoutes({ game }: { game: GameGroup }) { - const { playtype } = useParams<{ playtype: string }>(); +function V3GameRoutes() { + const { game: gameParam } = useParams<{ game: string }>(); + const { setBackground } = useContext(BackgroundContext); - if (!IsSupportedPlaytype(game, playtype)) { + useEffect(() => { + setBackground(ToCDNURL(`/game-banners/${gameParam}`)); + }, [gameParam, setBackground]); + + if (!IsSupportedGame(gameParam)) { return ( - + ); } + const game = gameParam; + + return ( + + + + + + ); +} + +function GameV3Routes({ game }: { game: V3Game }) { return ( <>
- +
- - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + @@ -150,46 +137,46 @@ function GamePlaytypeRoutes({ game }: { game: GameGroup }) { ); } -function GPTQuestRoutes({ game, playtype }: GamePT) { +function GPTQuestRoutes({ game }: GamePT) { return ( <> - - + + - - + + - - + + - - + + - - + + ); } -function ChartPageRoutes({ game, playtype }: GamePT) { +function ChartPageRoutes({ game }: GamePT) { const { chartID } = useParams<{ chartID: string }>(); const { data: singleData, error: chartErr } = useApiQuery<{ - chart: MONGO_ChartDocument; - song: MONGO_SongDocument; - }>(`/games/${game}/${playtype}/charts/${chartID}`); + chart: ChartDocument; + song: SongDocument; + }>(`/games/${game}/charts/${chartID}`); const songLegacyId = singleData?.song.id; const { data: songsData, error: songsErr } = useApiQuery( - songLegacyId !== undefined ? `/games/${game}/${playtype}/songs/${songLegacyId}` : "", + songLegacyId !== undefined ? `/games/${game}/songs/${songLegacyId}` : "", undefined, undefined, songLegacyId === undefined, @@ -197,7 +184,7 @@ function ChartPageRoutes({ game, playtype }: GamePT) { const { settings } = useContext(UserSettingsContext); - const [activeChart, setActiveChart] = useState(null); + const [activeChart, setActiveChart] = useState(null); useEffect(() => { const c = songsData?.charts.find((x) => x.chartID === chartID); @@ -216,10 +203,7 @@ function ChartPageRoutes({ game, playtype }: GamePT) { if (songsData.charts.length === 0) { return ( - + ); } @@ -227,18 +211,12 @@ function ChartPageRoutes({ game, playtype }: GamePT) { <> - + {settings?.preferences.developerMode && ( <> @@ -251,10 +229,10 @@ function ChartPageRoutes({ game, playtype }: GamePT) { ); } -function SongChartRedirectRoutes({ game, playtype }: GamePT) { +function SongChartRedirectRoutes({ game }: GamePT) { const { songID } = useParams<{ songID: string }>(); - const { data, error } = useApiQuery(`/games/${game}/${playtype}/songs/${songID}`); + const { data, error } = useApiQuery(`/games/${game}/songs/${songID}`); if (error) { return ; @@ -266,14 +244,11 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) { if (data.charts.length === 0) { return ( - + ); } - if (data.charts.every((c) => c.playtype !== playtype)) { + if (data.charts.every((c) => c.game !== game)) { const c = data.charts[0]; if (!c.chartID) { @@ -285,17 +260,17 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) { ); } - return ; + return ; } return ( - - + + - - + + @@ -305,11 +280,7 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) { ); } -function SongSongIdOnlyRedirect({ - charts, - game, - playtype, -}: { charts: MONGO_ChartDocument[] } & GamePT) { +function SongSongIdOnlyRedirect({ charts, game }: { charts: ChartDocument[] } & GamePT) { const hardest = charts.slice(0).sort(NumericSOV((x) => x.levelNum, true))[0]; if (!hardest.chartID) { @@ -321,52 +292,53 @@ function SongSongIdOnlyRedirect({ ); } - return ; + return ; } -function SongDifficultyRedirect({ data, game, playtype }: { data: SongsReturn } & GamePT) { +function SongDifficultyRedirect({ data, game }: { data: SongsReturn } & GamePT) { const { difficulty: d } = useParams<{ difficulty: string }>(); const difficulty = decodeURIComponent(d); - const gptConfig = GetGamePTConfig(game, playtype); - const chart = SelectRightChart(gptConfig, difficulty, data.charts); + const gameConfig = GetGameConfig(game); + const chart = SelectRightChart(gameConfig, difficulty, data.charts); if (!chart?.chartID) { return ; } - return ; + return ; } function SongInfoHeader({ game, - playtype, song, charts, activeChart, setActiveChart, }: { - activeChart: MONGO_ChartDocument | null; - setActiveChart: SetState; + activeChart: ChartDocument | null; + setActiveChart: SetState; } & GamePT & SongsReturn) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); const sortedCharts = charts.slice(0).sort( - gptConfig.difficulties.type === "DYNAMIC" + gameConfig.difficulties.type === "DYNAMIC" ? StrSOV((x) => x.difficulty) : NumericSOV((x) => // can TS *really* not infer this? - (gptConfig.difficulties as FixedDifficulties).order.indexOf( + (gameConfig.difficulties as FixedDifficulties).order.indexOf( x.difficulty, ), ), ); + const gameGroup = GameToGameGroup(game); + return ( - {game !== "bms" && game !== "pms" && ( + {gameGroup !== "bms" && gameGroup !== "pms" && ( {/* empty padding :) */} @@ -374,7 +346,7 @@ function SongInfoHeader({ - {game !== "bms" && game !== "pms" && ( + {gameGroup !== "bms" && gameGroup !== "pms" && (
Charts

@@ -382,13 +354,12 @@ function SongInfoHeader({ className="btn-group-vertical d-flex justify-content-center" role="group" > - {game === "iidx" ? ( + {gameGroup === "iidx" ? (
- + )}
@@ -425,9 +390,9 @@ function SongInfoHeader({ } type Props = { - activeChart: MONGO_ChartDocument | null; - setActiveChart: SetState; -} & { song: MONGO_SongDocument } & GamePT; + activeChart: ChartDocument | null; + setActiveChart: SetState; +} & { song: SongDocument } & GamePT; const ITG_COLOUR_LOOKUP = { Beginner: COLOUR_SET.paleBlue, @@ -441,13 +406,13 @@ const ITG_COLOUR_LOOKUP = { function DifficultyButton({ chart, game, - playtype, setActiveChart, activeChart, -}: { chart: MONGO_ChartDocument } & Props) { - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)]; +}: { chart: ChartDocument } & Props) { + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; const diffTag = chart.difficulty; + const gameGroup = GameToGameGroup(game); return (
- {FormatDifficulty(chart, game)} + {FormatDifficulty(chart)} {chart.isPrimary ? ( "" ) : ( @@ -502,9 +467,8 @@ function DifficultyList({ activeChart, setActiveChart, game, - playtype, }: { - charts: MONGO_ChartDocument[]; + charts: ChartDocument[]; } & Props) { return ( <> @@ -514,7 +478,6 @@ function DifficultyList({ chart={e} game={game} key={e.chartID} - playtype={playtype} setActiveChart={setActiveChart} song={song} /> @@ -533,16 +496,15 @@ function IIDXDifficultyList({ activeChart, setActiveChart, game, - playtype, }: { - charts: MONGO_ChartDocument[]; + charts: ChartDocument[]; } & Props) { - const { settings } = useLUGPTSettings<"iidx:DP" | "iidx:SP">(); + const { settings } = useLUGPTSettings(); const [set, setSet] = useState<"All Scratch" | "Kichiku" | "Kiraku" | null>(null); if ( - !(activeChart as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">)?.data["2dxtraSet"] && + !(activeChart as ChartDocument)?.data["2dxtraSet"] && !settings?.preferences.gameSpecific.display2DXTra ) { return ( @@ -550,14 +512,12 @@ function IIDXDifficultyList({ {...{ charts: charts.filter( // @ts-expect-error hack - (e: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">) => - e.data["2dxtraSet"] === null, + (e: ChartDocument) => e.data["2dxtraSet"] === null, ), song, activeChart, setActiveChart, game, - playtype, }} /> ); @@ -583,14 +543,13 @@ function IIDXDifficultyList({ {...{ charts: charts.filter( // @ts-expect-error hack - (e: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">) => + (e: ChartDocument) => set ? e.difficulty.startsWith(set) : e.data["2dxtraSet"] === null, ), song, activeChart, setActiveChart, game, - playtype, }} /> diff --git a/typescript/client/src/app/routes/UserRoutes.tsx b/typescript/client/src/app/routes/UserRoutes.tsx index 87dfa53be..e489dbb59 100644 --- a/typescript/client/src/app/routes/UserRoutes.tsx +++ b/typescript/client/src/app/routes/UserRoutes.tsx @@ -1,4 +1,3 @@ -import PlaytypeSelect from "#app/pages/dashboard/games/_game/PlaytypeSelect"; import FoldersMainPage from "#app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage"; import LeaderboardsPage from "#app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage"; import OverviewPage from "#app/pages/dashboard/users/games/_game/_playtype/OverviewPage"; @@ -27,18 +26,11 @@ import { UserContext } from "#context/UserContext"; import { UserSettingsContext } from "#context/UserSettingsContext"; import { type UGPTStatsReturn } from "#types/api-returns"; import { APIFetchV1, type APIFetchV1Return, ToAPIURL } from "#util/api"; -import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts"; +import { IsSupportedGame } from "#util/asserts"; import React, { useContext, useEffect } from "react"; import { useQuery } from "react-query"; import { Redirect, Route, Switch, useHistory, useParams } from "react-router-dom"; -import { - FormatGameGroup, - type GameGroup, - GetGameGroupConfig, - type MONGO_UserDocument, - type MONGO_UserGameStats, - type Playtype, -} from "tachi-common"; +import { FormatGame, type UserDocument, type UserGameStats, type V3Game } from "tachi-common"; import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage"; import UserPage from "../pages/dashboard/users/UserPage"; @@ -48,7 +40,7 @@ export default function UserRoutes() { const { userID } = useParams<{ userID: string }>(); const history = useHistory(); - const { data: reqUser, error } = useApiQuery(`/users/${params.userID}`); + const { data: reqUser, error } = useApiQuery(`/users/${params.userID}`); const { setBackground } = useContext(BackgroundContext); useEffect(() => { @@ -105,7 +97,7 @@ export default function UserRoutes() { ); } -function UserProfileRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) { +function UserProfileRoutes({ reqUser }: { reqUser: UserDocument }) { const { settings } = useContext(UserSettingsContext); return ( @@ -150,81 +142,43 @@ function UserProfileRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) { ); } -function UserGameRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) { - const { game } = useParams<{ game: string }>(); - - if (!IsSupportedGame(game)) { - return ; - } - - const gameConfig = GetGameGroupConfig(game); - +function UserGameRoutes({ reqUser }: { reqUser: UserDocument }) { return ( - - {gameConfig.playtypes.length === 1 ? ( - - ) : ( - - )} - - - - - - - - + + ); } -function UserGamePlaytypeRoutes({ - reqUser, - game, -}: { - game: GameGroup; - reqUser: MONGO_UserDocument; -}) { - const { playtype } = useParams<{ playtype: string }>(); +function V3UserGameRoutes({ reqUser }: { reqUser: UserDocument }) { + const { game: gameParam } = useParams<{ game: string }>(); - if (!IsSupportedPlaytype(game, playtype)) { + if (!IsSupportedGame(gameParam)) { return ( - + ); } - return ; + const game = gameParam; + + return ( + + + + + + ); } -function Inner({ - reqUser, - game, - playtype, -}: { - game: GameGroup; - playtype: Playtype; - reqUser: MONGO_UserDocument; -}) { +function Inner({ reqUser, game }: { game: V3Game; reqUser: UserDocument }) { const { user } = useContext(UserContext); - const { data, error } = useQuery>( - [reqUser.id, game, playtype], + const { data, error } = useQuery>( + [reqUser.id, game], async () => { - const res = await APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}`, - ); + const res = await APIFetchV1(`/users/${reqUser.id}/games/${game}`); if (!res.success) { console.error(res); @@ -255,47 +209,46 @@ function Inner({ } - header={`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Profile`} + header={`${reqUser.username}'s ${FormatGame(game)} Profile`} > - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + diff --git a/typescript/client/src/components/activity/Activity.tsx b/typescript/client/src/components/activity/Activity.tsx index 0fcc0e437..842c8d514 100644 --- a/typescript/client/src/components/activity/Activity.tsx +++ b/typescript/client/src/components/activity/Activity.tsx @@ -38,10 +38,10 @@ import { Button, Col, Row } from "react-bootstrap"; import { Link } from "react-router-dom"; import { FormatChart, - FormatGameGroup, - GetGamePTConfig, + FormatGame, + GetGameConfig, GetScoreEnumConfs, - type MONGO_UserDocument, + type UserDocument, } from "tachi-common"; // Records activity for a group of users on a GPT. Also used for single users. @@ -57,7 +57,7 @@ export default function Activity({ url: string; }) { const [clumped, setClumped] = useState([]); - const [users, setUsers] = useState>([]); + const [users, setUsers] = useState>([]); const [shouldShowGame, setShouldShowGame] = useState(false); const [exhausted, setExhausted] = useState(false); @@ -132,7 +132,7 @@ function ActivityInner({ exhausted: boolean; fetchMoreFrom: (start: number) => void; shouldShowGame: boolean; - users: Array; + users: Array; }) { const userMap = CreateUserMap(users); @@ -250,11 +250,11 @@ function ScoresActivity({ }: { data: ClumpedActivityScores; shouldShowGame: boolean; - user: MONGO_UserDocument; + user: UserDocument; }) { - const { game, playtype } = data.scores[0]; + const game = data.scores[0].game; - const prettyGame = shouldShowGame ? `${FormatGameGroup(game, playtype)} ` : ""; + const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : ""; const [show, setShow] = useState(false); @@ -264,12 +264,7 @@ function ScoresActivity({ if (data.scores.length === 1) { const score0 = data.scores[0]; - subMessage = `a ${prettyGame}score on ${FormatChart( - score0.game, - score0.__related.song, - score0.__related.chart, - true, - )}`; + subMessage = `a ${prettyGame}score on ${FormatChart(score0.__related.chart)}`; if (score0.comment) { mutedText = `"${score0.comment}"`; @@ -278,9 +273,7 @@ function ScoresActivity({ subMessage = `${data.scores.length} ${prettyGame}scores`; mutedText = TruncateString( - data.scores - .map((e) => FormatChart(e.game, e.__related.song, e.__related.chart, true)) - .join(", "), + data.scores.map((e) => FormatChart(e.__related.chart)).join(", "), 100, ); } @@ -301,7 +294,7 @@ function ScoresActivity({
setShow(!show)}>
- + - highlighted{" "} - {subMessage}! + highlighted {subMessage}! {mutedText && ( <> @@ -333,12 +325,7 @@ function ScoresActivity({ {show && ( <> - + )}
@@ -353,11 +340,11 @@ function GoalActivity({ }: { data: ClumpedActivityGoalAchievement; shouldShowGame: boolean; - user: MONGO_UserDocument; + user: UserDocument; }) { - const { game, playtype } = data.goals[0]; + const game = data.goals[0].game; - const prettyGame = shouldShowGame ? `${FormatGameGroup(game, playtype)} ` : ""; + const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : ""; const [show, setShow] = useState(false); @@ -368,7 +355,7 @@ function GoalActivity({ const goal0 = data.goals[0]; subMessage = `${goal0.__related.goal.name}${ - shouldShowGame ? ` in ${FormatGameGroup(game, playtype)}` : "" + shouldShowGame ? ` in ${FormatGame(game)}` : "" }!`; } else { subMessage = `${data.goals.length} ${prettyGame}goals`; @@ -383,7 +370,7 @@ function GoalActivity({
setShow(!show)}>
- + - achieved{" "} - {subMessage}! + achieved {subMessage}! {mutedText && ( <> @@ -438,11 +424,11 @@ function QuestActivity({ }: { data: ClumpedActivityQuestAchievement; shouldShowGame: boolean; - user: MONGO_UserDocument; + user: UserDocument; }) { - const { game, playtype } = data.quest; + const game = data.quest.game; - const prettyGame = shouldShowGame ? FormatGameGroup(game, playtype) : ""; + const prettyGame = shouldShowGame ? FormatGame(game) : ""; return (
@@ -452,13 +438,12 @@ function QuestActivity({
- + - completed - the{" "} + completed the{" "} {data.quest.name} {" "} @@ -486,17 +471,16 @@ function SessionActivity({ }: { data: ClumpedActivitySession; shouldShowGame: boolean; - user: MONGO_UserDocument; + user: UserDocument; }) { const [show, setShow] = useState(false); const { user: loggedInUser } = useContext(UserContext); - const prettyGame = shouldShowGame ? `${FormatGameGroup(data.game, data.playtype)} ` : ""; + const game = data.game; + const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : ""; const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR; - const { game, playtype } = data; - return (
@@ -504,7 +488,7 @@ function SessionActivity({
setShow(!show)}>
- + {/* worst string formatting ever */} - {" "} + {" "} {isProbablyActive ? user.id === loggedInUser?.id ? "are having" @@ -564,7 +548,8 @@ function SessionShower({ sessionID }: { sessionID: string }) { const scoreMap = CreateScoreIDMap(data.scores); - const gptConfig = GetGamePTConfig(data.session.game, data.session.playtype); + const sessionGame = data.session.game; + const gameConfig = GetGameConfig(sessionGame); const raises = data.scoreInfo.filter((e) => { const score = scoreMap.get(e.scoreID); @@ -574,7 +559,7 @@ function SessionShower({ sessionID }: { sessionID: string }) { return false; } - const enumMetrics = GetScoreEnumConfs(gptConfig); + const enumMetrics = GetScoreEnumConfs(gameConfig); // for all enum metrics, check if this score beats the minimum relevant enum // and is a raise. @@ -601,7 +586,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
This session had no raises.
View Full Session @@ -620,7 +605,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
View Full Session @@ -637,7 +622,7 @@ function ClassAchievementActivity({ }: { data: ClumpedActivityClassAchievement; shouldShowGame: boolean; - user: MONGO_UserDocument; + user: UserDocument; }) { return (
@@ -645,35 +630,39 @@ function ClassAchievementActivity({
- - - - {" "} - achieved{" "} - - {shouldShowGame && ` in ${FormatGameGroup(data.game, data.playtype)}`}! - {data.classOldValue !== null && ( - <> - {" "} - (Raised from{" "} - - ) - - )} + {(() => { + const classGame = data.game; + return ( + <> + + + + achieved{" "} + + {shouldShowGame && ` in ${FormatGame(classGame)}`}! + {data.classOldValue !== null && ( + <> + {" "} + (Raised from{" "} + + ) + + )} + + ); + })()}
@@ -689,15 +678,11 @@ function ClassAchievementActivity({ ); } -function UGPTLink({ reqUser, game, playtype }: UGPT) { - // currently +function UGPTLink({ reqUser, game }: UGPT) { const { user } = useContext(UserContext); return ( - + {user?.id === reqUser.id ? "You" : reqUser.username} {reqUser?.isSupporter ? ( <> diff --git a/typescript/client/src/components/charts/GekichumaiScoreChart.tsx b/typescript/client/src/components/charts/GekichumaiScoreChart.tsx index 4f80282a9..aac436cfd 100644 --- a/typescript/client/src/components/charts/GekichumaiScoreChart.tsx +++ b/typescript/client/src/components/charts/GekichumaiScoreChart.tsx @@ -73,11 +73,7 @@ const getScoreYAxisNotch = (game: GameGroup) => (s: number) => { }; const strokeColor = ( - type: - | "BELLS" - | Difficulties["chunithm:Single"] - | Difficulties["maimaidx:Single"] - | Difficulties["ongeki:Single"], + type: "BELLS" | Difficulties["chunithm"] | Difficulties["maimaidx"] | Difficulties["ongeki"], ) => { const isLight = getTheme() === "light"; @@ -170,10 +166,7 @@ export default function GekichumaiScoreChart({ duration, }: { data: Serie[]; - difficulty: - | Difficulties["chunithm:Single"] - | Difficulties["maimaidx:Single"] - | Difficulties["ongeki:Single"]; + difficulty: Difficulties["chunithm"] | Difficulties["maimaidx"] | Difficulties["ongeki"]; duration: number; game: "chunithm" | "maimaidx" | "ongeki"; height?: number | string; @@ -188,18 +181,18 @@ export default function GekichumaiScoreChart({ if (type === "Score") { if (game === "chunithm") { color = - GPT_CLIENT_IMPLEMENTATIONS["chunithm:Single"].difficultyColours[ - difficulty as Difficulties["chunithm:Single"] + GPT_CLIENT_IMPLEMENTATIONS.chunithm.difficultyColours[ + difficulty as Difficulties["chunithm"] ]; } else if (game === "ongeki") { color = - GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].difficultyColours[ - difficulty as Difficulties["ongeki:Single"] + GPT_CLIENT_IMPLEMENTATIONS.ongeki.difficultyColours[ + difficulty as Difficulties["ongeki"] ]; } else if (game === "maimaidx") { color = - GPT_CLIENT_IMPLEMENTATIONS["maimaidx:Single"].difficultyColours[ - difficulty as Difficulties["maimaidx:Single"] + GPT_CLIENT_IMPLEMENTATIONS.maimaidx.difficultyColours[ + difficulty as Difficulties["maimaidx"] ]; } } else if (type === "Bells") { diff --git a/typescript/client/src/components/dashboard/DashboardActivity.tsx b/typescript/client/src/components/dashboard/DashboardActivity.tsx index c940afa6a..9fabbd638 100644 --- a/typescript/client/src/components/dashboard/DashboardActivity.tsx +++ b/typescript/client/src/components/dashboard/DashboardActivity.tsx @@ -1,11 +1,11 @@ import Activity from "#components/activity/Activity"; import { AllLUGPTStatsContext } from "#context/AllLUGPTStatsContext"; import React, { useContext } from "react"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; import { DashboardLoggedInNoScores } from "./DashboardLoggedInNoScores"; -export default function DashboardActivity({ user }: { user: MONGO_UserDocument }) { +export default function DashboardActivity({ user }: { user: UserDocument }) { const { ugs } = useContext(AllLUGPTStatsContext); if (ugs?.length === 0) { diff --git a/typescript/client/src/components/dashboard/DashboardLoggedInNoScores.tsx b/typescript/client/src/components/dashboard/DashboardLoggedInNoScores.tsx index 4185f438c..8e8eaed47 100644 --- a/typescript/client/src/components/dashboard/DashboardLoggedInNoScores.tsx +++ b/typescript/client/src/components/dashboard/DashboardLoggedInNoScores.tsx @@ -2,9 +2,9 @@ import Divider from "#components/util/Divider"; import LinkButton from "#components/util/LinkButton"; import { TachiConfig } from "#lib/config"; import React from "react"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; -export function DashboardLoggedInNoScores({ user }: { user: MONGO_UserDocument }) { +export function DashboardLoggedInNoScores({ user }: { user: UserDocument }) { return (
diff --git a/typescript/client/src/components/game/ClassBadge.tsx b/typescript/client/src/components/game/ClassBadge.tsx index c56ffaf3a..9b41b46e1 100644 --- a/typescript/client/src/components/game/ClassBadge.tsx +++ b/typescript/client/src/components/game/ClassBadge.tsx @@ -4,11 +4,10 @@ import { type GamePT } from "#types/react"; import { UppercaseFirst } from "#util/misc"; import React from "react"; import { Badge } from "react-bootstrap"; -import { type Classes, GetGamePTConfig, GetGPTString, type GPTString } from "tachi-common"; +import { type Classes, GetGameConfig, type V3Game } from "tachi-common"; -export default function ClassBadge({ +export default function ClassBadge({ game, - playtype, classSet, classValue, showSetOnHover = true, @@ -18,12 +17,10 @@ export default function ClassBadge({ showSetOnHover?: boolean; } & GamePT) { const classStyle = - // @ts-expect-error hepl i'm trapped in a type factory - GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)].classColours[classSet][classValue]; + // @ts-expect-error complex indexed types + GPT_CLIENT_IMPLEMENTATIONS[game].classColours[classSet][classValue]; - const data = GetGamePTConfig(game, playtype).classes[classSet].values.find( - (e) => e.id === classValue, - ); + const data = GetGameConfig(game).classes[classSet].values.find((e) => e.id === classValue); if (!data) { return ( @@ -64,7 +61,9 @@ export default function ClassBadge({ } else if (data.hoverText) { return {badgeComponent}; } else if (showSetOnHover) { - {badgeComponent}; + return ( + {badgeComponent} + ); } return badgeComponent; diff --git a/typescript/client/src/components/game/ScoreLeaderboard.tsx b/typescript/client/src/components/game/ScoreLeaderboard.tsx index 51c9e9c71..0fd8499f4 100644 --- a/typescript/client/src/components/game/ScoreLeaderboard.tsx +++ b/typescript/client/src/components/game/ScoreLeaderboard.tsx @@ -14,7 +14,7 @@ import { NumericSOV } from "#util/sorts"; import { ResponsiveBar } from "@nivo/bar"; import React, { useState } from "react"; import Form from "react-bootstrap/Form"; -import { COLOUR_SET, CreateSongMap, GetGamePTConfig, type integer } from "tachi-common"; +import { COLOUR_SET, CreateSongMap, GetGameConfig, type integer } from "tachi-common"; const USER_COLOURS = [ COLOUR_SET.blue, @@ -27,22 +27,21 @@ const USER_COLOURS = [ export default function ScoreLeaderboard({ game, - playtype, url, refreshDeps = [], }: { refreshDeps?: Array; url: string } & GamePT) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - const defaultAlg = useScoreRatingAlg(game, playtype); + const defaultAlg = useScoreRatingAlg(game); const [alg, setAlg] = useState(defaultAlg); const SelectComponent = - Object.keys(gptConfig.scoreRatingAlgs).length > 1 ? ( + Object.keys(gameConfig.scoreRatingAlgs).length > 1 ? ( setAlg(e.target.value as any)} value={alg}> - {Object.keys(gptConfig.scoreRatingAlgs).map((e) => ( + {Object.keys(gameConfig.scoreRatingAlgs).map((e) => ( ))} @@ -102,7 +101,6 @@ export default function ScoreLeaderboard({ defaultRankingViewMode="both-if-self" game={game} indexCol - playtype={playtype} showChart showUser /> @@ -111,15 +109,12 @@ export default function ScoreLeaderboard({ } function DistributionChart({ dataset }: { dataset: PBDataset }) { - // username -> scores in the top N const dist: Record = {}; for (const pb of dataset) { const key = pb.__related.user?.username; if (key === undefined) { - // shouldn't be possible, but lets not have the ui crash - // because of something so menial continue; } diff --git a/typescript/client/src/components/game/charts/ChartInfoFormat.tsx b/typescript/client/src/components/game/charts/ChartInfoFormat.tsx index cbc8190da..94433359f 100644 --- a/typescript/client/src/components/game/charts/ChartInfoFormat.tsx +++ b/typescript/client/src/components/game/charts/ChartInfoFormat.tsx @@ -15,42 +15,41 @@ import React, { useContext } from "react"; import { Col, Row } from "react-bootstrap"; import { Link } from "react-router-dom"; import { + type ChartDocument, + type FolderDocument, FormatDifficultySearch, - type GameGroup, + GameToGameGroup, + GetGameConfig, GetGameGroupConfig, - GetGamePTConfig, - GetGPTString, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_SongDocument, + type SongDocument, + type V3Game, } from "tachi-common"; export default function ChartInfoFormat({ song, chart, game, - playtype, -}: { chart: MONGO_ChartDocument; song: MONGO_SongDocument } & GamePT) { - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)]; +}: { chart: ChartDocument; song: SongDocument } & GamePT) { + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; const ratingSystems = gptImpl.ratingSystems; - const { data, error } = useApiQuery( - `/games/${game}/${playtype}/charts/${chart.chartID}/folders`, + const { data, error } = useApiQuery( + `/games/${game}/charts/${chart.chartID}/folders`, ); const { user } = useContext(UserContext); const { ugs } = useContext(AllLUGPTStatsContext); if (error) { - ; + return ; } if (!data) { return ; } - const versions = Object.keys(GetGamePTConfig(game, playtype).versions); + const versions = Object.keys(GetGameConfig(game).versions); return ( ( -
  • +
  • {user && ugs ? ( {e.title} @@ -145,12 +144,12 @@ function ChartInfoMiddle({ song, chart, }: { - chart: MONGO_ChartDocument; - game: GameGroup; - song: MONGO_SongDocument; + chart: ChartDocument; + game: V3Game; + song: SongDocument; }) { - if (game === "bms") { - const bmsChart = chart as MONGO_ChartDocument<"bms:7K" | "bms:14K">; + if (game === "bms-7k" || game === "bms-14k") { + const bmsChart = chart as ChartDocument<"bms-7k" | "bms-14k">; return ( <> @@ -167,8 +166,8 @@ function ChartInfoMiddle({ ); - } else if (game === "pms") { - const pmsChart = chart as MONGO_ChartDocument<"pms:Controller" | "pms:Keyboard">; + } else if (game === "pms-controller" || game === "pms-keyboard") { + const pmsChart = chart as ChartDocument<"pms-controller" | "pms-keyboard">; return ( <> @@ -181,11 +180,11 @@ function ChartInfoMiddle({ ); } - const gameConfig = GetGameGroupConfig(game); + const gameGroupConfig = GetGameGroupConfig(GameToGameGroup(game)); - const diff = FormatDifficultySearch(chart, game); + const diff = FormatDifficultySearch(chart); const gameName = - game === "ongeki" ? "オンゲキ" : game === "maimaidx" ? "maimai" : gameConfig.name; + game === "ongeki" ? "オンゲキ" : game === "maimaidx" ? "maimai" : gameGroupConfig.name; const formattedTitle = song.title.replace(/-/gu, " "); let search = `${gameName} ${formattedTitle}`; diff --git a/typescript/client/src/components/game/folder/FolderInfoHeader.tsx b/typescript/client/src/components/game/folder/FolderInfoHeader.tsx index e589b36c5..4956967b7 100644 --- a/typescript/client/src/components/game/folder/FolderInfoHeader.tsx +++ b/typescript/client/src/components/game/folder/FolderInfoHeader.tsx @@ -8,12 +8,7 @@ import { type GamePT } from "#types/react"; import { type FolderDataset } from "#types/tables"; import { UppercaseFirst } from "#util/misc"; import React, { useMemo, useState } from "react"; -import { - GetGamePTConfig, - GetScoreEnumConfs, - type GPTString, - type MONGO_UserDocument, -} from "tachi-common"; +import { GetGameConfig, GetScoreEnumConfs, type UserDocument } from "tachi-common"; import FolderMinimap from "./FolderMinimap"; import FolderScoreAverages from "./FolderScoreAverages"; @@ -21,25 +16,24 @@ import FolderScoreDistributionChart from "./FolderScoreDistributionChart"; export default function FolderInfoHeader({ game, - playtype, reqUser, folderDataset, folderTitle, }: { folderDataset: FolderDataset; folderTitle: string; - reqUser: MONGO_UserDocument; + reqUser: UserDocument; } & GamePT) { - const preferredDefaultEnum = useBucket(game, playtype); + const preferredDefaultEnum = useBucket(game); const [currentGraph, setCurrentGraph] = useState(`${preferredDefaultEnum}-stats`); const enumGraphs = ["minimap", "stats"]; - const gptConfig = GetGamePTConfig(game, playtype); - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString]; + const gameConfig = GetGameConfig(game); + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; - const enumConf = GetScoreEnumConfs(gptConfig); + const enumConf = GetScoreEnumConfs(gameConfig); const [metric, type] = useMemo(() => currentGraph.split("-"), [currentGraph]); @@ -82,7 +76,6 @@ export default function FolderInfoHeader({ ) : type === "minimap" ? ( @@ -90,16 +83,10 @@ export default function FolderInfoHeader({ enumMetric={metric} folderDataset={folderDataset} game={game} - playtype={playtype} reqUser={reqUser} /> ) : ( - + )} ); diff --git a/typescript/client/src/components/game/folder/FolderMinimap.tsx b/typescript/client/src/components/game/folder/FolderMinimap.tsx index 97e2fca0d..8434ecac1 100644 --- a/typescript/client/src/components/game/folder/FolderMinimap.tsx +++ b/typescript/client/src/components/game/folder/FolderMinimap.tsx @@ -16,28 +16,27 @@ import React, { useMemo } from "react"; import { Link } from "react-router-dom"; import { FormatChart, - type GameGroup, - type GamePTConfig, - GetGamePTConfig, - GetGPTString, + type GameConfig, + GetGameConfig, GetScoreMetricConf, IIDX_LAMPS, - type MONGO_SessionDocument, - type MONGO_UserDocument, + type SessionDocument, type SessionScoreInfo, + type UserDocument, + type V3Game, } from "tachi-common"; type Props = { enumMetric: string; folderDataset: FolderDataset; - reqUser: MONGO_UserDocument; + reqUser: UserDocument; } & GamePT; export default function FolderMinimap(props: Props) { const { data, isLoading, error } = useApiQuery<{ scoreInfo: Array; - session: MONGO_SessionDocument; - }>(`/users/${props.reqUser.id}/games/${props.game}/${props.playtype}/sessions/last`); + session: SessionDocument; + }>(`/users/${props.reqUser.id}/games/${props.game}/sessions/last`); const session = useMemo(() => { if (error && error.statusCode === 404) { @@ -71,17 +70,16 @@ export default function FolderMinimap(props: Props) { function FolderMinimapMain({ game, - playtype, folderDataset, enumMetric, recentSession, }: { - recentSession: { scoreInfo: Array; session: MONGO_SessionDocument } | null; + recentSession: { scoreInfo: Array; session: SessionDocument } | null; } & Props) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); const getterFn = useMemo<(f: FolderDataset[0]) => number | undefined>(() => { - const conf = GetScoreMetricConf(gptConfig, enumMetric); + const conf = GetScoreMetricConf(gameConfig, enumMetric); if (!conf) { return () => 0; // wut @@ -133,7 +131,7 @@ function FolderMinimapMain({ data={d} enumMetric={enumMetric} game={game} - gptConfig={gptConfig} + gameConfig={gameConfig} key={d.chartID} wasRecent={ (d.__related.pb && @@ -154,18 +152,18 @@ function FolderMinimapMain({ function MinimapElement({ data, - gptConfig, + gameConfig, enumMetric, game, wasRecent, }: { data: FolderDataset[0]; enumMetric: string; - game: GameGroup; - gptConfig: GamePTConfig; + game: V3Game; + gameConfig: GameConfig; wasRecent: boolean; }) { - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, data.playtype)]; + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; let icon = "level-up-alt"; @@ -188,14 +186,14 @@ function MinimapElement({ // @ts-expect-error unhinged dynamic access (i dont care) return gptImpl.enumColours[enumMetric][data.__related.pb.scoreData[enumMetric]]; - }, [data.__related.pb, gptConfig, enumMetric]); + }, [data.__related.pb, gameConfig, enumMetric]); return ( - {FormatChart(game, data.__related.song, data)} + {FormatChart(data)} {wasRecent && ( <> @@ -222,7 +220,7 @@ function MinimapElement({
  • } > - +
    ; +export default function FolderScoreDistributionChart({ game, folderDataset, view: metric }: Props) { + const gameConfig = GetGameConfig(game); + const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric; - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)]; + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; const values = useMemo( // @ts-expect-error hack diff --git a/typescript/client/src/components/game/songs/DefaultSongChartInfoFormat.tsx b/typescript/client/src/components/game/songs/DefaultSongChartInfoFormat.tsx index 40af83f77..fe7cc0aff 100644 --- a/typescript/client/src/components/game/songs/DefaultSongChartInfoFormat.tsx +++ b/typescript/client/src/components/game/songs/DefaultSongChartInfoFormat.tsx @@ -1,20 +1,15 @@ import { DisplayLevelNum } from "#components/tables/cells/DifficultyCell"; import React from "react"; -import { - FormatDifficulty, - type GameGroup, - type MONGO_ChartDocument, - type MONGO_SongDocument, -} from "tachi-common"; +import { type ChartDocument, FormatDifficulty, type SongDocument, type V3Game } from "tachi-common"; export default function DefaultSongChartInfoFormat({ song, chart, game, }: { - chart: MONGO_ChartDocument | null; - game: GameGroup; - song: MONGO_SongDocument; + chart: ChartDocument | null; + game: V3Game; + song: SongDocument; }) { return ( <> @@ -23,7 +18,7 @@ export default function DefaultSongChartInfoFormat({ {chart && ( <> -
    ({FormatDifficulty(chart, game)})
    +
    ({FormatDifficulty(chart)})
    ; + chart: ChartDocument | null; + game: V3Game; + song: SongDocument<"bms" | "chunithm" | "iidx" | "maimaidx" | "ongeki" | "pms" | "popn">; }) { return ( <>

    {song.data.genre}

    {song.title}

    {song.artist}

    - {chart &&
    ({LevelText(chart, game)})
    } + {chart &&
    ({LevelText(chart)})
    } ); } -function LevelText(chart: MONGO_ChartDocument, game: GameGroup) { +function LevelText(chart: ChartDocument) { if ("tableFolders" in chart.data) { const hasLevel = Object.keys(chart.data.tableFolders).length > 0; return hasLevel ? FormatTables(chart.data.tableFolders) : "No Level"; } - return FormatDifficulty(chart, game); + return FormatDifficulty(chart); } diff --git a/typescript/client/src/components/game/songs/SongChartInfoFormat.tsx b/typescript/client/src/components/game/songs/SongChartInfoFormat.tsx index 28ddf655b..5ce58dad2 100644 --- a/typescript/client/src/components/game/songs/SongChartInfoFormat.tsx +++ b/typescript/client/src/components/game/songs/SongChartInfoFormat.tsx @@ -1,7 +1,7 @@ import { DisplayLevelNum } from "#components/tables/cells/DifficultyCell"; import Muted from "#components/util/Muted"; import React from "react"; -import { type GameGroup, type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, GameToGameGroup, type SongDocument, type V3Game } from "tachi-common"; import DefaultSongChartInfoFormat from "./DefaultSongChartInfoFormat"; import IIDXStyleSongChartInfoFormat from "./IIDXStyleSongChartInfoFormat"; @@ -11,27 +11,29 @@ export default function SongChartInfoFormat({ chart, game, }: { - chart: MONGO_ChartDocument | null; - game: GameGroup; - song: MONGO_SongDocument; + chart: ChartDocument | null; + game: V3Game; + song: SongDocument; }) { - if (["bms", "iidx", "pms", "popn"].includes(game)) { + const gameGroup = GameToGameGroup(game); + + if (["bms", "iidx", "pms", "popn"].includes(gameGroup)) { return ( , + song: song as SongDocument<"bms" | "iidx" | "pms" | "popn">, chart, game, }} /> ); } - if (game === "ongeki" || game === "chunithm" || game === "maimaidx") { + if (gameGroup === "ongeki" || gameGroup === "chunithm" || gameGroup === "maimaidx") { return ( <> , + song: song as SongDocument<"chunithm" | "maimaidx" | "ongeki">, chart, game, }} diff --git a/typescript/client/src/components/game/targets/QuestPage.tsx b/typescript/client/src/components/game/targets/QuestPage.tsx index d8c0208b5..60f03994d 100644 --- a/typescript/client/src/components/game/targets/QuestPage.tsx +++ b/typescript/client/src/components/game/targets/QuestPage.tsx @@ -10,24 +10,16 @@ import { CreateGoalMap } from "#util/data"; import React from "react"; import { Col } from "react-bootstrap"; import { Link, useParams } from "react-router-dom"; -import { GetGameGroupConfig } from "tachi-common"; +import { FormatGame } from "tachi-common"; -export default function QuestPage({ game, playtype }: GamePT) { +export default function QuestPage({ game }: GamePT) { const { questID } = useParams<{ questID: string }>(); - const { data, error } = useApiQuery( - `/games/${game}/${playtype}/targets/quests/${questID}`, - ); + const { data, error } = useApiQuery(`/games/${game}/targets/quests/${questID}`); useSetSubheader( - [ - "Games", - GetGameGroupConfig(game).name, - playtype, - "Quests", - data ? data.quest.name : "Loading...", - ], - [game, playtype, data], + ["Games", FormatGame(game), "Quests", data ? data.quest.name : "Loading..."], + [game, data], data ? data.quest.name : "Loading...", ); @@ -46,7 +38,7 @@ export default function QuestPage({ game, playtype }: GamePT) { return (
    - Go back to all quests... + Go back to all quests... diff --git a/typescript/client/src/components/game/targets/QuestlinePage.tsx b/typescript/client/src/components/game/targets/QuestlinePage.tsx index 5e0d3815f..acd7b573b 100644 --- a/typescript/client/src/components/game/targets/QuestlinePage.tsx +++ b/typescript/client/src/components/game/targets/QuestlinePage.tsx @@ -12,24 +12,18 @@ import { CreateQuestMap } from "#util/misc"; import React from "react"; import { Col, Row } from "react-bootstrap"; import { Link, useParams } from "react-router-dom"; -import { GetGameGroupConfig } from "tachi-common"; +import { FormatGame } from "tachi-common"; -export default function QuestlinePage({ game, playtype }: GamePT) { +export default function QuestlinePage({ game }: GamePT) { const { questlineID } = useParams<{ questlineID: string }>(); const { data, error } = useApiQuery( - `/games/${game}/${playtype}/targets/questlines/${questlineID}`, + `/games/${game}/targets/questlines/${questlineID}`, ); useSetSubheader( - [ - "Games", - GetGameGroupConfig(game).name, - playtype, - "Quests", - data ? data.questline.name : "Loading...", - ], - [game, playtype, data], + ["Games", FormatGame(game), "Quests", data ? data.questline.name : "Loading..."], + [game, data], data ? data.questline.name : "Loading...", ); @@ -48,7 +42,7 @@ export default function QuestlinePage({ game, playtype }: GamePT) { return ( - Go back to all questlines... + Go back to all questlines... diff --git a/typescript/client/src/components/game/targets/QuestsPage.tsx b/typescript/client/src/components/game/targets/QuestsPage.tsx index f7bbf2dc0..72565c717 100644 --- a/typescript/client/src/components/game/targets/QuestsPage.tsx +++ b/typescript/client/src/components/game/targets/QuestsPage.tsx @@ -12,33 +12,28 @@ import React from "react"; import { Col, Row } from "react-bootstrap"; import { Link } from "react-router-dom"; import { - FormatGameGroup, - GetGameGroupConfig, - type MONGO_GoalDocument, - type MONGO_QuestDocument, - type MONGO_QuestlineDocument, + FormatGame, + type GoalDocument, + type QuestDocument, + type QuestlineDocument, } from "tachi-common"; -export default function QuestsPage({ game, playtype }: GamePT) { - useSetSubheader( - ["Games", GetGameGroupConfig(game).name, playtype, "Quests"], - [game, playtype], - `${FormatGameGroup(game, playtype)} Quests`, - ); +export default function QuestsPage({ game }: GamePT) { + useSetSubheader(["Games", FormatGame(game), "Quests"], [game], `${FormatGame(game)} Quests`); return (
    - +
    ); } -function QuestlineSelector({ game, playtype }: GamePT) { +function QuestlineSelector({ game }: GamePT) { const { data, error } = useApiQuery<{ - questlines: Array; - standalone: Array; - standaloneGoals: Array; - }>(`/games/${game}/${playtype}/targets/questlines`); + questlines: Array; + standalone: Array; + standaloneGoals: Array; + }>(`/games/${game}/targets/questlines`); if (error) { return ; diff --git a/typescript/client/src/components/gpt-utils/GPTUtils.tsx b/typescript/client/src/components/gpt-utils/GPTUtils.tsx index 9d4936627..b3b8d8046 100644 --- a/typescript/client/src/components/gpt-utils/GPTUtils.tsx +++ b/typescript/client/src/components/gpt-utils/GPTUtils.tsx @@ -1,5 +1,5 @@ import { type GPTUtility } from "#types/ugpt"; -import { type GameGroup, type GPTString, type Playtype } from "tachi-common"; +import { type V3Game } from "tachi-common"; import { JubilityBreakdownInsight } from "./insights/JubilityBreakdownInsight"; import { @@ -11,46 +11,39 @@ import { BMSSieglindeInfoTool } from "./tools/BMSSieglindeInfoTool"; import { IIDXEamusementExportTool } from "./tools/IIDXEamusementExportTool"; import { IIDXPlaylistsTool } from "./tools/IIDXPlaylistsTool"; -// What utils does each game support? -const GPT_UTILS: Record> = { - "arcaea:Touch": [], - "bms:7K": [BMSCustomTablesTool, BMSSieglindeInfoTool], - "bms:14K": [BMSCustomTablesTool, BMSSieglindeInfoTool], - "chunithm:Single": [], - "gitadora:Dora": [], - "gitadora:Gita": [], - "iidx:DP": [IIDXEamusementExportTool, IIDXPlaylistsTool], - "iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool], - "itg:Stamina": [], - "jubeat:Single": [JubilityBreakdownInsight], - "museca:Single": [], - "pms:Controller": [], - "pms:Keyboard": [], - "popn:9B": [], - "sdvx:Single": [], - "usc:Controller": [], - "usc:Keyboard": [], - "wacca:Single": [], - "maimaidx:Single": [], - "maimai:Single": [], - "ongeki:Single": [ONGEKIRefreshBreakdownInsight, ONGEKIClassicBreakdownInsight], - "ddr:SP": [], - "ddr:DP": [], +const GPT_UTILS: Record> = { + arcaea: [], + "bms-7k": [BMSCustomTablesTool, BMSSieglindeInfoTool], + "bms-14k": [BMSCustomTablesTool, BMSSieglindeInfoTool], + chunithm: [], + "gitadora-dora": [], + "gitadora-gita": [], + "iidx-dp": [IIDXEamusementExportTool, IIDXPlaylistsTool], + "iidx-sp": [IIDXEamusementExportTool, IIDXPlaylistsTool], + "itg-stamina": [], + jubeat: [JubilityBreakdownInsight], + museca: [], + "pms-controller": [], + "pms-keyboard": [], + popn: [], + sdvx: [], + "usc-controller": [], + "usc-keyboard": [], + wacca: [], + maimaidx: [], + maimai: [], + ongeki: [ONGEKIRefreshBreakdownInsight, ONGEKIClassicBreakdownInsight], + "ddr-sp": [], + "ddr-dp": [], }; -export function GetGPTUtils(game: GameGroup, playtype: Playtype) { - const idString = `${game}:${playtype}` as GPTString; - - return GPT_UTILS[idString]; +export function GetGPTUtils(game: V3Game) { + return GPT_UTILS[game]; } -export function GetGPTUtilsName(game: GameGroup, playtype: Playtype, isViewingOwnProfile: boolean) { - const utils = GetGPTUtils(game, playtype); +export function GetGPTUtilsName(game: V3Game, isViewingOwnProfile: boolean) { + const utils = GetGPTUtils(game); - // things for personal use only are called "tools" - // things everyone might want to view about a person are called "insights". - // an example of an insight would be a jubility breakdown - // an example of a tool would be a eamusement csv export thing. const tools = utils.filter((e) => e.personalUseOnly); const insights = utils.filter((e) => e.personalUseOnly !== true); diff --git a/typescript/client/src/components/gpt-utils/insights/JubilityBreakdownInsight.tsx b/typescript/client/src/components/gpt-utils/insights/JubilityBreakdownInsight.tsx index d792eefeb..dd8cd4994 100644 --- a/typescript/client/src/components/gpt-utils/insights/JubilityBreakdownInsight.tsx +++ b/typescript/client/src/components/gpt-utils/insights/JubilityBreakdownInsight.tsx @@ -14,19 +14,19 @@ import { NumericSOV } from "#util/sorts"; import React from "react"; import { Col, Row } from "react-bootstrap"; import { + type ChartDocument, CreateSongMap, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_SongDocument, + type PBScoreDocument, + type SongDocument, } from "tachi-common"; -function Component({ game, playtype, reqUser }: UGPT) { +function Component({ game, reqUser }: UGPT) { const { data, error } = useApiQuery<{ - charts: Array; - other: Array; - pickUp: Array; - songs: Array; - }>(`/users/${reqUser.id}/games/${game}/${playtype}/jubility`); + charts: Array; + other: Array; + pickUp: Array; + songs: Array; + }>(`/users/${reqUser.id}/games/${game}/jubility`); const preferredRanking = usePreferredRanking(); @@ -107,7 +107,6 @@ function Component({ game, playtype, reqUser }: UGPT) { defaultRankingViewMode={preferredRanking} game={game} indexCol - playtype={playtype} /> @@ -123,7 +122,6 @@ function Component({ game, playtype, reqUser }: UGPT) { defaultRankingViewMode={preferredRanking} game={game} indexCol - playtype={playtype} /> diff --git a/typescript/client/src/components/gpt-utils/insights/ONGEKIBreakdownInsight.tsx b/typescript/client/src/components/gpt-utils/insights/ONGEKIBreakdownInsight.tsx index 37ab6a4ba..c8f23f66c 100644 --- a/typescript/client/src/components/gpt-utils/insights/ONGEKIBreakdownInsight.tsx +++ b/typescript/client/src/components/gpt-utils/insights/ONGEKIBreakdownInsight.tsx @@ -20,27 +20,26 @@ import React from "react"; import { Col, Row } from "react-bootstrap"; import { type AnyScoreRatingAlg, + type ChartDocument, COLOUR_SET, CreateSongMap, FmtNum, - type GameGroup, - type GPTString, type integer, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_SongDocument, + type PBScoreDocument, + type SongDocument, + type V3Game, } from "tachi-common"; -function ComponentClassic({ game, playtype, reqUser }: UGPT) { - if (game !== "ongeki" || playtype !== "Single") { - throw new Error("Game or playtype is not ongeki:Single"); +function ComponentClassic({ game, reqUser }: UGPT) { + if (game !== "ongeki") { + throw new Error("Game is not ongeki"); } const { data, error } = useApiQuery<{ - charts: Array>; - pbs: Array>; - songs: Array>; - }>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=rating`); + charts: Array>; + pbs: Array>; + songs: Array>; + }>(`/users/${reqUser.id}/games/${game}/pbs/best?alg=rating`); if (error) { return ; @@ -50,8 +49,8 @@ function ComponentClassic({ game, playtype, reqUser }: UGPT) { return ; } - const flatDataset: PBDataset<"ongeki:Single"> = CreateFlatDataset(data, "rating", 45); - const compoundDataset = ColumnMerge<"ongeki:Single">(flatDataset, 3); + const flatDataset: PBDataset<"ongeki"> = CreateFlatDataset<"ongeki">(data, "rating", 45); + const compoundDataset = ColumnMerge<"ongeki">(flatDataset, 3); const classicRating = Math.floor( @@ -94,22 +93,22 @@ function ComponentClassic({ game, playtype, reqUser }: UGPT) { ); } -function ComponentRefresh({ game, playtype, reqUser }: UGPT) { - if (game !== "ongeki" || playtype !== "Single") { - throw new Error("Game or playtype is not ongeki:Single"); +function ComponentRefresh({ game, reqUser }: UGPT) { + if (game !== "ongeki") { + throw new Error("Game is not ongeki"); } const query1 = useApiQuery<{ - charts: Array>; - pbs: Array>; - songs: Array>; - }>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=scoreRating`); + charts: Array>; + pbs: Array>; + songs: Array>; + }>(`/users/${reqUser.id}/games/${game}/pbs/best?alg=scoreRating`); const query2 = useApiQuery<{ - charts: Array>; - pbs: Array>; - songs: Array>; - }>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=starRating`); + charts: Array>; + pbs: Array>; + songs: Array>; + }>(`/users/${reqUser.id}/games/${game}/pbs/best?alg=starRating`); if (query1.error) { return ; @@ -123,19 +122,19 @@ function ComponentRefresh({ game, playtype, reqUser }: UGPT) { return ; } - const flatDatasetScore: PBDataset<"ongeki:Single"> = CreateFlatDataset( + const flatDatasetScore: PBDataset<"ongeki"> = CreateFlatDataset<"ongeki">( query1.data, "scoreRating", 60, ); - const flatDatasetStar: PBDataset<"ongeki:Single"> = CreateFlatDataset( + const flatDatasetStar: PBDataset<"ongeki"> = CreateFlatDataset<"ongeki">( query2.data, "starRating", 51, ); - const datasetScore = ColumnMerge<"ongeki:Single">(flatDatasetScore, 3); - const datasetStar = ColumnMerge<"ongeki:Single">(flatDatasetStar, 3); + const datasetScore = ColumnMerge<"ongeki">(flatDatasetScore, 3); + const datasetStar = ColumnMerge<"ongeki">(flatDatasetStar, 3); const scoreR1k = Math.floor( flatDatasetScore.reduce( @@ -228,13 +227,13 @@ function CompactRow({ lampField, }: { count?: number; - game: GameGroup; - lampField: (pb: MONGO_PBScoreDocument<"ongeki:Single">) => string | JSX.Element; - pbs: PBDataset<"ongeki:Single">[0][]; - ratingField: (pb: MONGO_PBScoreDocument<"ongeki:Single">) => string | JSX.Element; + game: V3Game; + lampField: (pb: PBScoreDocument<"ongeki">) => string | JSX.Element; + pbs: PBDataset<"ongeki">[0][]; + ratingField: (pb: PBScoreDocument<"ongeki">) => string | JSX.Element; scoreField: ( - pb: MONGO_PBScoreDocument<"ongeki:Single">, - chart: MONGO_ChartDocument<"ongeki:Single">, + pb: PBScoreDocument<"ongeki">, + chart: ChartDocument<"ongeki">, ) => string | JSX.Element; }) { return ( @@ -273,15 +272,15 @@ function CompactCell({ ratingField, lampField, }: { - chart: MONGO_ChartDocument<"ongeki:Single">; - lampField: (pb: MONGO_PBScoreDocument<"ongeki:Single">) => string | JSX.Element; - pb: MONGO_PBScoreDocument<"ongeki:Single">; - ratingField: (pb: MONGO_PBScoreDocument<"ongeki:Single">) => string | JSX.Element; + chart: ChartDocument<"ongeki">; + lampField: (pb: PBScoreDocument<"ongeki">) => string | JSX.Element; + pb: PBScoreDocument<"ongeki">; + ratingField: (pb: PBScoreDocument<"ongeki">) => string | JSX.Element; scoreField: ( - pb: MONGO_PBScoreDocument<"ongeki:Single">, - chart: MONGO_ChartDocument<"ongeki:Single">, + pb: PBScoreDocument<"ongeki">, + chart: ChartDocument<"ongeki">, ) => string | JSX.Element; - song: MONGO_SongDocument; + song: SongDocument; }) { // Third-party scripts may find this useful const className = `c-${chart.data.inGameID}`; @@ -290,7 +289,7 @@ function CompactCell({
    - + {song.title}
    @@ -339,7 +338,7 @@ function ShortLamp({ let text1 = ""; let color2 = COLOUR_SET.gray; let text2 = ""; - const color3 = GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].enumColours.grade[grade]; + const color3 = GPT_CLIENT_IMPLEMENTATIONS.ongeki.enumColours.grade[grade]; if (noteLamp === "ALL BREAK+") { color1 = COLOUR_SET.vibrantBlue; @@ -388,14 +387,14 @@ function ShortLamp({ ); } -function CreateFlatDataset(data: any, alg: AnyScoreRatingAlg, count: number) { +function CreateFlatDataset(data: any, alg: AnyScoreRatingAlg, count: number) { const flatDataset: PBDataset = []; const songMap = CreateSongMap(data.songs); const chartMap = CreateChartMap(data.charts); const sortedRatings = data.pbs - .map((e: MONGO_PBScoreDocument) => e.calculatedData[alg]) + .map((e: PBScoreDocument) => e.calculatedData[alg]) .sort(NumericSOV((x: number) => x ?? -Infinity, true)); for (const pb of data.pbs.slice(0, count)) { @@ -419,7 +418,7 @@ function CreateFlatDataset(data: any, alg: AnyScoreRatingAl return flatDataset; } -function ColumnMerge(flatDataset: PBDataset, columns: number) { +function ColumnMerge(flatDataset: PBDataset, columns: number) { const compoundDataset: PBDataset[] = [[]]; for (const d of flatDataset) { const back = compoundDataset[compoundDataset.length - 1]; diff --git a/typescript/client/src/components/gpt-utils/tools/BMSCustomTablesTool.tsx b/typescript/client/src/components/gpt-utils/tools/BMSCustomTablesTool.tsx index 619ed2280..8b361d02a 100644 --- a/typescript/client/src/components/gpt-utils/tools/BMSCustomTablesTool.tsx +++ b/typescript/client/src/components/gpt-utils/tools/BMSCustomTablesTool.tsx @@ -12,7 +12,7 @@ import { CopyToClipboard } from "#util/misc"; import React from "react"; import { Col, Row } from "react-bootstrap"; -function Component({ game, playtype, reqUser }: UGPT) { +function Component({ game, reqUser }: UGPT) { const { data, error } = useApiQuery< Array<{ description: string; @@ -21,7 +21,7 @@ function Component({ game, playtype, reqUser }: UGPT) { tableName: string; urlName: string; }> - >(`/games/${game}/${playtype}/custom-tables`); + >(`/games/${game}/custom-tables`); if (error) { return ; @@ -42,7 +42,7 @@ function Component({ game, playtype, reqUser }: UGPT) { const TABLE_URL = ToAPIURL( `${ tbl.forSpecificUser ? `/users/${reqUser.username}` : "" - }/games/bms/${playtype}/custom-tables/${tbl.urlName}`, + }/games/${game}/custom-tables/${tbl.urlName}`, ); return ( diff --git a/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx b/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx index e3a3bb227..ac2b08790 100644 --- a/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx +++ b/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx @@ -13,18 +13,18 @@ import { CreateSongMap } from "#util/data"; import { NumericSOV, StrSOV } from "#util/sorts"; import React from "react"; import { Col, Row } from "react-bootstrap"; -import { BMS_TABLES, type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { BMS_TABLES, type ChartDocument, type SongDocument } from "tachi-common"; import { FormatSieglindeBMS } from "tachi-common/config/game-support/bms"; -type DatasetElement = { __related: { song: MONGO_SongDocument } } & MONGO_ChartDocument< - "bms:7K" | "bms:14K" ->; +type BMSGames = "bms-7k" | "bms-14k"; -function Component({ game, playtype }: UGPT) { +type DatasetElement = { __related: { song: SongDocument } } & ChartDocument; + +function Component({ game }: UGPT) { const { data, error } = useApiQuery<{ - charts: Array>; - songs: Array>; - }>(`/games/${game}/${playtype}/sieglinde-charts`); + charts: Array>; + songs: Array>; + }>(`/games/${game}/sieglinde-charts`); if (error) { return ; @@ -39,10 +39,10 @@ function Component({ game, playtype }: UGPT) { const songMap = CreateSongMap(data.songs); for (const chart of data.charts) { - const song = songMap.get(chart.songID); + const song = songMap.get(chart.song.id); if (!song) { - console.warn(`Couldn't find a parent song for ${chart.songID}? Skipping.`); + console.warn(`Couldn't find a parent song for ${chart.song.id}? Skipping.`); continue; } @@ -54,6 +54,8 @@ function Component({ game, playtype }: UGPT) { }); } + const bmsGame = (game === "bms-7k" || game === "bms-14k" ? game : "bms-7k") as BMSGames; + return ( @@ -75,32 +77,45 @@ function Component({ game, playtype }: UGPT) { defaultSortMode="EASY CLEAR Sieglinde" entryName="Charts" headers={[ - ChartHeader("bms", (k) => k as MONGO_ChartDocument), + ChartHeader(bmsGame, (k) => k as ChartDocument), ["Song", "Song", StrSOV((x) => x.__related.song.title)], [ "EASY CLEAR Sieglinde", "EC sgl.", - NumericSOV((x) => x.data.sglEC ?? -Infinity), + NumericSOV( + (x) => (x as ChartDocument<"bms-7k">).data.sglEC ?? -Infinity, + ), ], [ "HARD CLEAR Sieglinde", "EC sgl.", - NumericSOV((x) => x.data.sglHC ?? -Infinity), + NumericSOV( + (x) => (x as ChartDocument<"bms-7k">).data.sglHC ?? -Infinity, + ), ], ]} rowFunction={(d) => ( - - - {FormatSieglindeBMS(d.data.sglEC ?? 0)} - {FormatSieglindeBMS(d.data.sglHC ?? 0)} + } + game={bmsGame} + /> + + + {FormatSieglindeBMS((d as ChartDocument<"bms-7k">).data.sglEC ?? 0)} + + + {FormatSieglindeBMS((d as ChartDocument<"bms-7k">).data.sglHC ?? 0)} + )} searchFunctions={Object.fromEntries( BMS_TABLES.map((e) => [ e.asciiPrefix, (d: DatasetElement) => - !!Object.keys(d.data.tableFolders).find((k) => k === e.prefix), + !!Object.keys( + (d as ChartDocument<"bms-7k">).data.tableFolders, + ).find((k) => k === e.prefix), ]), )} /> diff --git a/typescript/client/src/components/gpt-utils/tools/IIDXEamusementExportTool.tsx b/typescript/client/src/components/gpt-utils/tools/IIDXEamusementExportTool.tsx index 0c37662a2..6e347249b 100644 --- a/typescript/client/src/components/gpt-utils/tools/IIDXEamusementExportTool.tsx +++ b/typescript/client/src/components/gpt-utils/tools/IIDXEamusementExportTool.tsx @@ -9,9 +9,9 @@ import { CopyToClipboard } from "#util/misc"; import React from "react"; import { Alert, Button, Col, Row } from "react-bootstrap"; -function Component({ game, playtype, reqUser }: UGPT) { +function Component({ game, reqUser }: UGPT) { const { data, error } = useApiQuery( - `/users/${reqUser.id}/games/${game}/${playtype}/eamusement-csv`, + `/users/${reqUser.id}/games/${game}/eamusement-csv`, ); if (error) { diff --git a/typescript/client/src/components/gpt-utils/tools/IIDXPlaylistsTool.tsx b/typescript/client/src/components/gpt-utils/tools/IIDXPlaylistsTool.tsx index 8d59bc450..302b78198 100644 --- a/typescript/client/src/components/gpt-utils/tools/IIDXPlaylistsTool.tsx +++ b/typescript/client/src/components/gpt-utils/tools/IIDXPlaylistsTool.tsx @@ -9,7 +9,7 @@ import { ToAPIURL } from "#util/api"; import React from "react"; import { Col, Row } from "react-bootstrap"; -function Component({ game, playtype, reqUser }: UGPT) { +function Component({ game, reqUser }: UGPT) { const { data, error } = useApiQuery< Array<{ description: string; @@ -17,7 +17,7 @@ function Component({ game, playtype, reqUser }: UGPT) { playlistName: string; urlName: string; }> - >(`/games/${game}/${playtype}/playlists`); + >(`/games/${game}/playlists`); if (error) { return ; @@ -47,7 +47,7 @@ function Component({ game, playtype, reqUser }: UGPT) { e.forSpecificUser ? `/users/${reqUser.username}` : "" - }/games/iidx/${playtype}/playlists/${e.urlName}`, + }/games/${game}/playlists/${e.urlName}`, ), ), ), diff --git a/typescript/client/src/components/imports/CGIntegrationPage.tsx b/typescript/client/src/components/imports/CGIntegrationPage.tsx index 8e42aa5b5..92e93ec07 100644 --- a/typescript/client/src/components/imports/CGIntegrationPage.tsx +++ b/typescript/client/src/components/imports/CGIntegrationPage.tsx @@ -12,7 +12,7 @@ import { type SetState } from "#types/react"; import { APIFetchV1 } from "#util/api"; import React, { useContext, useMemo, useReducer, useState } from "react"; import { Button, Form } from "react-bootstrap"; -import { type APIImportTypes, GetGameGroupConfig, type MONGO_CGCardInfo } from "tachi-common"; +import { type APIImportTypes, type CGCardInfo, GetGameGroupConfig } from "tachi-common"; import ImportStateRenderer from "./ImportStateRenderer"; @@ -37,7 +37,7 @@ export default function CGIntegrationPage({ cgType, game }: Props) { } // eslint-disable-next-line react-hooks/rules-of-hooks - const { data, error } = useApiQuery( + const { data, error } = useApiQuery( `/users/${user.id}/integrations/cg/${cgType}`, undefined, [reload], diff --git a/typescript/client/src/components/imports/ImportInfo.tsx b/typescript/client/src/components/imports/ImportInfo.tsx index db89b4fc6..4aff011bd 100644 --- a/typescript/client/src/components/imports/ImportInfo.tsx +++ b/typescript/client/src/components/imports/ImportInfo.tsx @@ -16,24 +16,23 @@ import React, { useContext, useEffect, useMemo, useState } from "react"; import { Alert, ButtonGroup } from "react-bootstrap"; import { Link } from "react-router-dom"; import { - type GameGroup, - type MONGO_ChartDocument, - type MONGO_ImportDocument, - type MONGO_ScoreDocument, - type MONGO_SessionDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, - type MONGO_UserGameStats, - type Playtype, + type ChartDocument, + type ImportDocument, + type ScoreDocument, + type SessionDocument, + type SongDocument, + type UserDocument, + type UserGameStats, + type V3Game, } from "tachi-common"; interface Data { - import: MONGO_ImportDocument; - scores: MONGO_ScoreDocument[]; - charts: MONGO_ChartDocument[]; - songs: MONGO_SongDocument[]; - sessions: MONGO_SessionDocument[]; - user: MONGO_UserDocument; + import: ImportDocument; + scores: ScoreDocument[]; + charts: ChartDocument[]; + songs: SongDocument[]; + sessions: SessionDocument[]; + user: UserDocument; } export default function ImportInfo({ @@ -54,7 +53,7 @@ export default function ImportInfo({ return; } - APIFetchV1(`/users/${user!.id}/game-stats`).then((r) => { + APIFetchV1(`/users/${user!.id}/game-stats`).then((r) => { if (!r.success) { console.warn(`Can't update user stats post-import. ${r.description}`); return; @@ -160,7 +159,7 @@ function SessionTab({ data }: { data: Data }) { const dataset = []; - const sessionMap: Map = new Map(); + const sessionMap: Map = new Map(); for (const session of data.sessions) { sessionMap.set(session.sessionID, session); @@ -187,7 +186,7 @@ function SessionTab({ data }: { data: Data }) { {r.session.name} @@ -203,22 +202,22 @@ function SessionTab({ data }: { data: Data }) { function ScoreTab({ data }: { data: Data }) { const importDoc = data.import; - if (importDoc.playtypes.length === 0) { + if (importDoc.games.length === 0) { return (
    No scores...
    ); - } else if (importDoc.playtypes.length > 1) { - const datasets = []; + } else if (importDoc.games.length > 1) { + const datasets: { data: ScoreDataset; game: V3Game }[] = []; - for (const playtype of importDoc.playtypes) { + for (const game of importDoc.games) { const scoreDataset: ScoreDataset = []; const songMap = CreateSongMap(data.songs); const chartMap = CreateChartMap(data.charts); - for (const [i, score] of data.scores.filter((e) => e.playtype === playtype).entries()) { + for (const [i, score] of data.scores.filter((e) => e.game === game).entries()) { scoreDataset.push({ ...score, __related: { @@ -230,12 +229,14 @@ function ScoreTab({ data }: { data: Data }) { }); } - datasets.push({ playtype, data: scoreDataset }); + datasets.push({ game, data: scoreDataset }); } - return ; + return ; } + const game = importDoc.games[0]; + const scoreDataset: ScoreDataset = []; const songMap = CreateSongMap(data.songs); @@ -253,21 +254,17 @@ function ScoreTab({ data }: { data: Data }) { }); } - return ( - - ); + return ; } -type ScoreDatasets = { data: ScoreDataset; playtype: Playtype }[]; +function MultiPlaytypeScoreTable({ + datasets, +}: { + datasets: { data: ScoreDataset; game: V3Game }[]; +}) { + const [selectedGame, setSelectedGame] = useState(datasets[0].game); -function MultiPlaytypeScoreTable({ datasets, game }: { datasets: ScoreDatasets; game: GameGroup }) { - const [playtype, setPlaytype] = useState(datasets[0].playtype); - - const content = useMemo(() => datasets.find((e) => e.playtype === playtype)!, [playtype]); + const content = useMemo(() => datasets.find((e) => e.game === selectedGame)!, [selectedGame]); return (
    @@ -275,18 +272,18 @@ function MultiPlaytypeScoreTable({ datasets, game }: { datasets: ScoreDatasets;
    {datasets.map((e) => ( - {e.playtype} + {e.game} ))}
    - +
    ); } diff --git a/typescript/client/src/components/imports/MYTIntegrationPage.tsx b/typescript/client/src/components/imports/MYTIntegrationPage.tsx index 4b5b64270..eb71bea1f 100644 --- a/typescript/client/src/components/imports/MYTIntegrationPage.tsx +++ b/typescript/client/src/components/imports/MYTIntegrationPage.tsx @@ -12,7 +12,7 @@ import { type SetState } from "#types/react"; import { APIFetchV1 } from "#util/api"; import React, { useContext, useMemo, useReducer, useState } from "react"; import { Button, Form } from "react-bootstrap"; -import { type APIImportTypes, GetGameGroupConfig, type MONGO_MytCardInfo } from "tachi-common"; +import { type APIImportTypes, GetGameGroupConfig, type MytCardInfo } from "tachi-common"; import ImportStateRenderer from "./ImportStateRenderer"; @@ -35,7 +35,7 @@ export default function MytIntegrationPage({ game }: Props) { } // eslint-disable-next-line react-hooks/rules-of-hooks - const { data, error } = useApiQuery( + const { data, error } = useApiQuery( `/users/${user.id}/integrations/myt`, undefined, [reload], diff --git a/typescript/client/src/components/layout/header/GlobalInfoDropdown.tsx b/typescript/client/src/components/layout/header/GlobalInfoDropdown.tsx index 152036763..8c0313191 100644 --- a/typescript/client/src/components/layout/header/GlobalInfoDropdown.tsx +++ b/typescript/client/src/components/layout/header/GlobalInfoDropdown.tsx @@ -3,7 +3,7 @@ import QuickDropdown from "#components/ui/QuickDropdown"; import { TachiConfig } from "#lib/config"; import { type SetState } from "#types/react"; import React from "react"; -import { FormatGameGroup, GetGameGroupConfig } from "tachi-common"; +import { FormatGame, GetGameGroupConfig, LEGACY_GameGroupPTToGame } from "tachi-common"; export default function GlobalInfoDropdown({ className, @@ -18,7 +18,7 @@ export default function GlobalInfoDropdown({ }) { const links = []; - for (const game of TachiConfig.GAMES) { + for (const game of TachiConfig.GAME_GROUPS) { const gameConfig = GetGameGroupConfig(game); for (const playtype of gameConfig.playtypes) { @@ -26,9 +26,9 @@ export default function GlobalInfoDropdown({ setState?.(false)} - to={`/games/${game}/${playtype}`} + to={`/games/${LEGACY_GameGroupPTToGame(game, playtype)}`} > - {FormatGameGroup(game, playtype)} + {FormatGame(LEGACY_GameGroupPTToGame(game, playtype))} , ); } diff --git a/typescript/client/src/components/layout/header/HeaderMenu.tsx b/typescript/client/src/components/layout/header/HeaderMenu.tsx index b6309040b..085cbcd73 100644 --- a/typescript/client/src/components/layout/header/HeaderMenu.tsx +++ b/typescript/client/src/components/layout/header/HeaderMenu.tsx @@ -5,7 +5,7 @@ import { UserSettingsContext } from "#context/UserSettingsContext"; import { type SetState } from "#types/react"; import React, { useContext, useEffect } from "react"; import Nav from "react-bootstrap/Nav"; -import { type MONGO_UserGameStats } from "tachi-common"; +import { type UserGameStats } from "tachi-common"; import GlobalInfoDropdown from "./GlobalInfoDropdown"; import ImportScoresDropdown from "./ImportScoresDropdown"; @@ -26,7 +26,7 @@ export function HeaderMenu({ const { ugs, setUGS } = useContext(AllLUGPTStatsContext); const { settings } = useContext(UserSettingsContext); - const { data, error } = useApiQuery( + const { data, error } = useApiQuery( // We should generate a valid url just in case the skip somehow fails `/users/${user?.id ?? "me"}/game-stats`, undefined, diff --git a/typescript/client/src/components/layout/header/ImportScoresDropdown.tsx b/typescript/client/src/components/layout/header/ImportScoresDropdown.tsx index 0bed78c80..a60070a12 100644 --- a/typescript/client/src/components/layout/header/ImportScoresDropdown.tsx +++ b/typescript/client/src/components/layout/header/ImportScoresDropdown.tsx @@ -18,7 +18,7 @@ export default function ImportScoresLink({ }) { const links = []; - for (const game of TachiConfig.GAMES) { + for (const game of TachiConfig.GAME_GROUPS) { const gameConfig = GetGameGroupConfig(game); links.push( diff --git a/typescript/client/src/components/layout/header/UGPTDropdown.tsx b/typescript/client/src/components/layout/header/UGPTDropdown.tsx index 7b8b1eca4..76d9e2706 100644 --- a/typescript/client/src/components/layout/header/UGPTDropdown.tsx +++ b/typescript/client/src/components/layout/header/UGPTDropdown.tsx @@ -1,14 +1,8 @@ import DropdownNavLink from "#components/ui/DropdownNavLink"; import QuickDropdown from "#components/ui/QuickDropdown"; -import { TachiConfig } from "#lib/config"; import { type SetState } from "#types/react"; import React from "react"; -import { - FormatGameGroup, - GetGameGroupConfig, - type MONGO_UserDocument, - type MONGO_UserGameStats, -} from "tachi-common"; +import { ALL_GAMES, FormatGame, type UserDocument, type UserGameStats } from "tachi-common"; export default function UGPTDropdown({ user, @@ -22,37 +16,32 @@ export default function UGPTDropdown({ menuClassName?: string; setState?: SetState; style?: React.CSSProperties; - ugs: MONGO_UserGameStats[]; - user: MONGO_UserDocument; + ugs: UserGameStats[]; + user: UserDocument; }) { const userProfileLinks = []; if (user && ugs && ugs.length !== 0) { - const ugsMap = new Map(); - for (const s of ugs) { - ugsMap.set(`${s.game}:${s.playtype}`, s); - } + const ugsMap = new Map(ugs.map((s) => [s.game, s] as const)); - for (const game of TachiConfig.GAMES) { - for (const playtype of GetGameGroupConfig(game).playtypes) { - const e = ugsMap.get(`${game}:${playtype}`); + for (const game of ALL_GAMES) { + const e = ugsMap.get(game); - if (!e) { - continue; - } - - userProfileLinks.push( - { - setState?.(false); - }} - to={`/u/${user.username}/games/${e.game}/${e.playtype}`} - > - {FormatGameGroup(e.game, e.playtype)} - , - ); + if (!e) { + continue; } + + userProfileLinks.push( + { + setState?.(false); + }} + to={`/u/${user.username}/games/${e.game}`} + > + {FormatGame(e.game)} + , + ); } } diff --git a/typescript/client/src/components/layout/header/UserArea.tsx b/typescript/client/src/components/layout/header/UserArea.tsx index d3c90e1ea..341402576 100644 --- a/typescript/client/src/components/layout/header/UserArea.tsx +++ b/typescript/client/src/components/layout/header/UserArea.tsx @@ -1,7 +1,7 @@ import ProfilePicture from "#components/user/ProfilePicture"; import LinkButton from "#components/util/LinkButton"; import React from "react"; -import { type MONGO_UserDocument } from "tachi-common/types/documents"; +import { type UserDocument } from "tachi-common/types/documents"; import { SearchButton } from "./SearchButton"; import { UserNotificationButton } from "./UserNotificationButton"; @@ -12,7 +12,7 @@ export default function UserArea({ dropdownMenuStyle, }: { dropdownMenuStyle?: React.CSSProperties; - user: MONGO_UserDocument | null; + user: UserDocument | null; }) { return (
    diff --git a/typescript/client/src/components/layout/header/UserProfileDropdown.tsx b/typescript/client/src/components/layout/header/UserProfileDropdown.tsx index 2ccaebf46..e248bcc38 100644 --- a/typescript/client/src/components/layout/header/UserProfileDropdown.tsx +++ b/typescript/client/src/components/layout/header/UserProfileDropdown.tsx @@ -9,9 +9,9 @@ import { type TextColour } from "#types/bootstrap"; import { RFA } from "#util/misc"; import { heySplashes } from "#util/splashes"; import React, { useState } from "react"; -import { type MONGO_UserDocument, UserAuthLevels } from "tachi-common"; +import { UserAuthLevels, type UserDocument } from "tachi-common"; -function UserProfileDropdownToggle({ user }: { user: MONGO_UserDocument }) { +function UserProfileDropdownToggle({ user }: { user: UserDocument }) { const [heySplash] = useState(RFA(heySplashes)); return ( <> @@ -64,7 +64,7 @@ export function UserProfileDropdown({ style, }: { style?: React.CSSProperties; - user: MONGO_UserDocument; + user: UserDocument; }) { return ( ("/users/me").then((rj) => { + APIFetchV1("/users/me").then((rj) => { if (rj.success) { setUser(rj.body); } else { diff --git a/typescript/client/src/components/layout/subheader/SubHeader.tsx b/typescript/client/src/components/layout/subheader/SubHeader.tsx index 2dd1b87f2..105a14e68 100644 --- a/typescript/client/src/components/layout/subheader/SubHeader.tsx +++ b/typescript/client/src/components/layout/subheader/SubHeader.tsx @@ -1,7 +1,7 @@ +import { SubheaderContext } from "#context/SubheaderContext"; import React, { useContext } from "react"; import { Container } from "react-bootstrap"; -import { SubheaderContext } from "../../../context/SubheaderContext"; import { type LayoutStyles } from "../Layout"; import { Breadcrumbs } from "./components/Breadcrumbs"; diff --git a/typescript/client/src/components/notifications/NotificationRow.tsx b/typescript/client/src/components/notifications/NotificationRow.tsx index ab20cc9f0..5ecc741b1 100644 --- a/typescript/client/src/components/notifications/NotificationRow.tsx +++ b/typescript/client/src/components/notifications/NotificationRow.tsx @@ -2,9 +2,9 @@ import TimestampCell from "#components/tables/cells/TimestampCell"; import Icon from "#components/util/Icon"; import React from "react"; import { Link } from "react-router-dom"; -import { type MONGO_NotificationDocument } from "tachi-common"; +import { type NotificationDocument } from "tachi-common"; -export default function NotificationRow({ notif }: { notif: MONGO_NotificationDocument }) { +export default function NotificationRow({ notif }: { notif: NotificationDocument }) { const url = NotifToURL(notif); return ( @@ -32,15 +32,15 @@ export default function NotificationRow({ notif }: { notif: MONGO_NotificationDo ); } -function NotifToURL(notif: MONGO_NotificationDocument) { +function NotifToURL(notif: NotificationDocument) { switch (notif.body.type) { case "QUEST_CHANGED": { - const { game, playtype, questID } = notif.body.content; + const { game, questID } = notif.body.content; - return `/games/${game}/${playtype}/quests/${questID}`; + return `/games/${game}/quests/${questID}`; } case "RIVALED_BY": - return `/u/${notif.body.content.userID}/games/${notif.body.content.game}/${notif.body.content.playtype}`; + return `/u/${notif.body.content.userID}/games/${notif.body.content.game}`; case "SITE_ANNOUNCEMENT": return null; } diff --git a/typescript/client/src/components/seeds/SeedsPicker.tsx b/typescript/client/src/components/seeds/SeedsPicker.tsx index 8cb281126..b4d6b95b4 100644 --- a/typescript/client/src/components/seeds/SeedsPicker.tsx +++ b/typescript/client/src/components/seeds/SeedsPicker.tsx @@ -79,8 +79,6 @@ export default function SeedsPicker({ } else if (repo.startsWith("GitHub:")) { const repoName = repo.slice("GitHub:".length); - console.log(repoName); - const res: Array<{ commit: { sha: string }; name: string }> = await fetch( `https://api.github.com/repos/${repoName}/branches`, ).then((r) => r.json()); diff --git a/typescript/client/src/components/sessions/SessionCalendar.tsx b/typescript/client/src/components/sessions/SessionCalendar.tsx index 064958d44..0b0184a23 100644 --- a/typescript/client/src/components/sessions/SessionCalendar.tsx +++ b/typescript/client/src/components/sessions/SessionCalendar.tsx @@ -14,18 +14,13 @@ import FullCalendar from "@fullcalendar/react"; // must go before plugins import React from "react"; import { Row } from "react-bootstrap"; import { Link } from "react-router-dom"; -import { - COLOUR_SET, - FormatGameGroup, - type MONGO_SessionDocument, - type MONGO_UserDocument, -} from "tachi-common"; +import { COLOUR_SET, FormatGame, type SessionDocument, type UserDocument } from "tachi-common"; import SessionRaiseBreakdown from "./SessionRaiseBreakdown"; type MinSession = Pick< - MONGO_SessionDocument, - "desc" | "game" | "highlight" | "name" | "playtype" | "sessionID" | "timeEnded" | "timeStarted" + SessionDocument, + "desc" | "game" | "highlight" | "name" | "sessionID" | "timeEnded" | "timeStarted" >; export default function SessionCalendar({ @@ -35,7 +30,7 @@ export default function SessionCalendar({ }: { shouldDifferentiateGames?: boolean; url: string; - user: MONGO_UserDocument; + user: UserDocument; }) { const { data, error } = useApiQuery>(url); @@ -58,7 +53,6 @@ export default function SessionCalendar({ tooltipContent={ @@ -70,7 +64,7 @@ export default function SessionCalendar({ textOverflow: "ellipsis", wordWrap: "break-word", }} - to={`/u/${user.username}/games/${e.event.extendedProps.game}/${e.event.extendedProps.playtype}/sessions/${e.event.extendedProps.sessionID}`} + to={`/u/${user.username}/games/${e.event.extendedProps.game}/sessions/${e.event.extendedProps.sessionID}`} >

    - - {FormatGameGroup( - e.event.extendedProps.game, - e.event.extendedProps.playtype, - )} - + {FormatGame(e.event.extendedProps.game)} )}
    @@ -130,7 +119,7 @@ export default function SessionCalendar({ ); } -function SessionTooltip({ sessionID, game, playtype, reqUser }: { sessionID: string } & UGPT) { +function SessionTooltip({ sessionID, game, reqUser }: { sessionID: string } & UGPT) { const { data, error } = useApiQuery(`/sessions/${sessionID}`); if (error) { @@ -153,9 +142,7 @@ function SessionTooltip({ sessionID, game, playtype, reqUser }: { sessionID: str
    - + View Session
    @@ -174,7 +161,6 @@ function convertSession(min: MinSession): EventInput { highlight: min.highlight, sessionID: min.sessionID, game: min.game, - playtype: min.playtype, }, }; } diff --git a/typescript/client/src/components/sessions/SessionCard.tsx b/typescript/client/src/components/sessions/SessionCard.tsx index 124c4c594..892a3f1de 100644 --- a/typescript/client/src/components/sessions/SessionCard.tsx +++ b/typescript/client/src/components/sessions/SessionCard.tsx @@ -18,7 +18,7 @@ import React, { useContext, useMemo, useState } from "react"; import Button from "react-bootstrap/Button"; import Row from "react-bootstrap/Row"; import toast from "react-hot-toast"; -import { type MONGO_SessionDocument } from "tachi-common"; +import { type SessionDocument } from "tachi-common"; export default function SessionCard({ sessionID }: { sessionID: string }) { const { user } = useContext(UserContext); @@ -64,12 +64,7 @@ export default function SessionCard({ sessionID }: { sessionID: string }) { - + {sessionUser.id === user?.id && (
    @@ -107,7 +102,7 @@ export default function SessionCard({ sessionID }: { sessionID: string }) { View Session @@ -117,7 +112,7 @@ export default function SessionCard({ sessionID }: { sessionID: string }) { ); } -function SessionOverview({ session }: { session: MONGO_SessionDocument }) { +function SessionOverview({ session }: { session: SessionDocument }) { return ( diff --git a/typescript/client/src/components/sessions/SessionFolderRaiseBreakdown.tsx b/typescript/client/src/components/sessions/SessionFolderRaiseBreakdown.tsx index e437eb06e..012d9c659 100644 --- a/typescript/client/src/components/sessions/SessionFolderRaiseBreakdown.tsx +++ b/typescript/client/src/components/sessions/SessionFolderRaiseBreakdown.tsx @@ -20,17 +20,14 @@ import React, { useEffect, useMemo, useState } from "react"; import { Col, Row } from "react-bootstrap"; import { Link } from "react-router-dom"; import { + type ChartDocument, FormatChart, - type GameGroup, - type GamePTConfig, - GetGamePTConfig, - GetGPTString, + type GameConfig, + GetGameConfig, GetScoreMetricConf, - type GPTString, - type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, - type MONGO_TableDocument, + type SongDocument, + type TableDocument, + type V3Game, } from "tachi-common"; import { type ConfEnumScoreMetric } from "tachi-common/types/metrics"; @@ -43,19 +40,16 @@ export default function SessionFolderRaiseBreakdown({ const { settings } = useLUGPTSettings(); const game = sessionData.session.game; - const playtype = sessionData.session.playtype; - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - const [selectedTable, setSelectedTable] = useState<"LOADING" | MONGO_TableDocument | null>( - "LOADING", - ); + const [selectedTable, setSelectedTable] = useState<"LOADING" | TableDocument | null>("LOADING"); const { data, error } = useApiQuery>( `/sessions/${sessionData.session.sessionID}/folder-raises`, ); - const { data: tableData, error: tableError } = useApiQuery>( - `/games/${game}/${playtype}/tables`, + const { data: tableData, error: tableError } = useApiQuery>( + `/games/${game}/tables`, ); useEffect(() => { @@ -95,7 +89,7 @@ export default function SessionFolderRaiseBreakdown({ // hide certain folders if (selectedTable && selectedTable !== "LOADING") { - folders = folders.filter((e) => selectedTable.folders.includes(e.folderID)); + folders = folders.filter((e) => selectedTable.folders.includes(e.slug)); } return folders; @@ -121,7 +115,7 @@ export default function SessionFolderRaiseBreakdown({ const songMap = CreateSongMap(sessionData.songs); const preferredScoreBucket = - settings?.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum; + settings?.preferences.preferredDefaultEnum ?? gameConfig.preferredDefaultEnum; if (selectedTable === "LOADING") { return ; @@ -132,7 +126,7 @@ export default function SessionFolderRaiseBreakdown({ const filteredTables = tableData // disgusting filter: check if this table has any overlap with any // relevant folders. If not, don't show it. - .filter((t) => allFolders.find((e) => t.folders.includes(e.folderID))); + .filter((t) => allFolders.find((e) => t.folders.includes(e.slug))); if (filteredTables.length === 0) { return null; @@ -167,7 +161,7 @@ export default function SessionFolderRaiseBreakdown({

    {folder.title} @@ -177,13 +171,12 @@ export default function SessionFolderRaiseBreakdown({ {data .filter((e) => e.folder.folderID === folder.folderID) - .sort(SortRaisesNicely(gptConfig, preferredScoreBucket)) + .sort(SortRaisesNicely(gameConfig, preferredScoreBucket)) .map((folderRaiseInfo, i) => ( @@ -197,9 +190,9 @@ export default function SessionFolderRaiseBreakdown({ ); } -const SortRaisesNicely = (gptConfig: GamePTConfig, preferredEnum: string) => +const SortRaisesNicely = (gameConfig: GameConfig, preferredEnum: string) => NumericSOV((x) => { - const conf = GetScoreMetricConf(gptConfig, x.type) as ConfEnumScoreMetric; + const conf = GetScoreMetricConf(gameConfig, x.type) as ConfEnumScoreMetric; const baseValue = conf.values.indexOf(x.value); @@ -216,19 +209,15 @@ function FolderRaiseRender({ game, songMap, chartMap, - gptString, }: { - chartMap: Map; + chartMap: Map; folderRaiseInfo: SessionFolderRaises; - game: GameGroup; - gptString: GPTString; - songMap: Map; + game: V3Game; + songMap: Map; }) { const colour = // @ts-expect-error lazy - GPT_CLIENT_IMPLEMENTATIONS[gptString].enumColours[folderRaiseInfo.type][ - folderRaiseInfo.value - ]; + GPT_CLIENT_IMPLEMENTATIONS[game].enumColours[folderRaiseInfo.type][folderRaiseInfo.value]; const newTotal = folderRaiseInfo.previousCount + folderRaiseInfo.raisedCharts.length; @@ -290,10 +279,10 @@ function ChartRaises({ game, raisedCharts, }: { - chartMap: Map; - game: GameGroup; + chartMap: Map; + game: V3Game; raisedCharts: Array; - songMap: Map; + songMap: Map; }) { const els = []; @@ -304,17 +293,17 @@ function ChartRaises({ console.warn(`No chart '${chartID}' exists? continuing.`); continue; } - const song = songMap.get(chart.songID); + const song = songMap.get(chart.song.id); if (!song) { - console.warn(`No song '${chart.songID}' exists, but the chart does?`); + console.warn(`No song '${chart.song.id}' exists, but the chart does?`); continue; } els.push( - - + {FormatChart(game, song, chart, true)} + + + {FormatChart(chart)} , ); diff --git a/typescript/client/src/components/sessions/SessionOverview.tsx b/typescript/client/src/components/sessions/SessionOverview.tsx index 7d542e571..23b98696d 100644 --- a/typescript/client/src/components/sessions/SessionOverview.tsx +++ b/typescript/client/src/components/sessions/SessionOverview.tsx @@ -16,18 +16,17 @@ import { Badge, Col, Row } from "react-bootstrap"; import { Route, Switch } from "react-router-dom"; import { type AnySessionRatingAlg, - GetGamePTConfig, - GetGPTString, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, - type MONGO_SessionDocument, - type MONGO_UserDocument, + GetGameConfig, + type PBScoreDocument, + type ScoreDocument, + type SessionDocument, + type UserDocument, } from "tachi-common"; import SessionFolderRaiseBreakdown from "./SessionFolderRaiseBreakdown"; import SessionRaiseBreakdown from "./SessionRaiseBreakdown"; -type PBsData = { pbs: Array }; +type PBsData = { pbs: Array }; export default function SessionOverview({ sessionData, @@ -35,17 +34,18 @@ export default function SessionOverview({ scoreDataset, reqUser, }: { - reqUser: MONGO_UserDocument; + reqUser: UserDocument; scoreDataset: ScoreDataset; sessionData: SessionReturns; setSessionData: SetState; }) { const { scores, session } = sessionData; - const gptConfig = GetGamePTConfig(session.game, session.playtype); - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(session.game, session.playtype)]; + const game = session.game; + const gameConfig = GetGameConfig(game); + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; const MAX_SCORES = gptImpl.sessionImportantScoreCount; - const setScores = (scores: MONGO_ScoreDocument[]) => { + const setScores = (scores: ScoreDocument[]) => { setSessionData({ ...sessionData, scores, @@ -54,14 +54,13 @@ export default function SessionOverview({ const [importantScores, setImportantScores] = useState(null); - const scoreRatingAlgsKeys = Object.keys(gptConfig.scoreRatingAlgs); + const scoreRatingAlgsKeys = Object.keys(gameConfig.scoreRatingAlgs); const query = scoreRatingAlgsKeys.length === 1 - ? `/users/${reqUser.id}/games/${session.game}/${session.playtype}/pbs/best?alg=${scoreRatingAlgsKeys[0]}` + ? `/users/${reqUser.id}/games/${game}/pbs/best?alg=${scoreRatingAlgsKeys[0]}` : scoreRatingAlgsKeys.map( - (alg) => - `/users/${reqUser.id}/games/${session.game}/${session.playtype}/pbs/best?alg=${alg}`, + (alg) => `/users/${reqUser.id}/games/${game}/pbs/best?alg=${alg}`, ); const { data } = useApiQuery(query); @@ -95,7 +94,7 @@ export default function SessionOverview({ setImportantScores(scoreDataset.filter((e) => important.includes(e.scoreID))); }, [data]); - const base = useUGPTBase({ game: session.game, playtype: session.playtype, reqUser }); + const base = useUGPTBase({ game, reqUser }); const baseUrl = `${base}/sessions/${session.sessionID}`; const highlightedScores = scoreDataset.filter((e) => e.highlight); @@ -148,7 +147,7 @@ export default function SessionOverview({ - + - + - + {importantScores && importantScores.length > 0 && ( - + )} {importantScores && importantScores?.length > 0 && highlightedScores.length > 0 && } - + - + { const newScores = [ ...sessionData.scores.filter((e) => e.scoreID !== sc.scoreID), @@ -206,7 +191,6 @@ export default function SessionOverview({ scores: newScores, }); }} - playtype={session.playtype} /> @@ -217,8 +201,9 @@ export default function SessionOverview({ // Temporarily shoved to the bottom, as it needs to be significantly improved, // but we can't really just remove it lol. -function RatingsOverview({ session }: { session: MONGO_SessionDocument }) { - const gptConfig = GetGamePTConfig(session.game, session.playtype); +function RatingsOverview({ session }: { session: SessionDocument }) { + const game = session.game; + const gameConfig = GetGameConfig(game); function Thing({ value, name }: { name: string; value: number | string }) { return ( @@ -235,17 +220,12 @@ function RatingsOverview({ session }: { session: MONGO_SessionDocument }) { return (
    - {Object.keys(gptConfig.sessionRatingAlgs).map((e) => ( + {Object.keys(gameConfig.sessionRatingAlgs).map((e) => ( void; +type SetScores = (scores: ScoreDocument[]) => void; export default function SessionRaiseBreakdown({ sessionData, @@ -51,13 +48,12 @@ export default function SessionRaiseBreakdown({ setScores?: SetScores; }) { const game = sessionData.session.game; - const playtype = sessionData.session.playtype; const lampName = game === "ongeki" || game === "chunithm" ? "noteLamp" : "lamp"; const { user } = useContext(UserContext); - const { data, error } = useQuery(`/games/${game}/${playtype}/tables`, async () => { - const res = await APIFetchV1(`/games/${game}/${playtype}/tables`); + const { data, error } = useQuery(`/games/${game}/tables`, async () => { + const res = await APIFetchV1(`/games/${game}/tables`); if (!res.success) { throw new Error(res.description); @@ -126,11 +122,12 @@ function SessionScoreStatBreakdown({ const songMap = CreateSongMap(sessionData.songs); const chartMap = CreateChartMap(sessionData.charts); const scoreMap = CreateScoreIDMap(sessionData.scores); - const gptConfig = GetGamePTConfig(sessionData.session.game, sessionData.session.playtype); + const game = sessionData.session.game; + const gameConfig = GetGameConfig(game); - const enumMetrics = GetScoreMetrics(gptConfig, "ENUM"); + const enumMetrics = GetScoreMetrics(gameConfig, "ENUM"); - type Datapoint = { score: MONGO_ScoreDocument; scoreInfo: SessionScoreInfo }; + type Datapoint = { score: ScoreDocument; scoreInfo: SessionScoreInfo }; const newEnums = useMemo(() => { const newEnums: Record>> = {}; @@ -186,10 +183,7 @@ function SessionScoreStatBreakdown({ return newEnums; }, [view]); - const gptImpl = - GPT_CLIENT_IMPLEMENTATIONS[ - GetGPTString(sessionData.session.game, sessionData.session.playtype) - ]; + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; return ( <> @@ -212,8 +206,8 @@ function SessionScoreStatBreakdown({ >; - counts: Record; + chartMap: Map>; + counts: Record; fullSize?: boolean; - game: GameGroup; - gptConfig: GamePTConfig; + game: V3Game; + gameConfig: GameConfig; gptImpl: GPTClientImplementation; metric: string; - scores: MONGO_ScoreDocument[]; + scores: ScoreDocument[]; setScores?: SetScores; - songMap: Map>; + songMap: Map; }) { const tableContents = useMemo(() => { - const conf = GetScoreMetricConf(gptConfig, metric) as ConfEnumScoreMetric; + const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric; // relements.. haha const relevantElements = conf.values.slice(conf.values.indexOf(conf.minimumRelevantValue)); @@ -305,7 +299,7 @@ function ElementStatTable({ songMap, fullSize, game, - gptConfig, + gameConfig, metric: metric, scores, setScores, @@ -324,7 +318,7 @@ function ElementStatTable({ songMap, fullSize, game, - gptConfig, + gameConfig, metric: metric, scores, setScores, @@ -356,31 +350,29 @@ function BreakdownChartContents({ songMap, chartMap, fullSize, - gptConfig, + gameConfig, metric, scores, setScores, }: { - chartMap: Map; + chartMap: Map>; fullSize: boolean; - game: GameGroup; - gptConfig: GamePTConfig; + game: V3Game; + gameConfig: GameConfig; metric: string; - score: MONGO_ScoreDocument; + score: ScoreDocument; scoreInfo: SessionScoreInfo; - scores: Array; + scores: Array; setScores?: SetScores; - songMap: Map; + songMap: Map; }) { const modifyScore = useMemo( () => ( { highlight, comment }: { comment?: string | null; highlight?: boolean }, - scores: MONGO_ScoreDocument[], + scores: ScoreDocument[], setScores: SetScores, ) => { - console.log(scores.filter((e) => e.highlight).length); - const scoreID = score.scoreID; ModifyScore(scoreID, { highlight, comment }).then((r) => { @@ -430,7 +422,7 @@ function BreakdownChartContents({ } if (fullSize) { - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(score.game, score.playtype)]; + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[score.game]; let preScoreCell = No Play; @@ -441,7 +433,7 @@ function BreakdownChartContents({ // @ts-expect-error it'll be an enum if (typeof score.scoreData[k] === "string") { const enumConf = GetScoreMetricConf( - gptConfig, + gameConfig, k, ) as ConfEnumScoreMetric; @@ -460,7 +452,7 @@ function BreakdownChartContents({ const mockScore = deepmerge(score, { scoreData: newScoreData, - }) as MONGO_ScoreDocument; + }) as ScoreDocument; // We don't actually know what the user's previous score was, we can only walk // back the raise information we have. As such, we don't keep track of diff --git a/typescript/client/src/components/tables/cells/ArcaeaJudgementCell.tsx b/typescript/client/src/components/tables/cells/ArcaeaJudgementCell.tsx index 19af81286..4180c1c40 100644 --- a/typescript/client/src/components/tables/cells/ArcaeaJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/ArcaeaJudgementCell.tsx @@ -1,11 +1,11 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function ArcaeaJudgementCell({ score, }: { - score: MONGO_PBScoreDocument<"arcaea:Touch"> | MONGO_ScoreDocument<"arcaea:Touch">; + score: PBScoreDocument<"arcaea"> | ScoreDocument<"arcaea">; }) { // even if we dont have judgement data, we know what they got. if (score.scoreData.lamp === "PURE MEMORY") { diff --git a/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx b/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx index 0c3f9e9ea..01b4469b8 100644 --- a/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx +++ b/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx @@ -7,28 +7,31 @@ import { FormatTables } from "#util/misc"; import React from "react"; import { BMS_TABLES, + type ChartDocument, COLOUR_SET, - type MONGO_ChartDocument, - type MONGO_UGPTSettingsDocument, + type UGPTSettingsDocument, } from "tachi-common"; import RatingSystemPart from "./RatingSystemPart"; +type BMSGames = "bms-7k" | "bms-14k" | "pms-controller" | "pms-keyboard"; + export default function BMSOrPMSDifficultyCell({ chart, game, }: { - chart: MONGO_ChartDocument<"bms:7K" | "bms:14K" | "pms:Controller" | "pms:Keyboard">; - game: "bms" | "pms"; + chart: ChartDocument; + game: BMSGames; }) { const { settings } = useLUGPTSettings() as { - settings: MONGO_UGPTSettingsDocument<"bms:7K" | "bms:14K">; + settings: UGPTSettingsDocument<"bms-7k" | "bms-14k"> | null; }; - const hasLevel = Object.keys(chart.data.tableFolders).length > 0; + const hasLevel = Object.keys((chart as ChartDocument<"bms-7k">).data.tableFolders).length > 0; const aiLevel = - game === "bms" && (chart as MONGO_ChartDocument<"bms:7K" | "bms:14K">).data.aiLevel; + (game === "bms-7k" || game === "bms-14k") && + (chart as ChartDocument<"bms-7k" | "bms-14k">).data.aiLevel; let levelText = "No Rating"; let backgroundColour = hasLevel ? COLOUR_SET.red : COLOUR_SET.gray; @@ -36,40 +39,37 @@ export default function BMSOrPMSDifficultyCell({ let filteredTables: Record = {}; if (hasLevel) { - let tables = chart.data.tableFolders; + let tables = (chart as ChartDocument<"bms-7k">).data.tableFolders; - if (settings?.preferences.gameSpecific.displayTables) { + const gameSpecific = settings?.preferences.gameSpecific as any; + + if (gameSpecific?.displayTables) { filteredTables = Object.entries(tables) - .filter( - ([table, _]) => - !settings.preferences.gameSpecific.displayTables!.includes(table), - ) + .filter(([table, _]) => !gameSpecific.displayTables.includes(table)) .reduce( (acc, [table, level]) => { - acc[table] = level; + acc[table] = level as string; return acc; }, {} as Record, ); tables = Object.entries(tables) - .filter(([table, _]) => - settings.preferences.gameSpecific.displayTables!.includes(table), - ) + .filter(([table, _]) => gameSpecific.displayTables.includes(table)) .reduce( (acc, [table, level]) => { - acc[table] = level; + acc[table] = level as string; return acc; }, {} as Record, ); } - levelText = FormatTables(tables); + levelText = FormatTables(tables as Record); - if (game === "bms") { + if (game === "bms-7k" || game === "bms-14k") { // use bms table colour instead of just red/gray. // todo: add pms table colours maybe? - backgroundColour = FindTableColour(tables); + backgroundColour = FindTableColour(tables as Record); } } @@ -112,9 +112,7 @@ export default function BMSOrPMSDifficultyCell({ ); } -function FindTableColour( - tableFolders: MONGO_ChartDocument<"bms:7K" | "bms:14K">["data"]["tableFolders"], -) { +function FindTableColour(tableFolders: Record) { const lookup = new Map(BMS_TABLES.map((e) => [e.prefix, e.colour])); for (const table of Object.keys(tableFolders)) { diff --git a/typescript/client/src/components/tables/cells/BMSOrPMSLampCell.tsx b/typescript/client/src/components/tables/cells/BMSOrPMSLampCell.tsx index e5d6468b2..0aa6f2309 100644 --- a/typescript/client/src/components/tables/cells/BMSOrPMSLampCell.tsx +++ b/typescript/client/src/components/tables/cells/BMSOrPMSLampCell.tsx @@ -2,14 +2,14 @@ import { GetEnumColour } from "#lib/game-implementations"; import { ChangeOpacity } from "#util/color-opacity"; import { IsNotNullish } from "#util/misc"; import React from "react"; -import { type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { type PBScoreDocument, type ScoreDocument } from "tachi-common"; + +type BMSGames = "bms-7k" | "bms-14k" | "pms-controller" | "pms-keyboard"; export default function BMSOrPMSLampCell({ score, }: { - score: - | MONGO_PBScoreDocument<"bms:7K" | "bms:14K" | "pms:Controller" | "pms:Keyboard"> - | MONGO_ScoreDocument<"bms:7K" | "bms:14K" | "pms:Controller" | "pms:Keyboard">; + score: PBScoreDocument | ScoreDocument; }) { return ( ; - score: - | MONGO_PBScoreDocument<"iidx:DP" | "iidx:SP"> - | MONGO_ScoreDocument<"iidx:DP" | "iidx:SP">; + chart: ChartDocument; + score: PBScoreDocument | ScoreDocument; }) { const { user } = useContext(UserContext); - const { settings } = useLUGPTSettings<"iidx:DP" | "iidx:SP">(); + const { settings } = useLUGPTSettings(); const bpi = score.calculatedData.BPI; const { kaidenAverage, worldRecord, notecount, bpiCoefficient } = chart.data; @@ -61,21 +60,23 @@ export default function BPICell({ const kavgDelta = score.scoreData.score - kaidenAverage!; + const iidxPlaytype: "DP" | "SP" = chart.game === "iidx-sp" ? "SP" : "DP"; + const { score: WRAverageCell, delta: WRDeltaCell } = FormatAverage( worldRecord!, - chart.playtype, + iidxPlaytype, chart.data.notecount, ); const { score: KDAverageCell, delta: KDDeltaCell } = FormatAverage( kaidenAverage!, - chart.playtype, + iidxPlaytype, chart.data.notecount, ); const { score: TGAverageCell, delta: TGDeltaCell } = FormatAverage( bpiTargetScore, - chart.playtype, + iidxPlaytype, chart.data.notecount, ); @@ -148,10 +149,12 @@ function FormatAverage(score: integer, playtype: "DP" | "SP", notecount: integer const grade = GetGrade(IIDXLIKE_GBOUNDARIES, percent); + const iidxGame = playtype === "SP" ? "iidx-sp" : "iidx-dp"; + return { score: ( | MONGO_ScoreDocument<"chunithm:Single">; + score: PBScoreDocument<"chunithm"> | ScoreDocument<"chunithm">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/cells/CHUNITHMLampCell.tsx b/typescript/client/src/components/tables/cells/CHUNITHMLampCell.tsx index 87d05eba9..a2c35ea1e 100644 --- a/typescript/client/src/components/tables/cells/CHUNITHMLampCell.tsx +++ b/typescript/client/src/components/tables/cells/CHUNITHMLampCell.tsx @@ -8,9 +8,9 @@ export default function CHUNITHMLampCell({ noteLampColour, clearLampColour, }: { - clearLamp: GetEnumValue<"chunithm:Single", "clearLamp">; + clearLamp: GetEnumValue<"chunithm", "clearLamp">; clearLampColour: string; - noteLamp: GetEnumValue<"chunithm:Single", "noteLamp">; + noteLamp: GetEnumValue<"chunithm", "noteLamp">; noteLampColour: string; }) { let content =
    {clearLamp}
    ; diff --git a/typescript/client/src/components/tables/cells/DifficultyCell.tsx b/typescript/client/src/components/tables/cells/DifficultyCell.tsx index 1084243a4..bd33c7b52 100644 --- a/typescript/client/src/components/tables/cells/DifficultyCell.tsx +++ b/typescript/client/src/components/tables/cells/DifficultyCell.tsx @@ -5,12 +5,10 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; import { ChangeOpacity } from "#util/color-opacity"; import React from "react"; import { + type ChartDocument, FormatDifficulty, FormatDifficultyShort, - type GameGroup, - GetGamePTConfig, - GetGPTString, - type MONGO_ChartDocument, + type V3Game, } from "tachi-common"; import BMSOrPMSDifficultyCell from "./BMSOrPMSDifficultyCell"; @@ -18,6 +16,8 @@ import ITGDifficultyCell from "./ITGDifficultyCell"; import RatingSystemPart from "./RatingSystemPart"; import USCDifficultyCell from "./USCDifficultyCell"; +type BMSGames = "bms-7k" | "bms-14k" | "pms-controller" | "pms-keyboard"; + export default function DifficultyCell({ game, chart, @@ -25,44 +25,28 @@ export default function DifficultyCell({ noTierlist, }: { alwaysShort?: boolean; - chart: MONGO_ChartDocument; - game: GameGroup; + chart: ChartDocument; + game: V3Game; noTierlist?: boolean; }) { - const gptConfig = GetGamePTConfig(game, chart.playtype); - - if (!gptConfig) { - throw new Error(`Was passed nonsense combination of ${game}, ${chart.playtype}`); + if ( + game === "bms-7k" || + game === "bms-14k" || + game === "pms-controller" || + game === "pms-keyboard" + ) { + return } game={game} />; + } else if (game === "usc-controller" || game === "usc-keyboard") { + return ( + } /> + ); + } else if (game === "itg-stamina") { + return } />; } - if (game === "bms" || game === "pms") { - return ( - - } - game={game} - /> - ); - } else if (game === "usc") { - return ( - } - /> - ); - } else if (game === "itg") { - return } />; - } - - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, chart.playtype)]; - - if (game === "iidx" || game === "maimaidx") { - // IIDX stuff should always be in the form SPA/SPL to save space. - // For the same reason, maimai DX stuff should be using (DX) EXP/MAS/Re:MAS. - // All players know what this means. + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; + if (game === "iidx-sp" || game === "iidx-dp" || game === "maimaidx") { alwaysShort = true; } @@ -75,12 +59,8 @@ export default function DifficultyCell({ maxWidth: "100px", }} > - {!alwaysShort && ( -
    {FormatDifficulty(chart, game)}
    - )} -
    - {FormatDifficultyShort(chart, game)} -
    + {!alwaysShort &&
    {FormatDifficulty(chart)}
    } +
    {FormatDifficultyShort(chart)}
    {!noTierlist && gptImpl.ratingSystems.length > 0 && ( @@ -102,12 +82,11 @@ export function DisplayLevelNum({ prefix, game, }: { - game: GameGroup; + game: V3Game; level: string; levelNum: number; prefix?: string; }) { - // Always display 1dp levelNum for these games if (["chunithm", "maimai", "maimaidx", "ongeki", "wacca"].includes(game)) { return ( @@ -117,14 +96,11 @@ export function DisplayLevelNum({ ); } - // Don't display levelnum if its identical to the level, the decimal places in the - // level end with .0, or the levelNum itself is 0. if (levelNum.toString() === level || level.endsWith(".0") || levelNum === 0) { return null; } - // or if it's gitadora - if (game === "gitadora") { + if (game === "gitadora-gita" || game === "gitadora-dora") { return null; } diff --git a/typescript/client/src/components/tables/cells/GitadoraJudgementCell.tsx b/typescript/client/src/components/tables/cells/GitadoraJudgementCell.tsx index 92d941fea..38ae47dd7 100644 --- a/typescript/client/src/components/tables/cells/GitadoraJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/GitadoraJudgementCell.tsx @@ -1,13 +1,13 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function GitadoraJudgementCell({ score, }: { score: - | MONGO_PBScoreDocument<"gitadora:Dora" | "gitadora:Gita"> - | MONGO_ScoreDocument<"gitadora:Dora" | "gitadora:Gita">; + | PBScoreDocument<"gitadora-dora" | "gitadora-gita"> + | ScoreDocument<"gitadora-dora" | "gitadora-gita">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/cells/IIDXLampCell.tsx b/typescript/client/src/components/tables/cells/IIDXLampCell.tsx index 48cae6ccb..ea0c4d2e0 100644 --- a/typescript/client/src/components/tables/cells/IIDXLampCell.tsx +++ b/typescript/client/src/components/tables/cells/IIDXLampCell.tsx @@ -4,17 +4,18 @@ import { ChangeOpacity } from "#util/color-opacity"; import { IsNotNullish } from "#util/misc"; import React from "react"; import { - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, + type ChartDocument, + type GamesForGroup, + type PBScoreDocument, + type ScoreDocument, } from "tachi-common"; export default function IIDXLampCell({ sc, chart, }: { - chart: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">; - sc: MONGO_PBScoreDocument<"iidx:DP" | "iidx:SP"> | MONGO_ScoreDocument<"iidx:DP" | "iidx:SP">; + chart: ChartDocument; + sc: PBScoreDocument | ScoreDocument; }) { let gaugeText = null; diff --git a/typescript/client/src/components/tables/cells/ITGDifficultyCell.tsx b/typescript/client/src/components/tables/cells/ITGDifficultyCell.tsx index a3f1300ff..81c1b2834 100644 --- a/typescript/client/src/components/tables/cells/ITGDifficultyCell.tsx +++ b/typescript/client/src/components/tables/cells/ITGDifficultyCell.tsx @@ -3,7 +3,7 @@ import Divider from "#components/util/Divider"; import Muted from "#components/util/Muted"; import { ChangeOpacity } from "#util/color-opacity"; import React from "react"; -import { COLOUR_SET, type MONGO_ChartDocument } from "tachi-common"; +import { type ChartDocument, COLOUR_SET } from "tachi-common"; import MiniTable from "../components/MiniTable"; @@ -16,11 +16,7 @@ const COLOUR_LOOKUP = { Edit: COLOUR_SET.gray, }; -export default function ITGDifficultyCell({ - chart, -}: { - chart: MONGO_ChartDocument<"itg:Stamina">; -}) { +export default function ITGDifficultyCell({ chart }: { chart: ChartDocument<"itg-stamina"> }) { let diff; if (chart.data.rankedLevel === null) { diff --git a/typescript/client/src/components/tables/cells/ITGJudgementCell.tsx b/typescript/client/src/components/tables/cells/ITGJudgementCell.tsx index 42a54124c..936041ec9 100644 --- a/typescript/client/src/components/tables/cells/ITGJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/ITGJudgementCell.tsx @@ -1,11 +1,11 @@ import { IsNotNullish, IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function ITGJudgementCell({ score, }: { - score: MONGO_PBScoreDocument<"itg:Stamina"> | MONGO_ScoreDocument<"itg:Stamina">; + score: PBScoreDocument<"itg-stamina"> | ScoreDocument<"itg-stamina">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/cells/JubeatJudgementCell.tsx b/typescript/client/src/components/tables/cells/JubeatJudgementCell.tsx index cb5fbc63e..117c6f533 100644 --- a/typescript/client/src/components/tables/cells/JubeatJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/JubeatJudgementCell.tsx @@ -1,11 +1,11 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function JubeatJudgementCell({ score, }: { - score: MONGO_PBScoreDocument<"jubeat:Single"> | MONGO_ScoreDocument<"jubeat:Single">; + score: PBScoreDocument<"jubeat"> | ScoreDocument<"jubeat">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/cells/JubeatScoreCell.tsx b/typescript/client/src/components/tables/cells/JubeatScoreCell.tsx index afe5d8517..4a4c4694a 100644 --- a/typescript/client/src/components/tables/cells/JubeatScoreCell.tsx +++ b/typescript/client/src/components/tables/cells/JubeatScoreCell.tsx @@ -2,20 +2,18 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; import { ChangeOpacity } from "#util/color-opacity"; import { FormatMillions, ToFixedFloor } from "#util/misc"; import React from "react"; -import { type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function JubeatScoreCell({ sc, }: { - sc: MONGO_PBScoreDocument<"jubeat:Single"> | MONGO_ScoreDocument<"jubeat:Single">; + sc: PBScoreDocument<"jubeat"> | ScoreDocument<"jubeat">; }) { return ( | MONGO_ScoreDocument<"maimaidx:Single">; + score: PBScoreDocument<"maimaidx"> | ScoreDocument<"maimaidx">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/cells/MaimaiJudgementCell.tsx b/typescript/client/src/components/tables/cells/MaimaiJudgementCell.tsx index cd80d618a..74efb8bb1 100644 --- a/typescript/client/src/components/tables/cells/MaimaiJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/MaimaiJudgementCell.tsx @@ -1,11 +1,11 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function MaimaiJudgementCell({ score, }: { - score: MONGO_PBScoreDocument<"maimai:Single"> | MONGO_ScoreDocument<"maimai:Single">; + score: PBScoreDocument<"maimai"> | ScoreDocument<"maimai">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/cells/MusecaJudgementCell.tsx b/typescript/client/src/components/tables/cells/MusecaJudgementCell.tsx index 94f36d547..a1087230f 100644 --- a/typescript/client/src/components/tables/cells/MusecaJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/MusecaJudgementCell.tsx @@ -1,11 +1,11 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function MusecaJudgementCell({ score, }: { - score: MONGO_PBScoreDocument<"museca:Single"> | MONGO_ScoreDocument<"museca:Single">; + score: PBScoreDocument<"museca"> | ScoreDocument<"museca">; }) { // even if we dont have judgement data, we know what they got. if (score.scoreData.lamp === "PERFECT CONNECT ALL") { diff --git a/typescript/client/src/components/tables/cells/OngekiJudgementCell.tsx b/typescript/client/src/components/tables/cells/OngekiJudgementCell.tsx index fc5dcb27a..04f2a7b0f 100644 --- a/typescript/client/src/components/tables/cells/OngekiJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/OngekiJudgementCell.tsx @@ -1,11 +1,11 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function OngekiJudgementCell({ score, }: { - score: MONGO_PBScoreDocument<"ongeki:Single"> | MONGO_ScoreDocument<"ongeki:Single">; + score: PBScoreDocument<"ongeki"> | ScoreDocument<"ongeki">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/cells/OngekiScoreRatingCell.tsx b/typescript/client/src/components/tables/cells/OngekiScoreRatingCell.tsx index 2b47b31bc..f377e5a0a 100644 --- a/typescript/client/src/components/tables/cells/OngekiScoreRatingCell.tsx +++ b/typescript/client/src/components/tables/cells/OngekiScoreRatingCell.tsx @@ -3,7 +3,7 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; import { ChangeOpacity } from "#util/color-opacity"; import { FormatMillions, FormatScoreRating } from "#util/misc"; import React from "react"; -import { type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { type PBScoreDocument, type ScoreDocument } from "tachi-common"; import MiniTable from "../components/MiniTable"; import LampCell from "./LampCell"; @@ -11,7 +11,7 @@ import LampCell from "./LampCell"; export default function OngekiScoreRatingCell({ score, }: { - score: MONGO_PBScoreDocument<"ongeki:Single"> | MONGO_ScoreDocument<"ongeki:Single">; + score: PBScoreDocument<"ongeki"> | ScoreDocument<"ongeki">; }) { const ratingValue = score.calculatedData.scoreRating ?? 0; const noteLamp = score.scoreData.noteLamp; @@ -19,7 +19,7 @@ export default function OngekiScoreRatingCell({ const grade = score.scoreData.grade; const gradeColor = ChangeOpacity( - GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].enumColours.grade[score.scoreData.grade], + GPT_CLIENT_IMPLEMENTATIONS.ongeki.enumColours.grade[score.scoreData.grade], 0.2, ); @@ -54,8 +54,9 @@ export default function OngekiScoreRatingCell({ @@ -64,8 +65,9 @@ export default function OngekiScoreRatingCell({ @@ -88,7 +90,7 @@ export default function OngekiScoreRatingCell({ >
    - {FormatScoreRating(score.game, score.playtype, "scoreRating", ratingValue)} + {FormatScoreRating("ongeki", "scoreRating", ratingValue)}
    diff --git a/typescript/client/src/components/tables/cells/PBCompareCell.tsx b/typescript/client/src/components/tables/cells/PBCompareCell.tsx index e6a7d34d1..7d3fe5c15 100644 --- a/typescript/client/src/components/tables/cells/PBCompareCell.tsx +++ b/typescript/client/src/components/tables/cells/PBCompareCell.tsx @@ -1,10 +1,5 @@ import React from "react"; -import { - COLOUR_SET, - type GameGroup, - type MONGO_PBScoreDocument, - type Playtype, -} from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument } from "tachi-common"; import { type ConfScoreMetric } from "tachi-common/types/metrics"; export default function PBCompareCell({ @@ -13,12 +8,10 @@ export default function PBCompareCell({ metricConf, metric, }: { - base: MONGO_PBScoreDocument | null; - compare: MONGO_PBScoreDocument | null; - game: GameGroup; + base: PBScoreDocument | null; + compare: PBScoreDocument | null; metric: string; metricConf: ConfScoreMetric; - playtype: Playtype; }) { let status: "draw" | "lose" | "win"; @@ -27,9 +20,9 @@ export default function PBCompareCell({ const getMetricNum = metricConf.type === "ENUM" ? // @ts-expect-error bad hacks - (pb: MONGO_PBScoreDocument) => pb.scoreData.enumIndexes[metric] + (pb: PBScoreDocument) => pb.scoreData.enumIndexes[metric] : // @ts-expect-error bad hacks - (pb: MONGO_PBScoreDocument) => pb.scoreData[metric]; + (pb: PBScoreDocument) => pb.scoreData[metric]; // wut? if (!base && !compare) { diff --git a/typescript/client/src/components/tables/cells/PopnJudgementCell.tsx b/typescript/client/src/components/tables/cells/PopnJudgementCell.tsx index 4320f8927..b5181fa1a 100644 --- a/typescript/client/src/components/tables/cells/PopnJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/PopnJudgementCell.tsx @@ -1,11 +1,11 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function PopnJudgementCell({ score, }: { - score: MONGO_PBScoreDocument<"popn:9B"> | MONGO_ScoreDocument<"popn:9B">; + score: PBScoreDocument<"popn"> | ScoreDocument<"popn">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/cells/PopnLampCell.tsx b/typescript/client/src/components/tables/cells/PopnLampCell.tsx index 25bc0e054..0e4c77ab7 100644 --- a/typescript/client/src/components/tables/cells/PopnLampCell.tsx +++ b/typescript/client/src/components/tables/cells/PopnLampCell.tsx @@ -1,18 +1,18 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; import { ChangeOpacity } from "#util/color-opacity"; import React from "react"; -import { type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function PopnLampCell({ score, }: { - score: MONGO_PBScoreDocument<"popn:9B"> | MONGO_ScoreDocument<"popn:9B">; + score: PBScoreDocument<"popn"> | ScoreDocument<"popn">; }) { return ( {FormatScoreRating(score.game, score.playtype, rating, value)}; + return {FormatScoreRating(game, rating, value)}; } diff --git a/typescript/client/src/components/tables/cells/RatingSystemPart.tsx b/typescript/client/src/components/tables/cells/RatingSystemPart.tsx index d2ef7e954..a9fbcc330 100644 --- a/typescript/client/src/components/tables/cells/RatingSystemPart.tsx +++ b/typescript/client/src/components/tables/cells/RatingSystemPart.tsx @@ -5,19 +5,13 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; import { type GPTRatingSystem } from "#lib/types"; import { IsNotNullish } from "#util/misc"; import React from "react"; -import { type GameGroup, GetGPTString, type MONGO_ChartDocument } from "tachi-common"; +import { type ChartDocument, type V3Game } from "tachi-common"; import MiniTable from "../components/MiniTable"; -export default function RatingSystemPart({ - chart, - game, -}: { - chart: MONGO_ChartDocument; - game: GameGroup; -}) { +export default function RatingSystemPart({ chart, game }: { chart: ChartDocument; game: V3Game }) { const ratingSystems: Array> = - GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, chart.playtype)].ratingSystems; + GPT_CLIENT_IMPLEMENTATIONS[game].ratingSystems; // don't render anything if this game has no other rating systems defined // for this chart diff --git a/typescript/client/src/components/tables/cells/SDVXJudgementCell.tsx b/typescript/client/src/components/tables/cells/SDVXJudgementCell.tsx index 7320f8582..33a90c9a0 100644 --- a/typescript/client/src/components/tables/cells/SDVXJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/SDVXJudgementCell.tsx @@ -1,13 +1,13 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function SDVXJudgementCell({ score, }: { score: - | MONGO_PBScoreDocument<"sdvx:Single" | "usc:Controller" | "usc:Keyboard"> - | MONGO_ScoreDocument<"sdvx:Single" | "usc:Controller" | "usc:Keyboard">; + | PBScoreDocument<"sdvx" | "usc-controller" | "usc-keyboard"> + | ScoreDocument<"sdvx" | "usc-controller" | "usc-keyboard">; }) { // even if we dont have judgement data, we know what they got. if (score.scoreData.lamp === "PERFECT ULTIMATE CHAIN") { diff --git a/typescript/client/src/components/tables/cells/SDVXLampCell.tsx b/typescript/client/src/components/tables/cells/SDVXLampCell.tsx index 38ad34d1c..c9a250502 100644 --- a/typescript/client/src/components/tables/cells/SDVXLampCell.tsx +++ b/typescript/client/src/components/tables/cells/SDVXLampCell.tsx @@ -1,15 +1,15 @@ import { GetEnumColour } from "#lib/game-implementations"; import { ChangeOpacity } from "#util/color-opacity"; import React from "react"; -import { type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { type PBScoreDocument, type ScoreDocument } from "tachi-common"; // Lamp cell, but if the lamp is FAILED, display it slightly differently. export default function SDVXLampCell({ score, }: { score: - | MONGO_PBScoreDocument<"sdvx:Single" | "usc:Controller" | "usc:Keyboard"> - | MONGO_ScoreDocument<"sdvx:Single" | "usc:Controller" | "usc:Keyboard">; + | PBScoreDocument<"sdvx" | "usc-controller" | "usc-keyboard"> + | ScoreDocument<"sdvx" | "usc-controller" | "usc-keyboard">; }) { return ( ; + } else if (game === "itg-stamina" && chart) { + const itgChart = chart as ChartDocument<"itg-stamina">; const banner = itgChart.data.bannerLocationOverride ?? itgChart.data.originalPack; if (banner) { @@ -56,16 +56,16 @@ export default function TitleCell({ > {game === "popn" && ( <> - {(song as MONGO_SongDocument<"popn">).data.genre === song.title || - (song as MONGO_SongDocument<"popn">).data.genre === null ? ( + {(song as SongDocument<"popn">).data.genre === song.title || + (song as SongDocument<"popn">).data.genre === null ? ( Unknown Genre ) : ( - (song as MONGO_SongDocument<"popn">).data.genre + (song as SongDocument<"popn">).data.genre )}
    )} - + {song.title} {!noArtist && ( diff --git a/typescript/client/src/components/tables/cells/USCDifficultyCell.tsx b/typescript/client/src/components/tables/cells/USCDifficultyCell.tsx index f501f394b..6ac1d5655 100644 --- a/typescript/client/src/components/tables/cells/USCDifficultyCell.tsx +++ b/typescript/client/src/components/tables/cells/USCDifficultyCell.tsx @@ -1,26 +1,23 @@ import QuickTooltip from "#components/layout/misc/QuickTooltip"; import Icon from "#components/util/Icon"; import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; -import { type GPTClientImplementation } from "#lib/types"; import { ChangeOpacity } from "#util/color-opacity"; import { FormatTables } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_ChartDocument } from "tachi-common"; +import { type ChartDocument, COLOUR_SET } from "tachi-common"; import RatingSystemPart from "./RatingSystemPart"; -export default function USCDifficultyCell({ - chart, -}: { - chart: MONGO_ChartDocument<"usc:Controller" | "usc:Keyboard">; -}) { +type USCGame = "usc-controller" | "usc-keyboard"; + +export default function USCDifficultyCell({ chart }: { chart: ChartDocument }) { + const game: USCGame = chart.game as USCGame; + const levelText = chart.data.isOfficial ? `${chart.difficulty} ${chart.level}` : FormatTables(chart.data.tableFolders); - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[ - `usc:${chart.playtype}` - ] as GPTClientImplementation<"usc:Controller">; + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; const bgColour = ChangeOpacity( chart.data.isOfficial ? gptImpl.difficultyColours[chart.difficulty]! : COLOUR_SET.teal, @@ -34,7 +31,7 @@ export default function USCDifficultyCell({ }} > {levelText} - + {!chart.isPrimary && (
    diff --git a/typescript/client/src/components/tables/cells/UserCell.tsx b/typescript/client/src/components/tables/cells/UserCell.tsx index 30bc98a08..bdd4788cd 100644 --- a/typescript/client/src/components/tables/cells/UserCell.tsx +++ b/typescript/client/src/components/tables/cells/UserCell.tsx @@ -3,9 +3,9 @@ import { type GamePT } from "#types/react"; import { ToAPIURL } from "#util/api"; import React from "react"; import { Link } from "react-router-dom"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; -export default function UserCell({ user, game, playtype }: { user: MONGO_UserDocument } & GamePT) { +export default function UserCell({ user, game }: { user: UserDocument } & GamePT) { return ( - + {user.username} {user.isSupporter && ( <> diff --git a/typescript/client/src/components/tables/cells/VF6Cell.tsx b/typescript/client/src/components/tables/cells/VF6Cell.tsx index eeab7e515..d8f815bae 100644 --- a/typescript/client/src/components/tables/cells/VF6Cell.tsx +++ b/typescript/client/src/components/tables/cells/VF6Cell.tsx @@ -4,14 +4,13 @@ import { IsNullish } from "#util/misc"; import React, { useContext } from "react"; import { Volforce } from "rg-stats"; import { - GetGPTString, - GetSpecificGPTConfig, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, + type ChartDocument, + GetSpecificGameConfig, + type PBScoreDocument, + type ScoreDocument, } from "tachi-common"; -type VF6GPTString = "sdvx:Single" | "usc:Controller" | "usc:Keyboard"; +type VF6Game = "sdvx" | "usc-controller" | "usc-keyboard"; const SHORT_LAMPS = { CLEAR: "CLR", @@ -24,11 +23,11 @@ export default function VF6Cell({ score, chart, }: { - chart: MONGO_ChartDocument; - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + chart: ChartDocument; + score: PBScoreDocument | ScoreDocument; }) { const { user } = useContext(UserContext); - const { settings } = useLUGPTSettings(); + const { settings } = useLUGPTSettings(); if (IsNullish(score.calculatedData.VF6)) { return N/A; @@ -36,9 +35,8 @@ export default function VF6Cell({ const vf6Target = settings?.preferences.gameSpecific.vf6Target; - const gptConfig = GetSpecificGPTConfig( - GetGPTString(score.game, score.playtype) as VF6GPTString, - ); + const game = score.game as VF6Game; + const gameConfig = GetSpecificGameConfig(game); const targets: Record = {}; @@ -46,7 +44,7 @@ export default function VF6Cell({ for (const lamp of ["CLEAR", "EXCESSIVE CLEAR", "ULTIMATE CHAIN"] as const) { if ( score.scoreData.enumIndexes.lamp <= - gptConfig.providedMetrics.lamp.values.indexOf(lamp) + gameConfig.providedMetrics.lamp.values.indexOf(lamp) ) { const expectedScore = InverseVF6(vf6Target, lamp, chart.levelNum); diff --git a/typescript/client/src/components/tables/cells/WACCAJudgementCell.tsx b/typescript/client/src/components/tables/cells/WACCAJudgementCell.tsx index ac9c5a576..5f30fd0cb 100644 --- a/typescript/client/src/components/tables/cells/WACCAJudgementCell.tsx +++ b/typescript/client/src/components/tables/cells/WACCAJudgementCell.tsx @@ -1,11 +1,11 @@ import { IsNullish } from "#util/misc"; import React from "react"; -import { COLOUR_SET, type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export default function WaccaJudgementCell({ score, }: { - score: MONGO_PBScoreDocument<"wacca:Single"> | MONGO_ScoreDocument<"wacca:Single">; + score: PBScoreDocument<"wacca"> | ScoreDocument<"wacca">; }) { const judgements = score.scoreData.judgements; diff --git a/typescript/client/src/components/tables/components/SelectableCompareType.tsx b/typescript/client/src/components/tables/components/SelectableCompareType.tsx index f9d0256f0..cb99b36b5 100644 --- a/typescript/client/src/components/tables/components/SelectableCompareType.tsx +++ b/typescript/client/src/components/tables/components/SelectableCompareType.tsx @@ -2,7 +2,7 @@ import Icon from "#components/util/Icon"; import { type SetState } from "#types/react"; import { UppercaseFirst } from "#util/misc"; import React from "react"; -import { type GamePTConfig, GetScoreMetrics } from "tachi-common"; +import { type GameConfig, GetScoreMetrics } from "tachi-common"; import { type ZTableTHProps } from "./TachiTable"; @@ -12,9 +12,9 @@ export default function SelectableCompareType({ changeSort, currentSortMode, reverseSort, - gptConfig, + gameConfig, }: { - gptConfig: GamePTConfig; + gameConfig: GameConfig; metric: string; setMetric: SetState; } & ZTableTHProps) { @@ -25,7 +25,7 @@ export default function SelectableCompareType({ onChange={(v) => setMetric(v.target.value)} value={metric} > - {GetScoreMetrics(gptConfig, ["DECIMAL", "INTEGER", "ENUM"]).map((e) => ( + {GetScoreMetrics(gameConfig, ["DECIMAL", "INTEGER", "ENUM"]).map((e) => ( diff --git a/typescript/client/src/components/tables/components/SelectableRating.tsx b/typescript/client/src/components/tables/components/SelectableRating.tsx index 6b32110a3..11af61e9b 100644 --- a/typescript/client/src/components/tables/components/SelectableRating.tsx +++ b/typescript/client/src/components/tables/components/SelectableRating.tsx @@ -2,16 +2,14 @@ import Icon from "#components/util/Icon"; import { type SetState } from "#types/react"; import { FormatGPTScoreRatingName, FormatGPTSessionRatingName } from "#util/misc"; import React from "react"; -import { type GameGroup, GetGamePTConfig, type GPTString, type Playtypes } from "tachi-common"; +import { GetGameConfig, type V3Game } from "tachi-common"; import { type ZTableTHProps } from "./TachiTable"; -// hack to get everything to work -type AllRatings<_GPT> = any; +type AllRatings = any; -export default function SelectableRating({ +export default function SelectableRating({ game, - playtype, rating, setRating, changeSort, @@ -19,13 +17,12 @@ export default function SelectableRating({ reverseSort, mode = "score", }: { - game: GameGroup; + game: V3Game; mode?: "profile" | "score" | "session"; - playtype: Playtypes[GameGroup]; - rating: AllRatings; - setRating: SetState>; + rating: AllRatings; + setRating: SetState; } & ZTableTHProps) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); let key: "profileRatingAlgs" | "scoreRatingAlgs" | "sessionRatingAlgs"; if (mode === "score") { @@ -41,14 +38,14 @@ export default function SelectableRating({
    diff --git a/typescript/client/src/components/tables/components/UseScoreState.tsx b/typescript/client/src/components/tables/components/UseScoreState.tsx index 55b458305..6ee5aaffd 100644 --- a/typescript/client/src/components/tables/components/UseScoreState.tsx +++ b/typescript/client/src/components/tables/components/UseScoreState.tsx @@ -1,6 +1,6 @@ import { type ScoreDataset } from "#types/tables"; import { useEffect, useState } from "react"; -import { type MONGO_PBScoreDocument } from "tachi-common"; +import { type PBScoreDocument } from "tachi-common"; export function useScoreState(sc: ScoreDataset[0]) { const [highlight, setHighlight] = useState(sc.highlight); @@ -14,7 +14,7 @@ export function useScoreState(sc: ScoreDataset[0]) { return { highlight, comment, setHighlight, setComment }; } -export function usePBState(pb: MONGO_PBScoreDocument) { +export function usePBState(pb: PBScoreDocument) { const [highlight, setHighlight] = useState(pb.highlight); useEffect(() => { diff --git a/typescript/client/src/components/tables/dropdowns/GPTDropdownSettings.tsx b/typescript/client/src/components/tables/dropdowns/GPTDropdownSettings.tsx index db19d6f1f..8b594d6d2 100644 --- a/typescript/client/src/components/tables/dropdowns/GPTDropdownSettings.tsx +++ b/typescript/client/src/components/tables/dropdowns/GPTDropdownSettings.tsx @@ -1,4 +1,4 @@ -import { type GameGroup, type Playtype } from "tachi-common"; +import { GameToGameGroup, type V3Game } from "tachi-common"; import { BMSGraphsComponent } from "./components/BMSScoreDropdownParts"; import { ChunithmGraphsComponent } from "./components/ChunithmScoreDropdownParts"; @@ -8,40 +8,42 @@ import { JubeatGraphsComponent } from "./components/JubeatScoreDropdownParts"; import { MaimaiDXGraphsComponent } from "./components/MaimaiDXScoreDropdownParts"; import { OngekiGraphsComponent } from "./components/OngekiScoreDropdownParts"; -export function GPTDropdownSettings(game: GameGroup, playtype: Playtype): any { - if (game === "iidx") { +export function GPTDropdownSettings(game: V3Game): any { + const gameGroup = GameToGameGroup(game); + + if (gameGroup === "iidx") { return { renderScoreInfo: true, // let the record show that i tried fixing this // for a while, but gave up. GraphComponent: IIDXGraphsComponent as any, }; - } else if (game === "bms") { + } else if (gameGroup === "bms") { return { renderScoreInfo: true, GraphComponent: BMSGraphsComponent as any, }; - } else if (game === "itg") { + } else if (gameGroup === "itg") { return { renderScoreInfo: true, GraphComponent: ITGGraphsComponent as any, }; - } else if (game === "jubeat") { + } else if (gameGroup === "jubeat") { return { renderScoreInfo: true, GraphComponent: JubeatGraphsComponent as any, }; - } else if (game === "ongeki") { + } else if (gameGroup === "ongeki") { return { renderScoreInfo: true, GraphComponent: OngekiGraphsComponent as any, }; - } else if (game === "chunithm") { + } else if (gameGroup === "chunithm") { return { renderScoreInfo: true, GraphComponent: ChunithmGraphsComponent as any, }; - } else if (game === "maimaidx") { + } else if (gameGroup === "maimaidx") { return { renderScoreInfo: true, GraphComponent: MaimaiDXGraphsComponent as any, diff --git a/typescript/client/src/components/tables/dropdowns/PBDropdown.tsx b/typescript/client/src/components/tables/dropdowns/PBDropdown.tsx index d6b45bd27..cd89621a2 100644 --- a/typescript/client/src/components/tables/dropdowns/PBDropdown.tsx +++ b/typescript/client/src/components/tables/dropdowns/PBDropdown.tsx @@ -10,11 +10,11 @@ import { type GoalsOnChartReturn, type UGPTChartPBComposition } from "#types/api import { type GamePT } from "#types/react"; import React, { useContext, useMemo, useReducer, useState } from "react"; import { + type ChartDocument, type integer, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, + type PBScoreDocument, + type ScoreDocument, + type SongDocument, } from "tachi-common"; import DocComponentCreator, { @@ -28,55 +28,52 @@ import TargetInfo from "./components/TargetInfo"; import { GPTDropdownSettings } from "./GPTDropdownSettings"; export interface ScoreDropdownProps { - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + score: PBScoreDocument | ScoreDocument; scoreState: ScoreState; pbData: UGPTChartPBComposition; - chart: MONGO_ChartDocument; + chart: ChartDocument; } export default function PBDropdown({ game, - playtype, chart, scoreState, defaultView = "pb", userID, song, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; defaultView?: "debug" | "history" | "pb" | "rivals" | "targets" | `otherPB::${string}`; scoreState: ScoreState; - song: MONGO_SongDocument; + song: SongDocument; userID: integer; } & GamePT) { const { user: currentUser } = useContext(UserContext); const DocComponent: DocumentComponentType = (props) => - DocComponentCreator({ ...props, ...GPTDropdownSettings(game, playtype) }); + DocComponentCreator({ ...props, ...GPTDropdownSettings(game) }); const [view, setView] = useState(defaultView); const { data, error } = useApiQuery( - `/users/${userID}/games/${game}/${playtype}/pbs/${chart.chartID}?getComposition=true`, + `/users/${userID}/games/${game}/pbs/${chart.chartID}?getComposition=true`, ); - const { error: histError, data: histData } = useApiQuery( - `/users/${userID}/games/${game}/${playtype}/scores/${chart.chartID}`, + const { error: histError, data: histData } = useApiQuery( + `/users/${userID}/games/${game}/scores/${chart.chartID}`, ); const [shouldRefresh, forceRefresh] = useReducer((state) => state + 1, 0); // when a user isn't logged in, skip ever making this request. const { error: targetError, data: targetData } = useApiQuery( - `/users/${currentUser?.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${ - chart.chartID - }`, + `/users/${currentUser?.id ?? ""}/games/${game}/targets/on-chart/${chart.chartID}`, undefined, [shouldRefresh], currentUser === null, ); - const currentScoreDoc: MONGO_PBScoreDocument | MONGO_ScoreDocument | null = useMemo(() => { + const currentScoreDoc: PBScoreDocument | ScoreDocument | null = useMemo(() => { if (!data) { // dont worry about this null, it never gets below the rquery checks return null; @@ -117,15 +114,7 @@ export default function PBDropdown({ let body; if (view === "history") { - body = ( - - ); + body = ; } else if (view === "debug") { body = ; } else if (view === "rivals") { @@ -139,7 +128,6 @@ export default function PBDropdown({ error={targetError} game={game} onGoalSet={forceRefresh} - playtype={playtype} reqUser={currentUser} song={song} /> diff --git a/typescript/client/src/components/tables/dropdowns/ScoreDropdown.tsx b/typescript/client/src/components/tables/dropdowns/ScoreDropdown.tsx index 599f32f9c..1c202136b 100644 --- a/typescript/client/src/components/tables/dropdowns/ScoreDropdown.tsx +++ b/typescript/client/src/components/tables/dropdowns/ScoreDropdown.tsx @@ -10,12 +10,12 @@ import { type GoalsOnChartReturn, type UGPTChartPBComposition } from "#types/api import { type GamePT, type SetState } from "#types/react"; import React, { useContext, useReducer, useState } from "react"; import { - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, + type ChartDocument, + type PBScoreDocument, + type ScoreDocument, + type SongDocument, UserAuthLevels, + type UserDocument, } from "tachi-common"; import DeleteScoreBtn from "./components/DeleteScoreBtn"; @@ -33,13 +33,12 @@ export interface ScoreState { } export interface ScoreDropdownProps { - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + score: PBScoreDocument | ScoreDocument; scoreState: ScoreState; } export default function ScoreDropdown({ game, - playtype, user, chart, song, @@ -48,19 +47,19 @@ export default function ScoreDropdown({ defaultView = "moreInfo", onScoreUpdate, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; defaultView?: "debug" | "history" | "manage" | "moreInfo" | "rivals" | "targets" | "vsPB"; - onScoreUpdate?: (sc: MONGO_ScoreDocument) => void; + onScoreUpdate?: (sc: ScoreDocument) => void; scoreState: ScoreState; - song: MONGO_SongDocument; - thisScore: MONGO_ScoreDocument; - user: MONGO_UserDocument; + song: SongDocument; + thisScore: ScoreDocument; + user: UserDocument; } & GamePT) { const DocComponent: DocumentComponentType = (props) => DocComponentCreator({ renderScoreInfo: false, ...props, - ...GPTDropdownSettings(game, playtype), + ...GPTDropdownSettings(game), }); const [view, setView] = useState(defaultView); @@ -68,19 +67,17 @@ export default function ScoreDropdown({ const { settings } = useLUGPTSettings(); const { data, error } = useApiQuery( - `/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}?getComposition=true`, + `/users/${user.id}/games/${game}/pbs/${chart.chartID}?getComposition=true`, ); - const { error: histError, data: histData } = useApiQuery( - `/users/${user.id}/games/${game}/${playtype}/scores/${chart.chartID}`, + const { error: histError, data: histData } = useApiQuery( + `/users/${user.id}/games/${game}/scores/${chart.chartID}`, ); const [shouldRefresh, forceRefresh] = useReducer((state) => state + 1, 0); const { error: targetError, data: targetData } = useApiQuery( - `/users/${currentUser?.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${ - chart.chartID - }`, + `/users/${currentUser?.id ?? ""}/games/${game}/targets/on-chart/${chart.chartID}`, undefined, [shouldRefresh], // when a user isn't logged in, skip ever making this request. @@ -102,15 +99,7 @@ export default function ScoreDropdown({ let body; if (view === "history") { - body = ( - - ); + body = ; } else if (view === "debug") { body = ; } else if (view === "moreInfo") { @@ -136,7 +125,6 @@ export default function ScoreDropdown({ error={targetError} game={game} onGoalSet={forceRefresh} - playtype={playtype} reqUser={currentUser} song={song} /> diff --git a/typescript/client/src/components/tables/dropdowns/components/BMSScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/BMSScoreDropdownParts.tsx index 83ed4b81f..6071538e3 100644 --- a/typescript/client/src/components/tables/dropdowns/components/BMSScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/BMSScoreDropdownParts.tsx @@ -3,19 +3,15 @@ import SelectNav from "#components/util/SelectNav"; import { IsScore } from "#util/asserts"; import React, { useEffect, useState } from "react"; import { Nav } from "react-bootstrap"; -import { - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, -} from "tachi-common"; +import { type ChartDocument, type PBScoreDocument, type ScoreDocument } from "tachi-common"; type LampTypes = "Easy" | "EXHard" | "Hard" | "Normal"; export function BMSGraphsComponent({ score, }: { - chart: MONGO_ChartDocument<"bms:7K" | "bms:14K">; - score: MONGO_PBScoreDocument<"bms:7K" | "bms:14K"> | MONGO_ScoreDocument<"bms:7K" | "bms:14K">; + chart: ChartDocument<"bms-7k" | "bms-14k">; + score: PBScoreDocument<"bms-7k" | "bms-14k"> | ScoreDocument<"bms-7k" | "bms-14k">; }) { const [lamp, setLamp] = useState(LampToKey(score)); @@ -141,7 +137,7 @@ function GraphComponent({ type, values }: { type: LampTypes; values: (number | n } function LampToKey( - score: MONGO_PBScoreDocument<"bms:7K" | "bms:14K"> | MONGO_ScoreDocument<"bms:7K" | "bms:14K">, + score: PBScoreDocument<"bms-7k" | "bms-14k"> | ScoreDocument<"bms-7k" | "bms-14k">, ): LampTypes { const lamp = score.scoreData.lamp; diff --git a/typescript/client/src/components/tables/dropdowns/components/ChunithmScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/ChunithmScoreDropdownParts.tsx index e552967cc..fda92a61f 100644 --- a/typescript/client/src/components/tables/dropdowns/components/ChunithmScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/ChunithmScoreDropdownParts.tsx @@ -5,12 +5,12 @@ import { type SetState } from "#types/react"; import React, { useState } from "react"; import { Nav } from "react-bootstrap"; import { + type ChartDocument, type Difficulties, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreData, - type MONGO_ScoreDocument, - type MONGO_SongDocument, + type PBScoreDocument, + type ScoreData, + type ScoreDocument, + type SongDocument, } from "tachi-common"; type ChartType = "Life" | "Score"; @@ -19,8 +19,8 @@ export function ChunithmGraphsComponent({ score, chart, }: { - chart: MONGO_ChartDocument<"chunithm:Single">; - score: MONGO_PBScoreDocument<"chunithm:Single"> | MONGO_ScoreDocument<"chunithm:Single">; + chart: ChartDocument<"chunithm">; + score: PBScoreDocument<"chunithm"> | ScoreDocument<"chunithm">; }) { const [graph, setGraph] = useState("Score"); const available = score.scoreData.optional.scoreGraph && score.scoreData.optional.lifeGraph; @@ -48,13 +48,13 @@ function Inner({ available, }: { available: number[] | null | undefined; - chart: MONGO_ChartDocument<"chunithm:Single">; + chart: ChartDocument<"chunithm">; graph: ChartType; - score: MONGO_PBScoreDocument<"chunithm:Single"> | MONGO_ScoreDocument<"chunithm:Single">; + score: PBScoreDocument<"chunithm"> | ScoreDocument<"chunithm">; setGraph: SetState; }) { const { data, error } = useApiQuery<{ - song: MONGO_SongDocument<"chunithm">; + song: SongDocument<"chunithm">; }>(`/games/chunithm/Single/songs/${score.songID}`); if (error !== null || data === undefined) { @@ -105,9 +105,9 @@ function GraphComponent({ difficulty, type, }: { - difficulty: Difficulties["chunithm:Single"]; - scoreData: MONGO_ScoreData<"chunithm:Single">; - song: MONGO_SongDocument<"chunithm">; + difficulty: Difficulties["chunithm"]; + scoreData: ScoreData<"chunithm">; + song: SongDocument<"chunithm">; type: ChartType; }) { const values = diff --git a/typescript/client/src/components/tables/dropdowns/components/DeleteScoreBtn.tsx b/typescript/client/src/components/tables/dropdowns/components/DeleteScoreBtn.tsx index 5ce7cc4bb..200f2e37c 100644 --- a/typescript/client/src/components/tables/dropdowns/components/DeleteScoreBtn.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/DeleteScoreBtn.tsx @@ -2,9 +2,9 @@ import Icon from "#components/util/Icon"; import { APIFetchV1 } from "#util/api"; import { DelayedPageReload } from "#util/misc"; import React, { useMemo, useReducer } from "react"; -import { type MONGO_ScoreDocument } from "tachi-common"; +import { type ScoreDocument } from "tachi-common"; -export default function DeleteScoreBtn({ score }: { score: MONGO_ScoreDocument }) { +export default function DeleteScoreBtn({ score }: { score: ScoreDocument }) { const [warn, upgWarn] = useReducer((r) => r + 1, 0); const message = useMemo(() => { if (warn === 0) { diff --git a/typescript/client/src/components/tables/dropdowns/components/DocumentComponent.tsx b/typescript/client/src/components/tables/dropdowns/components/DocumentComponent.tsx index 9b915bd70..4d329b22d 100644 --- a/typescript/client/src/components/tables/dropdowns/components/DocumentComponent.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/DocumentComponent.tsx @@ -8,12 +8,7 @@ import { type SetState } from "#types/react"; import { IsScore } from "#util/asserts"; import { FormatGPTProfileRatingName } from "#util/misc"; import React, { useContext, useEffect, useState } from "react"; -import { - GetGPTString, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, -} from "tachi-common"; +import { type ChartDocument, type PBScoreDocument, type ScoreDocument } from "tachi-common"; import CommentContainer from "./CommentContainer"; import DeleteScoreBtn from "./DeleteScoreBtn"; @@ -25,11 +20,12 @@ export function ScoreInfo({ score, chart, }: { - chart: MONGO_ChartDocument; - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + chart: ChartDocument; + score: PBScoreDocument | ScoreDocument; }) { - const rating = useScoreRatingAlg(score.game, score.playtype); - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(score.game, chart.playtype)]; + const game = score.game; + const rating = useScoreRatingAlg(game); + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; return (
    @@ -37,18 +33,13 @@ export function ScoreInfo({ Score Info - {FormatGPTProfileRatingName(score.game, score.playtype, rating)} + {FormatGPTProfileRatingName(game, rating)} Timestamp - + {/* @ts-expect-error yeah we know service doesnt necessarily exist */} @@ -76,21 +67,21 @@ export default function DocumentComponent({ chart, onScoreUpdate, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; forceScoreData?: boolean; GraphComponent?: | (({ score, chart, }: { - chart: MONGO_ChartDocument; - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + chart: ChartDocument; + score: PBScoreDocument | ScoreDocument; }) => JSX.Element) | null; - onScoreUpdate?: (sc: MONGO_ScoreDocument) => void; + onScoreUpdate?: (sc: ScoreDocument) => void; pbData: UGPTChartPBComposition; renderScoreInfo?: boolean; - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + score: PBScoreDocument | ScoreDocument; scoreState: { highlight: boolean; setComment?: SetState; @@ -190,19 +181,19 @@ export function GraphAndJudgementDataComponent({ GraphComponent = null, chart, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; forceScoreData?: boolean; GraphComponent?: | (({ score, chart, }: { - chart: MONGO_ChartDocument; - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + chart: ChartDocument; + score: PBScoreDocument | ScoreDocument; }) => JSX.Element) | null; renderScoreInfo?: boolean; - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + score: PBScoreDocument | ScoreDocument; showSingleScoreNote?: boolean; }) { return ( diff --git a/typescript/client/src/components/tables/dropdowns/components/IIDXScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/IIDXScoreDropdownParts.tsx index 972add4f5..8446f9ee0 100644 --- a/typescript/client/src/components/tables/dropdowns/components/IIDXScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/IIDXScoreDropdownParts.tsx @@ -3,9 +3,9 @@ import SelectNav from "#components/util/SelectNav"; import { IsScore } from "#util/asserts"; import React, { useEffect, useState } from "react"; import { Nav } from "react-bootstrap"; -import { type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { type GamesForGroup, type PBScoreDocument, type ScoreDocument } from "tachi-common"; -// export function ModsTable({ score }: { score: MONGO_ScoreDocument<"iidx:SP" | "iidx:DP"> }) { +// export function ModsTable({ score }: { score: ScoreDocument<"iidx:SP" | "iidx:DP"> }) { // if (!score.scoreMeta.assist && !score.scoreMeta.random) { // return null; // } @@ -37,9 +37,7 @@ type LampTypes = "DAN_GAUGE" | "Easy" | "EXHard" | "Hard" | "Normal"; export function IIDXGraphsComponent({ score, }: { - score: - | MONGO_PBScoreDocument<"iidx:DP" | "iidx:SP"> - | MONGO_ScoreDocument<"iidx:DP" | "iidx:SP">; + score: PBScoreDocument | ScoreDocument; }) { const [lamp, setLamp] = useState(LampToKey(score)); @@ -153,9 +151,7 @@ function GraphComponent({ type, values }: { type: LampTypes; values: (number | n } function LampToKey( - score: - | MONGO_PBScoreDocument<"iidx:DP" | "iidx:SP"> - | MONGO_ScoreDocument<"iidx:DP" | "iidx:SP">, + score: PBScoreDocument | ScoreDocument, ): LampTypes { const lamp = score.scoreData.lamp; diff --git a/typescript/client/src/components/tables/dropdowns/components/ITGScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/ITGScoreDropdownParts.tsx index a9701c186..2d798d352 100644 --- a/typescript/client/src/components/tables/dropdowns/components/ITGScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/ITGScoreDropdownParts.tsx @@ -2,18 +2,14 @@ import ITGDensityChart from "#components/charts/ITGDensityChart"; import SelectNav from "#components/util/SelectNav"; import React, { useState } from "react"; import { Nav } from "react-bootstrap"; -import { - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, -} from "tachi-common"; +import { type ChartDocument, type PBScoreDocument, type ScoreDocument } from "tachi-common"; export function ITGGraphsComponent({ score, chart, }: { - chart: MONGO_ChartDocument<"itg:Stamina">; - score: MONGO_PBScoreDocument<"itg:Stamina"> | MONGO_ScoreDocument<"itg:Stamina">; + chart: ChartDocument<"itg-stamina">; + score: PBScoreDocument<"itg-stamina"> | ScoreDocument<"itg-stamina">; }) { const [graph, setGraph] = useState("DENSITY"); diff --git a/typescript/client/src/components/tables/dropdowns/components/JubeatScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/JubeatScoreDropdownParts.tsx index 05df4e941..d191220ee 100644 --- a/typescript/client/src/components/tables/dropdowns/components/JubeatScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/JubeatScoreDropdownParts.tsx @@ -1,17 +1,17 @@ import React, { useEffect, useRef } from "react"; import { + type ChartDocument, COLOUR_SET, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, + type PBScoreDocument, + type ScoreDocument, } from "tachi-common"; export function JubeatGraphsComponent({ score, chart, }: { - chart: MONGO_ChartDocument<"jubeat:Single">; - score: MONGO_PBScoreDocument<"jubeat:Single"> | MONGO_ScoreDocument<"jubeat:Single">; + chart: ChartDocument<"jubeat">; + score: PBScoreDocument<"jubeat"> | ScoreDocument<"jubeat">; }) { const canvasRef = useRef(null); diff --git a/typescript/client/src/components/tables/dropdowns/components/JudgementTable.tsx b/typescript/client/src/components/tables/dropdowns/components/JudgementTable.tsx index b38373da5..918bd63d4 100644 --- a/typescript/client/src/components/tables/dropdowns/components/JudgementTable.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/JudgementTable.tsx @@ -1,23 +1,14 @@ import MiniTable from "#components/tables/components/MiniTable"; import { ChangeOpacity } from "#util/color-opacity"; import React from "react"; -import { - COLOUR_SET, - GetGamePTConfig, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, -} from "tachi-common"; +import { COLOUR_SET, GetGameConfig, type PBScoreDocument, type ScoreDocument } from "tachi-common"; -export default function JudgementTable({ - score, -}: { - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; -}) { - const gptConfig = GetGamePTConfig(score.game, score.playtype); +export default function JudgementTable({ score }: { score: PBScoreDocument | ScoreDocument }) { + const gameConfig = GetGameConfig(score.game); return ( - {gptConfig.orderedJudgements.map((j) => ( + {gameConfig.orderedJudgements.map((j) => ( {j.toUpperCase()} {/* @ts-expect-error fine access */} diff --git a/typescript/client/src/components/tables/dropdowns/components/MaimaiDXScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/MaimaiDXScoreDropdownParts.tsx index ae4f00b56..0694351a3 100644 --- a/typescript/client/src/components/tables/dropdowns/components/MaimaiDXScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/MaimaiDXScoreDropdownParts.tsx @@ -5,12 +5,12 @@ import { type SetState } from "#types/react"; import React, { useState } from "react"; import { Nav } from "react-bootstrap"; import { + type ChartDocument, type Difficulties, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreData, - type MONGO_ScoreDocument, - type MONGO_SongDocument, + type PBScoreDocument, + type ScoreData, + type ScoreDocument, + type SongDocument, } from "tachi-common"; type ChartType = "Life" | "Score"; @@ -19,8 +19,8 @@ export function MaimaiDXGraphsComponent({ score, chart, }: { - chart: MONGO_ChartDocument<"maimaidx:Single">; - score: MONGO_PBScoreDocument<"maimaidx:Single"> | MONGO_ScoreDocument<"maimaidx:Single">; + chart: ChartDocument<"maimaidx">; + score: PBScoreDocument<"maimaidx"> | ScoreDocument<"maimaidx">; }) { const [graph, setGraph] = useState("Score"); const { percentGraph, lifeGraph } = score.scoreData.optional; @@ -49,15 +49,15 @@ function Inner({ percentGraph, lifeGraph, }: { - chart: MONGO_ChartDocument<"maimaidx:Single">; + chart: ChartDocument<"maimaidx">; graph: ChartType; lifeGraph: (number | null)[] | null | undefined; percentGraph: (number | null)[] | null | undefined; - score: MONGO_PBScoreDocument<"maimaidx:Single"> | MONGO_ScoreDocument<"maimaidx:Single">; + score: PBScoreDocument<"maimaidx"> | ScoreDocument<"maimaidx">; setGraph: SetState; }) { const { data, error } = useApiQuery<{ - song: MONGO_SongDocument<"maimaidx">; + song: SongDocument<"maimaidx">; }>(`/games/maimaidx/Single/songs/${score.songID}`); if (error !== null || data === undefined) { @@ -102,9 +102,9 @@ function GraphComponent({ difficulty, type, }: { - difficulty: Difficulties["maimaidx:Single"]; - scoreData: MONGO_ScoreData<"maimaidx:Single">; - song: MONGO_SongDocument<"maimaidx">; + difficulty: Difficulties["maimaidx"]; + scoreData: ScoreData<"maimaidx">; + song: SongDocument<"maimaidx">; type: ChartType; }) { const values = diff --git a/typescript/client/src/components/tables/dropdowns/components/ManageImport.tsx b/typescript/client/src/components/tables/dropdowns/components/ManageImport.tsx index 5e8c4905d..2d09fee40 100644 --- a/typescript/client/src/components/tables/dropdowns/components/ManageImport.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/ManageImport.tsx @@ -2,9 +2,9 @@ import { APIFetchV1 } from "#util/api"; import { DelayedPageReload } from "#util/misc"; import React, { useMemo, useReducer } from "react"; import { Button } from "react-bootstrap"; -import { type MONGO_ImportDocument } from "tachi-common"; +import { type ImportDocument } from "tachi-common"; -export default function ManageImport({ importDoc }: { importDoc: MONGO_ImportDocument }) { +export default function ManageImport({ importDoc }: { importDoc: ImportDocument }) { const [warn, upgWarn] = useReducer((r) => r + 1, 0); const message = useMemo(() => { if (warn === 0) { diff --git a/typescript/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx index a9d2df1ba..236cad565 100644 --- a/typescript/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx @@ -5,11 +5,11 @@ import { type SetState } from "#types/react"; import React, { useState } from "react"; import { Nav } from "react-bootstrap"; import { - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreData, - type MONGO_ScoreDocument, - type MONGO_SongDocument, + type ChartDocument, + type PBScoreDocument, + type ScoreData, + type ScoreDocument, + type SongDocument, } from "tachi-common"; type ChartType = "Bells" | "Life" | "Platinum" | "Score"; @@ -18,8 +18,8 @@ export function OngekiGraphsComponent({ score, chart, }: { - chart: MONGO_ChartDocument<"ongeki:Single">; - score: MONGO_PBScoreDocument<"ongeki:Single"> | MONGO_ScoreDocument<"ongeki:Single">; + chart: ChartDocument<"ongeki">; + score: PBScoreDocument<"ongeki"> | ScoreDocument<"ongeki">; }) { const [graph, setGraph] = useState("Score"); const available = @@ -58,13 +58,13 @@ function Inner({ }: { available: boolean; availablePlat: (number | null)[] | null | undefined; - chart: MONGO_ChartDocument<"ongeki:Single">; + chart: ChartDocument<"ongeki">; graph: ChartType; - score: MONGO_PBScoreDocument<"ongeki:Single"> | MONGO_ScoreDocument<"ongeki:Single">; + score: PBScoreDocument<"ongeki"> | ScoreDocument<"ongeki">; setGraph: SetState; }) { const { data, error } = useApiQuery<{ - song: MONGO_SongDocument<"ongeki">; + song: SongDocument<"ongeki">; }>(`/games/ongeki/Single/songs/${score.songID}`); if (error !== null || data === undefined) { return ; @@ -127,9 +127,9 @@ function GraphComponent({ song, chart, }: { - chart: MONGO_ChartDocument<"ongeki:Single">; - scoreData: MONGO_ScoreData<"ongeki:Single">; - song: MONGO_SongDocument<"ongeki">; + chart: ChartDocument<"ongeki">; + scoreData: ScoreData<"ongeki">; + song: SongDocument<"ongeki">; type: ChartType; }) { const values = diff --git a/typescript/client/src/components/tables/dropdowns/components/PBCompare.tsx b/typescript/client/src/components/tables/dropdowns/components/PBCompare.tsx index a47bdb969..3bea197b6 100644 --- a/typescript/client/src/components/tables/dropdowns/components/PBCompare.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/PBCompare.tsx @@ -1,10 +1,6 @@ import { type UGPTChartPBComposition } from "#types/api-returns"; import React from "react"; -import { - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, -} from "tachi-common"; +import { type ChartDocument, type PBScoreDocument, type ScoreDocument } from "tachi-common"; import { type ScoreState } from "../ScoreDropdown"; @@ -15,10 +11,10 @@ export default function PBCompare({ }: { data: UGPTChartPBComposition; DocComponent: (props: { - chart: MONGO_ChartDocument; + chart: ChartDocument; forceScoreData: boolean; pbData: UGPTChartPBComposition; - score: MONGO_PBScoreDocument | MONGO_ScoreDocument; + score: PBScoreDocument | ScoreDocument; scoreState: ScoreState; }) => JSX.Element; scoreState: ScoreState; diff --git a/typescript/client/src/components/tables/dropdowns/components/PlayHistory.tsx b/typescript/client/src/components/tables/dropdowns/components/PlayHistory.tsx index 2bec49693..0129e8af5 100644 --- a/typescript/client/src/components/tables/dropdowns/components/PlayHistory.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/PlayHistory.tsx @@ -3,17 +3,16 @@ import Loading from "#components/util/Loading"; import { type GamePT } from "#types/react"; import { type UnsuccessfulAPIFetchResponse } from "#util/api"; import React from "react"; -import { type MONGO_ChartDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { type ChartDocument, type ScoreDocument } from "tachi-common"; export default function PlayHistory({ data, error, game, - playtype, chart, }: { - chart: MONGO_ChartDocument; - data?: MONGO_ScoreDocument[]; + chart: ChartDocument; + data?: ScoreDocument[]; error: UnsuccessfulAPIFetchResponse | null; } & GamePT) { if (error) { @@ -26,7 +25,7 @@ export default function PlayHistory({ return (
    - +
    ); } diff --git a/typescript/client/src/components/tables/dropdowns/components/RivalCompare.tsx b/typescript/client/src/components/tables/dropdowns/components/RivalCompare.tsx index 839581ef1..dc9df0f7b 100644 --- a/typescript/client/src/components/tables/dropdowns/components/RivalCompare.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/RivalCompare.tsx @@ -10,47 +10,31 @@ import { NumericSOV } from "#util/sorts"; import React, { useContext } from "react"; import { Col } from "react-bootstrap"; import { Link } from "react-router-dom"; -import { - type GameGroup, - GetGamePTConfig, - type MONGO_ChartDocument, - type MONGO_UserDocument, - type Playtype, -} from "tachi-common"; +import { type ChartDocument, GetGameConfig, type UserDocument, type V3Game } from "tachi-common"; -export default function RivalCompare({ - chart, - game, -}: { - chart: MONGO_ChartDocument; - game: GameGroup; -}) { +export default function RivalCompare({ chart, game }: { chart: ChartDocument; game: V3Game }) { const { user: currentUser } = useContext(UserContext); - const playtype = chart.playtype; - if (!currentUser) { return
    You're not signed in. How did you even get to this page?
    ; } - return ; + return ; } function Inner({ currentUser, game, - playtype, chart, }: { - chart: MONGO_ChartDocument; - currentUser: MONGO_UserDocument; - game: GameGroup; - playtype: Playtype; + chart: ChartDocument; + currentUser: UserDocument; + game: V3Game; }) { - const base = useUGPTBase({ reqUser: currentUser, game, playtype }); + const base = useUGPTBase({ reqUser: currentUser, game }); const { data, error } = useApiQuery( - `/users/${currentUser.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals`, + `/users/${currentUser.id}/games/${game}/pbs/${chart.chartID}/rivals`, ); if (!data) { @@ -71,7 +55,7 @@ function Inner({ ); } - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); const rivalDataset: RivalChartDataset = [...data.rivals, currentUser] .map((u) => ({ @@ -83,7 +67,7 @@ function Inner({ .sort( NumericSOV( // @ts-expect-error this access is obviously legal - (x) => x.__related.pb?.scoreData[gptConfig.defaultMetric] ?? -Infinity, + (x) => x.__related.pb?.scoreData[gameConfig.defaultMetric] ?? -Infinity, true, ), ) diff --git a/typescript/client/src/components/tables/dropdowns/components/ScoreEditButtons.tsx b/typescript/client/src/components/tables/dropdowns/components/ScoreEditButtons.tsx index 81712fd5a..01bc777e8 100644 --- a/typescript/client/src/components/tables/dropdowns/components/ScoreEditButtons.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/ScoreEditButtons.tsx @@ -5,15 +5,15 @@ import { type SetState } from "#types/react"; import { APIFetchV1 } from "#util/api"; import React, { useContext, useEffect, useRef, useState } from "react"; import { Button, Form, InputGroup, Modal } from "react-bootstrap"; -import { type MONGO_ScoreDocument } from "tachi-common"; +import { type ScoreDocument } from "tachi-common"; export default function ScoreEditButtons({ score, scoreState, onScoreUpdate, }: { - onScoreUpdate?: (sc: MONGO_ScoreDocument) => void; - score: MONGO_ScoreDocument; + onScoreUpdate?: (sc: ScoreDocument) => void; + score: ScoreDocument; scoreState: { comment: string | null; highlight: boolean; diff --git a/typescript/client/src/components/tables/dropdowns/components/TargetInfo.tsx b/typescript/client/src/components/tables/dropdowns/components/TargetInfo.tsx index 70d1b5a5c..8b3f4c267 100644 --- a/typescript/client/src/components/tables/dropdowns/components/TargetInfo.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/TargetInfo.tsx @@ -10,23 +10,22 @@ import { type UnsuccessfulAPIFetchResponse } from "#util/api"; import { CreateGoalSubDataset, CreateUserMap } from "#util/data"; import React, { useState } from "react"; import { Button, Col } from "react-bootstrap"; -import { FormatChart, type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, FormatChart, type SongDocument } from "tachi-common"; export default function TargetInfo({ data, error, game, - playtype, reqUser, chart, song, onGoalSet, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; data: GoalsOnChartReturn | undefined; error: UnsuccessfulAPIFetchResponse | null; onGoalSet: () => void; - song: MONGO_SongDocument; + song: SongDocument; } & UGPT) { const [show, setShow] = useState(false); @@ -45,11 +44,11 @@ export default function TargetInfo({ return (
    -

    Your Goals involving {FormatChart(game, song, chart)}

    +

    Your Goals involving {FormatChart(chart)}

    - + @@ -59,7 +58,6 @@ export default function TargetInfo({ x.__related.song.title)], EmptyHeader, - ...GetGPTCoreHeaders( - game, - playtype, - rating, - setRating, - (x) => x.__related.pb, - ), + ...GetGPTCoreHeaders(game, rating, setRating, (x) => x.__related.pb), CreateRankingHeader( rankingViewMode, setRankingViewMode, @@ -80,7 +60,7 @@ export default function FolderTable({ rating={rating} /> )} - searchFunctions={CreateDefaultFolderSearchParams(game, playtype)} + searchFunctions={CreateDefaultFolderSearchParams(game)} /> ); } @@ -92,9 +72,9 @@ function Row({ rankingViewMode, }: { data: FolderDataset[0]; - game: GameGroup; + game: V3Game; rankingViewMode: RankingViewMode; - rating: ScoreRatingAlgorithms[GPTString]; + rating: ScoreRatingAlgorithms[V3Game]; }) { const score = data.__related.pb; @@ -128,10 +108,10 @@ function RowInner({ score, }: { data: FolderDataset[0]; - game: GameGroup; + game: V3Game; rankingViewMode: RankingViewMode; - rating: ScoreRatingAlgorithms[GPTString]; - score: MONGO_PBScoreDocument; + rating: ScoreRatingAlgorithms[V3Game]; + score: PBScoreDocument; }) { // screw the rules of hooks const scoreState = usePBState(score); @@ -142,7 +122,6 @@ function RowInner({ ( - game: GameGroup, - chartGetter: (k: D) => MONGO_ChartDocument, + game: V3Game, + chartGetter: (k: D) => ChartDocument, ): Header { return ["Chart", "Chart", (a, b) => CascadingRatingValue(game, chartGetter(a), chartGetter(b))]; } diff --git a/typescript/client/src/components/tables/headers/GameHeaders.tsx b/typescript/client/src/components/tables/headers/GameHeaders.tsx index 62566175f..870c4084c 100644 --- a/typescript/client/src/components/tables/headers/GameHeaders.tsx +++ b/typescript/client/src/components/tables/headers/GameHeaders.tsx @@ -12,14 +12,11 @@ import { NumericSOV } from "#util/sorts"; import React from "react"; import { type AnyScoreRatingAlg, - type GameGroup, - GetGamePTConfig, - GetGPTString, - type GPTString, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, - type Playtype, + GetGameConfig, + type PBScoreDocument, + type ScoreDocument, type ScoreRatingAlgorithms, + type V3Game, } from "tachi-common"; import SelectableRating from "../components/SelectableRating"; @@ -33,22 +30,21 @@ export function GetGPTCoreHeaders< | RivalChartDataset | ScoreDataset, >( - game: GameGroup, - playtype: Playtype, - rating: ScoreRatingAlgorithms[GPTString], - setRating: SetState, - kMapToScoreOrPB: (k: Dataset[0]) => MONGO_PBScoreDocument | MONGO_ScoreDocument | null, + game: V3Game, + rating: ScoreRatingAlgorithms[V3Game], + setRating: SetState, + kMapToScoreOrPB: (k: Dataset[0]) => PBScoreDocument | ScoreDocument | null, ): Header[] { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); let RatingHeader: Header; - if (Object.keys(gptConfig.scoreRatingAlgs).length === 1) { - const alg = Object.keys(gptConfig.scoreRatingAlgs)[0] as AnyScoreRatingAlg; + if (Object.keys(gameConfig.scoreRatingAlgs).length === 1) { + const alg = Object.keys(gameConfig.scoreRatingAlgs)[0] as AnyScoreRatingAlg; RatingHeader = [ - FormatGPTScoreRatingName(game, playtype, alg), - FormatGPTScoreRatingName(game, playtype, alg), + FormatGPTScoreRatingName(game, alg), + FormatGPTScoreRatingName(game, alg), NumericSOV((x) => kMapToScoreOrPB(x)?.calculatedData[alg] ?? -Infinity), ]; } else { @@ -59,8 +55,7 @@ export function GetGPTCoreHeaders< (thProps: ZTableTHProps) => ( > = []; for (const header of implHeaders) { if (header[2]) { - // replace the sort function with one that correctly resolves - // pbs outHeaders.push([ header[0], header[1], @@ -91,9 +84,6 @@ export function GetGPTCoreHeaders< return Infinity; } - // typescript is NOT happy with what i've done here - // LOL, who cares. - // safety is for chumps return (header[2] as any)(pbA, pbB); }, ]); @@ -102,7 +92,6 @@ export function GetGPTCoreHeaders< } } - // all games have a rating header after their impl header. outHeaders.push(RatingHeader); return outHeaders; diff --git a/typescript/client/src/components/tables/headers/IndicatorHeader.tsx b/typescript/client/src/components/tables/headers/IndicatorHeader.tsx index 5fe65a039..e1f5b0818 100644 --- a/typescript/client/src/components/tables/headers/IndicatorHeader.tsx +++ b/typescript/client/src/components/tables/headers/IndicatorHeader.tsx @@ -1,14 +1,14 @@ import { type FolderDataset, type PBDataset, type ScoreDataset } from "#types/tables"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { type MONGO_PBScoreDocument, type MONGO_ScoreDocument } from "tachi-common"; +import { type PBScoreDocument, type ScoreDocument } from "tachi-common"; import { type Header } from "../components/TachiTable"; const IndicatorHeader: Header = [ "Indicators", "Id.", - NumericSOV((x) => Number(x.highlight)), + NumericSOV((x) => Number(x.highlight)), () => , ]; diff --git a/typescript/client/src/components/tables/headers/RankingHeader.tsx b/typescript/client/src/components/tables/headers/RankingHeader.tsx index 24bf63488..ce58bc24b 100644 --- a/typescript/client/src/components/tables/headers/RankingHeader.tsx +++ b/typescript/client/src/components/tables/headers/RankingHeader.tsx @@ -1,7 +1,7 @@ import { type SetState } from "#types/react"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { type MONGO_PBScoreDocument } from "tachi-common"; +import { type PBScoreDocument } from "tachi-common"; import { type RankingViewMode } from "../cells/RankingCell"; import SelectableRanking from "../components/SelectableRanking"; @@ -10,7 +10,7 @@ import { type Header, type ZTableTHProps } from "../components/TachiTable"; export function CreateRankingHeader( rankingViewMode: RankingViewMode, setRankingViewMode: SetState, - kMapToRankingData: (k: T) => MONGO_PBScoreDocument["rankingData"] | undefined, + kMapToRankingData: (k: T) => PBScoreDocument["rankingData"] | undefined, ): Header { return [ "Site Ranking", diff --git a/typescript/client/src/components/tables/history-scores/HistoryScoreTable.tsx b/typescript/client/src/components/tables/history-scores/HistoryScoreTable.tsx index 94687b1de..d5f72edf5 100644 --- a/typescript/client/src/components/tables/history-scores/HistoryScoreTable.tsx +++ b/typescript/client/src/components/tables/history-scores/HistoryScoreTable.tsx @@ -3,13 +3,11 @@ import { type ScoreDataset } from "#types/tables"; import { NumericSOV } from "#util/sorts"; import React, { useState } from "react"; import { - type GameGroup, - type GPTString, + type ChartDocument, type integer, - type MONGO_ChartDocument, - type MONGO_ScoreDocument, - type Playtype, + type ScoreDocument, type ScoreRatingAlgorithms, + type V3Game, } from "tachi-common"; import DropdownIndicatorCell from "../cells/DropdownIndicatorCell"; @@ -25,21 +23,19 @@ import { EmptyHeader } from "../headers/IndicatorHeader"; export default function HistoryScoreTable({ dataset, pageLen = 10, - playtype, game, chart, }: { - chart: MONGO_ChartDocument; - dataset: MONGO_ScoreDocument[]; - game: GameGroup; + chart: ChartDocument; + dataset: ScoreDocument[]; + game: V3Game; pageLen?: integer; - playtype: Playtype; }) { - const defaultRating = useScoreRatingAlg(game, playtype); + const defaultRating = useScoreRatingAlg(game); const [rating, setRating] = useState(defaultRating); - const headers = GetGPTCoreHeaders(game, playtype, rating, setRating, (k) => k); + const headers = GetGPTCoreHeaders(game, rating, setRating, (k) => k); return ( } nested diff --git a/typescript/client/src/components/tables/pbs/PBLeadingRows.tsx b/typescript/client/src/components/tables/pbs/PBLeadingRows.tsx index abaf13645..aa37223fd 100644 --- a/typescript/client/src/components/tables/pbs/PBLeadingRows.tsx +++ b/typescript/client/src/components/tables/pbs/PBLeadingRows.tsx @@ -29,7 +29,7 @@ export default function PBLeadingRows({ <> {showUser && showChart && ( <> - + {diffCell} @@ -38,7 +38,7 @@ export default function PBLeadingRows({ {showUser && !showChart && ( <> - + )} {!showUser && showChart && ( diff --git a/typescript/client/src/components/tables/pbs/PBTable.tsx b/typescript/client/src/components/tables/pbs/PBTable.tsx index f758faf55..e9147f492 100644 --- a/typescript/client/src/components/tables/pbs/PBTable.tsx +++ b/typescript/client/src/components/tables/pbs/PBTable.tsx @@ -4,7 +4,7 @@ import { NumericSOV } from "#util/sorts"; import { CreateDefaultPBSearchParams } from "#util/tables/create-search"; import { GetPBLeadingHeaders } from "#util/tables/get-pb-leaders"; import React, { useState } from "react"; -import { type AnyScoreRatingAlg, type GameGroup, type Playtype } from "tachi-common"; +import { type AnyScoreRatingAlg, type V3Game } from "tachi-common"; import DropdownIndicatorCell from "../cells/DropdownIndicatorCell"; import IndexCell from "../cells/IndexCell"; @@ -26,23 +26,21 @@ export default function PBTable({ game, indexCol = true, showPlaycount = false, - showUser = false, showChart = true, - playtype, alg, defaultRankingViewMode, + showUser, }: { alg?: AnyScoreRatingAlg; dataset: PBDataset; defaultRankingViewMode?: RankingViewMode | null; - game: GameGroup; + game: V3Game; indexCol?: boolean; - playtype: Playtype; showChart?: boolean; showPlaycount?: boolean; showUser?: boolean; }) { - const defaultRating = useScoreRatingAlg(game, playtype); + const defaultRating = useScoreRatingAlg(game); const [rating, setRating] = useState(alg ?? defaultRating); const [rankingViewMode, setRankingViewMode] = useState( @@ -51,12 +49,12 @@ export default function PBTable({ const headers: Header[] = [ ...GetPBLeadingHeaders( - showUser, + showUser ?? false, showChart, ChartHeader(game, (k) => k.__related.chart), ), EmptyHeader, - ...GetGPTCoreHeaders(game, playtype, rating, setRating, (x) => x), + ...GetGPTCoreHeaders(game, rating, setRating, (x) => x), CreateRankingHeader(rankingViewMode, setRankingViewMode, (k) => k.rankingData), ["Last Raised", "Last Raised", NumericSOV((x) => x.timeAchieved ?? 0)], EmptyHeader, @@ -65,7 +63,6 @@ export default function PBTable({ if (showPlaycount) { headers.pop(); - // Put this just before the empty header. headers.push(["Playcount", "Plays", NumericSOV((x) => x.__playcount ?? 0)]); headers.push(EmptyHeader); } @@ -86,15 +83,14 @@ export default function PBTable({ indexCol={indexCol} key={`${pb.chartID}:${pb.userID}`} pb={pb} - playtype={playtype} rankingViewMode={rankingViewMode} rating={rating} showChart={showChart} showPlaycount={showPlaycount} - showUser={showUser} + showUser={showUser ?? false} /> )} - searchFunctions={CreateDefaultPBSearchParams(game, playtype)} + searchFunctions={CreateDefaultPBSearchParams(game)} /> ); } @@ -109,10 +105,9 @@ function Row({ rating, rankingViewMode, }: { - game: GameGroup; + game: V3Game; indexCol: boolean; pb: PBDataset[0]; - playtype: Playtype; rankingViewMode: RankingViewMode; // ts bug? rating: any; // ScoreRatingAlgorithms[I]; @@ -127,8 +122,7 @@ function Row({ dropdown={ (gptConfig.defaultMetric); + const [metric, setMetric] = useState(gameConfig.defaultMetric); useEffect(() => { - setMetric(gptConfig.defaultMetric); - }, [gptConfig]); + setMetric(gameConfig.defaultMetric); + }, [gameConfig]); const headers: Header[] = [ ChartHeader(game, (d) => d.chart), @@ -57,7 +49,7 @@ export default function ComparePBsTable({ return Infinity; } - const conf = GetScoreMetricConf(gptConfig, metric); + const conf = GetScoreMetricConf(gameConfig, metric); if (!conf) { return 0; // wut @@ -81,7 +73,7 @@ export default function ComparePBsTable({ }), (thProps: ZTableTHProps) => ( { @@ -103,22 +95,14 @@ export default function ComparePBsTable({ entryName="Charts" headers={headers} rowFunction={(data) => } - searchFunctions={CreatePBCompareSearchParams(game, playtype)} + searchFunctions={CreatePBCompareSearchParams(game)} /> ); } -function Row({ - data, - game, - metric, -}: { - data: ComparePBsDataset[0]; - game: GameGroup; - metric: string; -}) { - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, data.chart.playtype)]; - const metricConf = GetScoreMetricConf(GetGamePTConfig(game, data.chart.playtype), metric)!; +function Row({ data, game, metric }: { data: ComparePBsDataset[0]; game: V3Game; metric: string }) { + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; + const metricConf = GetScoreMetricConf(GetGameConfig(game), metric)!; return ( @@ -132,10 +116,8 @@ function Row({ {data.compare ? ( diff --git a/typescript/client/src/components/tables/rivals/RivalChartTable.tsx b/typescript/client/src/components/tables/rivals/RivalChartTable.tsx index 876a39577..0586efe5c 100644 --- a/typescript/client/src/components/tables/rivals/RivalChartTable.tsx +++ b/typescript/client/src/components/tables/rivals/RivalChartTable.tsx @@ -2,7 +2,7 @@ import useScoreRatingAlg from "#components/util/useScoreRatingAlg"; import { type RivalChartDataset } from "#types/tables"; import { NumericSOV, StrSOV } from "#util/sorts"; import React, { useState } from "react"; -import { type AnyScoreRatingAlg, type GameGroup, type MONGO_ChartDocument } from "tachi-common"; +import { type AnyScoreRatingAlg, type ChartDocument, type V3Game } from "tachi-common"; import IndexCell from "../cells/IndexCell"; import RankingCell, { type RankingViewMode } from "../cells/RankingCell"; @@ -21,13 +21,11 @@ export default function RivalChartTable({ game, chart, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; dataset: RivalChartDataset; - game: GameGroup; + game: V3Game; }) { - const playtype = chart.playtype; - - const defaultRating = useScoreRatingAlg(game, playtype); + const defaultRating = useScoreRatingAlg(game); const [rating, setRating] = useState(defaultRating); const [rankingViewMode, setRankingViewMode] = useState("global"); @@ -35,13 +33,7 @@ export default function RivalChartTable({ const headers: Header[] = [ ["#", "#", NumericSOV((x) => x.__related.index)], ["User", "User", StrSOV((x) => x.username)], - ...GetGPTCoreHeaders( - game, - playtype, - rating, - setRating, - (x) => x.__related.pb, - ), + ...GetGPTCoreHeaders(game, rating, setRating, (x) => x.__related.pb), CreateRankingHeader( rankingViewMode, setRankingViewMode, @@ -78,9 +70,9 @@ function Row({ chart, rankingViewMode, }: { - chart: MONGO_ChartDocument; + chart: ChartDocument; data: RivalChartDataset[0]; - game: GameGroup; + game: V3Game; rankingViewMode: RankingViewMode; rating: AnyScoreRatingAlg; }) { @@ -90,7 +82,7 @@ function Row({ return ( N/A - + Not Played. ); @@ -102,13 +94,13 @@ function Row({ } nested > - + void; + onScoreUpdate?: (sc: ScoreDocument) => void; pageLen?: integer; - playtype: Playtype; userCol?: boolean; }) { - const defaultRating = useScoreRatingAlg(game, playtype); + const defaultRating = useScoreRatingAlg(game); const [rating, setRating] = useState(alg ?? defaultRating); const headers: Header[] = [ ChartHeader(game, (k) => k.__related.chart), IndicatorHeader, ["Song", "Song", StrSOV((x) => x.__related.song.title)], - ...GetGPTCoreHeaders(game, playtype, rating, setRating, (k) => k), + ...GetGPTCoreHeaders(game, rating, setRating, (k) => k), ["Timestamp", "Timestamp", NumericSOV((x) => x.timeAchieved ?? 0)], EmptyHeader, ]; @@ -74,13 +70,12 @@ export default function ScoreTable({ game={game} key={sc.scoreID} onScoreUpdate={onScoreUpdate} - playtype={playtype} rating={rating as any} sc={sc} userCol={userCol} /> )} - searchFunctions={CreateDefaultScoreSearchParams(game, playtype)} + searchFunctions={CreateDefaultScoreSearchParams(game)} /> ); } @@ -88,14 +83,12 @@ export default function ScoreTable({ function Row({ sc, rating, - playtype, userCol, game, onScoreUpdate, }: { - game: GameGroup; - onScoreUpdate?: (sc: MONGO_ScoreDocument) => void; - playtype: Playtype; + game: V3Game; + onScoreUpdate?: (sc: ScoreDocument) => void; rating: AnyScoreRatingAlg; sc: ScoreDataset[0]; userCol: boolean; @@ -109,7 +102,6 @@ function Row({ chart={sc.__related.chart} game={game} onScoreUpdate={onScoreUpdate} - playtype={playtype} scoreState={scoreState} song={sc.__related.song} thisScore={sc} @@ -117,7 +109,7 @@ function Row({ /> } > - {userCol && } + {userCol && } [] = [ "Set (Index)", "Set (Idx)", (a, b) => { - const a2 = `${a.playtype} ${a.set}`; - const b2 = `${b.playtype} ${b.set}`; + const a2 = `${a.game} ${a.set}`; + const b2 = `${b.game} ${b.set}`; if (a2 === b2) { return GetBMSCourseIndex(a) - GetBMSCourseIndex(b); @@ -27,10 +27,10 @@ export const SeedsBMSCourseLookupHeaders: Header[] = [ export const SeedsBMSCourseLookupSearchFns: SearchFunctions = { title: (x) => x.title, - set: (x) => `${x.playtype} ${x.set}`, + set: (x) => `${x.game} ${x.set}`, md5: (x) => x.md5sums, value: (x) => x.value, - playtype: (x) => x.playtype, + game: (x) => x.game, // don't ask // it gets all the songtitles and joins them song: (x) => @@ -40,7 +40,7 @@ export const SeedsBMSCourseLookupSearchFns: SearchFunctions = ({ {typeof e === "string" ? ( UNKNOWN CHART ) : ( - {FormatChart("bms", e.song, e.chart)} + {FormatChart(e.chart)} )} {!compress && ( @@ -71,7 +71,7 @@ export const SeedsBMSCourseLookupCells: CellsRenderFN = ({ ))} - {data.playtype} {data.set} ({data.value}) + {data.game} {data.set} ({data.value}) ); diff --git a/typescript/client/src/components/tables/seeds/SeedsChartsTable.tsx b/typescript/client/src/components/tables/seeds/SeedsChartsTable.tsx index ee15dba9a..95d792005 100644 --- a/typescript/client/src/components/tables/seeds/SeedsChartsTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsChartsTable.tsx @@ -6,7 +6,7 @@ import { FlattenValue, StringifyKeyChain } from "#util/misc"; import { StrSOV } from "#util/sorts"; import { type SearchFunctions } from "#util/ztable/search"; import React from "react"; -import { COLOUR_SET, type GameGroup } from "tachi-common"; +import { COLOUR_SET, type V3Game } from "tachi-common"; import DifficultyCell from "../cells/DifficultyCell"; import ObjCell from "../cells/ObjCell"; @@ -14,7 +14,7 @@ import TitleCell from "../cells/TitleCell"; import { type Header } from "../components/TachiTable"; import ChartHeader from "../headers/ChartHeader"; -export function MakeSeedsChartsControls(game: GameGroup): { +export function MakeSeedsChartsControls(game: V3Game): { Cells: CellsRenderFN; headers: Header[]; searchFns: SearchFunctions; @@ -47,7 +47,7 @@ export function MakeSeedsChartsControls(game: GameGroup): { ) : ( - INVALID SONG ({data.songID}) + INVALID SONG ({data.song.id})
    diff --git a/typescript/client/src/components/tables/seeds/SeedsFolderTable.tsx b/typescript/client/src/components/tables/seeds/SeedsFolderTable.tsx index 8e9bace30..a0b5e7425 100644 --- a/typescript/client/src/components/tables/seeds/SeedsFolderTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsFolderTable.tsx @@ -6,12 +6,12 @@ import { StrSOV } from "#util/sorts"; import { type SearchFunctions } from "#util/ztable/search"; import React from "react"; import { Badge } from "react-bootstrap"; -import { FormatGameGroup, type MONGO_FolderDocument } from "tachi-common"; +import { type FolderDocument, FormatGame } from "tachi-common"; import { type Header } from "../components/TachiTable"; /** v3 `folders.json` omits `type`; infer from SQL body when needed. */ -function folderSeedKindLabel(x: MONGO_FolderDocument): string { +function folderSeedKindLabel(x: FolderDocument): string { const ext = x as unknown as { type?: string; where?: string }; if (typeof ext.type === "string") { @@ -27,20 +27,19 @@ function folderSeedKindLabel(x: MONGO_FolderDocument): string { return "charts"; } -export const SeedsFolderHeaders: Header[] = [ +export const SeedsFolderHeaders: Header[] = [ ["ID", "ID", StrSOV((x) => x.folderID)], ["Name", "Name", StrSOV((x) => x.title)], - ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], + ["GPT", "GPT", StrSOV((x) => x.game)], ["Query", "Query", StrSOV((x) => x.title)], ]; -export const SeedsFolderSearchFns: SearchFunctions = { +export const SeedsFolderSearchFns: SearchFunctions = { title: (x) => x.title, folderID: (x) => x.folderID, inactive: (x) => x.inactive, game: (x) => x.game, - playtype: (x) => x.playtype, - gpt: (x) => FormatGameGroup(x.game, x.playtype), + gpt: (x) => FormatGame(x.game), type: (x) => folderSeedKindLabel(x), query: (x) => { const w = (x as { where?: string }).where; @@ -55,10 +54,10 @@ export const SeedsFolderSearchFns: SearchFunctions = { }, }; -export const SeedsFolderCells: CellsRenderFN = ({ +export const SeedsFolderCells: CellsRenderFN = ({ data, }: { - data: MONGO_FolderDocument; + data: FolderDocument; }) => { const whereSql = (data as unknown as { where?: string }).where; const showWhere = typeof whereSql === "string" && whereSql.length > 0; @@ -83,7 +82,7 @@ export const SeedsFolderCells: CellsRenderFN = ({ )} - {FormatGameGroup(data.game, data.playtype)} + {FormatGame(data.game)} TYPE: {folderSeedKindLabel(data)} diff --git a/typescript/client/src/components/tables/seeds/SeedsGoalsTable.tsx b/typescript/client/src/components/tables/seeds/SeedsGoalsTable.tsx index 3fb6477b1..4e11dbe86 100644 --- a/typescript/client/src/components/tables/seeds/SeedsGoalsTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsGoalsTable.tsx @@ -3,24 +3,23 @@ import { FlattenValue, StringifyKeyChain } from "#util/misc"; import { StrSOV } from "#util/sorts"; import { type SearchFunctions } from "#util/ztable/search"; import React from "react"; -import { FormatGameGroup, type MONGO_GoalDocument } from "tachi-common"; +import { FormatGame, type GoalDocument } from "tachi-common"; import { type Header } from "../components/TachiTable"; -export const SeedsGoalsHeaders: Header[] = [ +export const SeedsGoalsHeaders: Header[] = [ ["ID", "ID", StrSOV((x) => x.goalID)], ["Name", "Name", StrSOV((x) => x.name)], - ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], + ["GPT", "GPT", StrSOV((x) => x.game)], ["Charts", "Charts"], ["Criteria", "Criteria"], ]; -export const SeedsGoalSearchFns: SearchFunctions = { +export const SeedsGoalSearchFns: SearchFunctions = { name: (x) => x.name, goalID: (x) => x.goalID, game: (x) => x.game, - playtype: (x) => x.playtype, - gpt: (x) => FormatGameGroup(x.game, x.playtype), + gpt: (x) => FormatGame(x.game), type: (x) => x.charts.type, charts: (x) => FlattenValue(x.charts) @@ -32,11 +31,7 @@ export const SeedsGoalSearchFns: SearchFunctions = { .join("\n"), }; -export const SeedsGoalCells: CellsRenderFN = ({ - data, -}: { - data: MONGO_GoalDocument; -}) => ( +export const SeedsGoalCells: CellsRenderFN = ({ data }: { data: GoalDocument }) => ( <> {data.goalID} @@ -44,7 +39,7 @@ export const SeedsGoalCells: CellsRenderFN = ({ {data.name} - {FormatGameGroup(data.game, data.playtype)} + {FormatGame(data.game)}
    {FlattenValue(data.charts).map((e) => ( diff --git a/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx b/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx index b51498358..2563d18da 100644 --- a/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx @@ -4,14 +4,14 @@ import { type CellsRenderFN, type QuestWithRelated } from "#types/seeds"; import { StrSOV } from "#util/sorts"; import { type SearchFunctions } from "#util/ztable/search"; import React from "react"; -import { FormatGameGroup } from "tachi-common"; +import { FormatGame } from "tachi-common"; import { type Header } from "../components/TachiTable"; export const SeedsQuestsHeaders: Header[] = [ ["ID", "ID", StrSOV((x) => x.questID)], ["Name", "Name", StrSOV((x) => x.name)], - ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], + ["GPT", "GPT", StrSOV((x) => x.game)], ["Goals", "Goals"], ]; @@ -19,8 +19,7 @@ export const SeedsQuestSearchFns: SearchFunctions = { name: (x) => x.name, questID: (x) => x.questID, game: (x) => x.game, - playtype: (x) => x.playtype, - gpt: (x) => FormatGameGroup(x.game, x.playtype), + gpt: (x) => FormatGame(x.game), goals: (x) => Object.values(x.__related.goals) .map((e) => e!.name) @@ -39,7 +38,7 @@ export const SeedsQuestCells: CellsRenderFN = ({ {data.name} - {FormatGameGroup(data.game, data.playtype)} + {FormatGame(data.game)}
    {data.questData.map((section) => ( diff --git a/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx b/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx index 0ef1fc101..3f9f4223b 100644 --- a/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx @@ -3,14 +3,14 @@ import { type CellsRenderFN, type QuestlineWithRelated } from "#types/seeds"; import { StrSOV } from "#util/sorts"; import { type SearchFunctions } from "#util/ztable/search"; import React from "react"; -import { FormatGameGroup } from "tachi-common"; +import { FormatGame } from "tachi-common"; import { type Header } from "../components/TachiTable"; export const SeedsQuestlineHeaders: Header[] = [ ["ID", "ID", StrSOV((x) => x.questlineID)], ["Name", "Name", StrSOV((x) => x.name)], - ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], + ["GPT", "GPT", StrSOV((x) => x.game)], ["Quests", "Quests"], ]; @@ -19,8 +19,7 @@ export const SeedsQuestlineSearchFns: SearchFunctions = { questlineID: (x) => x.questlineID, desc: (x) => x.desc, game: (x) => x.game, - playtype: (x) => x.playtype, - gpt: (x) => FormatGameGroup(x.game, x.playtype), + gpt: (x) => FormatGame(x.game), quests: (x) => Object.values(x.__related.quests) .filter((e) => e !== undefined) @@ -38,7 +37,7 @@ export const SeedsQuestlineCells: CellsRenderFN = ({ data
    {data.desc} - {FormatGameGroup(data.game, data.playtype)} + {FormatGame(data.game)}
    {data.quests.map((e) => { @@ -48,7 +47,7 @@ export const SeedsQuestlineCells: CellsRenderFN = ({ data
    {quest ? ( - {quest.name} ({quest.playtype}) + {quest.name} ({FormatGame(quest.game)}) ) : ( UNKNOWN QUEST {e} diff --git a/typescript/client/src/components/tables/seeds/SeedsSongsTable.tsx b/typescript/client/src/components/tables/seeds/SeedsSongsTable.tsx index ac6aaa108..51112a383 100644 --- a/typescript/client/src/components/tables/seeds/SeedsSongsTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsSongsTable.tsx @@ -1,22 +1,22 @@ import { type CellsRenderFN } from "#types/seeds"; import { FlattenValue, StringifyKeyChain } from "#util/misc"; -import { NumericSOV, StrSOV } from "#util/sorts"; +import { StrSOV } from "#util/sorts"; import { type SearchFunctions } from "#util/ztable/search"; import React from "react"; -import { type GameGroup, type MONGO_SongDocument } from "tachi-common"; +import { type SongDocument, type V3Game } from "tachi-common"; import ObjCell from "../cells/ObjCell"; import TitleCell from "../cells/TitleCell"; import { type Header } from "../components/TachiTable"; -export function MakeSeedsSongsControls(game: GameGroup): { - Cells: CellsRenderFN; - headers: Header[]; - searchFns: SearchFunctions; +export function MakeSeedsSongsControls(game: V3Game): { + Cells: CellsRenderFN; + headers: Header[]; + searchFns: SearchFunctions; } { return { headers: [ - ["ID", "ID", NumericSOV((x) => x.id)], + ["ID", "ID", StrSOV((x) => x.id)], ["Title", "Title", StrSOV((x) => x.title)], ["Data", "Data"], ], diff --git a/typescript/client/src/components/tables/seeds/SeedsTable.tsx b/typescript/client/src/components/tables/seeds/SeedsTable.tsx index 517cff334..7c6ef7f8d 100644 --- a/typescript/client/src/components/tables/seeds/SeedsTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsTable.tsx @@ -126,6 +126,10 @@ export function SeedsDiffTable({ const dataset: DiffSeedsCollection[] = []; + if (!headDSet || !baseDSet) { + return dataset; + } + for (let i = 0; i < modifiedDiffs.length; i++) { const diff = modifiedDiffs[i]!; const head = headDSet[i]!; diff --git a/typescript/client/src/components/tables/seeds/SeedsTableTable.tsx b/typescript/client/src/components/tables/seeds/SeedsTableTable.tsx index 155ebcf34..3d607522b 100644 --- a/typescript/client/src/components/tables/seeds/SeedsTableTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsTableTable.tsx @@ -4,14 +4,14 @@ import { StrSOV } from "#util/sorts"; import { type SearchFunctions } from "#util/ztable/search"; import React from "react"; import { Badge } from "react-bootstrap"; -import { FormatGameGroup } from "tachi-common"; +import { FormatGame } from "tachi-common"; import { type Header } from "../components/TachiTable"; export const SeedsTableHeaders: Header[] = [ ["ID", "ID", StrSOV((x) => x.tableID)], ["Name", "Name", StrSOV((x) => x.title)], - ["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)], + ["GPT", "GPT", StrSOV((x) => x.game)], ["Folders", "Folders", StrSOV((x) => x.title)], ]; @@ -22,8 +22,7 @@ export const SeedsTableSearchFns: SearchFunctions = { default: (x) => x.default, description: (x) => x.description, game: (x) => x.game, - playtype: (x) => x.playtype, - gpt: (x) => FormatGameGroup(x.game, x.playtype), + gpt: (x) => FormatGame(x.game), folder: (x) => Object.values(x.__related.folders) .filter((e) => e !== undefined) @@ -44,7 +43,7 @@ export const SeedsTableCells: CellsRenderFN = ({ data }) => ( {data.default && DEFAULT} {data.inactive && INACTIVE} - {FormatGameGroup(data.game, data.playtype)} + {FormatGame(data.game)}
    {data.folders.map((e) => { @@ -54,7 +53,7 @@ export const SeedsTableCells: CellsRenderFN = ({ data }) => (
    {folder ? ( - {folder.title} ({folder.playtype}) + {folder.title} ({FormatGame(folder.game)}) ) : ( UNKNOWN FOLDER {e} diff --git a/typescript/client/src/components/tables/sessions/GenericSessionTable.tsx b/typescript/client/src/components/tables/sessions/GenericSessionTable.tsx index d41971fb3..b66911583 100644 --- a/typescript/client/src/components/tables/sessions/GenericSessionTable.tsx +++ b/typescript/client/src/components/tables/sessions/GenericSessionTable.tsx @@ -6,15 +6,13 @@ import { FormatDuration, FormatTime, MillisToSince } from "#util/time"; import React, { useState } from "react"; import { Link } from "react-router-dom"; import { - type GameGroup, - GetGamePTConfig, - type GPTString, + GetGameConfig, type integer, - type MONGO_SessionDocument, - type MONGO_UserDocument, - type Playtype, + type SessionDocument, type SessionRatingAlgorithms, type SessionScoreInfo, + type UserDocument, + type V3Game, } from "tachi-common"; import IndexCell from "../cells/IndexCell"; @@ -23,26 +21,24 @@ import TachiTable, { type Header, type ZTableTHProps } from "../components/Tachi export type SessionDataset = ({ __related: { index: integer; scoreInfo: Array }; -} & MONGO_SessionDocument)[]; +} & SessionDocument)[]; export default function GenericSessionTable({ dataset, indexCol = false, reqUser, game, - playtype, }: { dataset: SessionDataset; - game: GameGroup; + game: V3Game; indexCol?: boolean; - playtype: Playtype; - reqUser: MONGO_UserDocument; + reqUser: UserDocument; }) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - const defaultRating = useSessionRatingAlg(game, playtype); + const defaultRating = useSessionRatingAlg(game); - const [alg, setAlg] = useState(defaultRating); + const [alg, setAlg] = useState(defaultRating); const headers: Header[] = [ ["Name", "Name", StrSOV((x) => x.name)], @@ -52,7 +48,7 @@ export default function GenericSessionTable({ ["Timestamp", "Timestamp", NumericSOV((x) => x.timeStarted)], ]; - if (Object.keys(gptConfig.sessionRatingAlgs).length > 1) { + if (Object.keys(gameConfig.sessionRatingAlgs).length > 1) { headers[2] = [ "Rating", "Rating", @@ -60,9 +56,8 @@ export default function GenericSessionTable({ (thProps: ZTableTHProps) => ( {indexCol && } {data.name} @@ -133,14 +128,7 @@ function Row({ {data.__related?.scoreInfo && `PBs: ${GetPBs(data.__related.scoreInfo).length}`} - - {FormatGPTSessionRating( - data.game, - data.playtype, - ratingAlg, - data.calculatedData[ratingAlg], - )} - + {FormatGPTSessionRating(game, ratingAlg, data.calculatedData[ratingAlg])} {FormatDuration(data.timeEnded - data.timeStarted)} {MillisToSince(data.timeStarted)} diff --git a/typescript/client/src/components/targets/AddNewGoalForQuestModal.tsx b/typescript/client/src/components/targets/AddNewGoalForQuestModal.tsx index cf7e0d2bf..71305ea02 100644 --- a/typescript/client/src/components/targets/AddNewGoalForQuestModal.tsx +++ b/typescript/client/src/components/targets/AddNewGoalForQuestModal.tsx @@ -5,21 +5,18 @@ import { type SongChartsSearch } from "#types/api-returns"; import { type GamePT, type SetState } from "#types/react"; import { type RawQuestGoal } from "#types/tachi"; import { APIFetchV1 } from "#util/api"; -import { CreateSongMap } from "#util/data"; import { StrSOV } from "#util/sorts"; import React, { useEffect, useState } from "react"; import { Button, Col, Form, InputGroup, Modal, Row } from "react-bootstrap"; import { type GroupBase, type OptionsOrGroups } from "react-select"; import { + type ChartDocument, + type FolderDocument, FormatChart, - GetGamePTConfig, - GetGPTString, + GetGameConfig, GetScoreMetricConf, - type GPTString, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_GoalDocument, - type MONGO_SongDocument, + type GoalDocument, + type SongDocument, } from "tachi-common"; import { type ConfEnumScoreMetric } from "tachi-common/types/metrics"; @@ -29,7 +26,6 @@ export default function AddNewGoalForQuestModal({ show, setShow, game, - playtype, onCreate, noNote = false, initialState, @@ -40,22 +36,22 @@ export default function AddNewGoalForQuestModal({ setShow: SetState; show: boolean; } & GamePT) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); const enumConf = GetScoreMetricConf( - gptConfig, - gptConfig.preferredDefaultEnum, + gameConfig, + gameConfig.preferredDefaultEnum, ) as ConfEnumScoreMetric; - const [criteria, setCriteria] = useState( + const [criteria, setCriteria] = useState( initialState?.goal.criteria ?? { mode: "single", - key: gptConfig.preferredDefaultEnum, + key: gameConfig.preferredDefaultEnum, value: enumConf.values.indexOf(enumConf.minimumRelevantValue), }, ); - const [charts, setCharts] = useState( + const [charts, setCharts] = useState( initialState?.goal.charts ?? ({ type: "single", @@ -75,7 +71,7 @@ export default function AddNewGoalForQuestModal({ } try { - APIFetchV1(`/games/${game}/${playtype}/targets/goals/format`, { + APIFetchV1(`/games/${game}/targets/goals/format`, { method: "POST", body: JSON.stringify({ criteria, charts }), headers: { "Content-Type": "application/json" }, @@ -123,7 +119,6 @@ export default function AddNewGoalForQuestModal({ charts={charts} game={game} onChange={(newCharts) => setCharts(newCharts)} - playtype={playtype} /> @@ -131,7 +126,6 @@ export default function AddNewGoalForQuestModal({ charts={charts} criteria={criteria} game={game} - playtype={playtype} setCriteria={setCriteria} /> @@ -160,7 +154,7 @@ export default function AddNewGoalForQuestModal({ } onClick={async () => { const res = await APIFetchV1( - `/games/${game}/${playtype}/targets/goals/format`, + `/games/${game}/targets/goals/format`, { headers: { "Content-Type": "application/json", @@ -201,13 +195,12 @@ export default function AddNewGoalForQuestModal({ function RenderGoalChartPicker({ charts, game, - playtype, onChange, }: { - charts: MONGO_GoalDocument["charts"]; - onChange: (charts: MONGO_GoalDocument["charts"]) => void; + charts: GoalDocument["charts"]; + onChange: (charts: GoalDocument["charts"]) => void; } & GamePT) { - const [type, setType] = useState(charts.type); + const [type, setType] = useState(charts.type); // hackily declaring this as any because type and chartInfo are technically disjoint // however, due to the code, these will always be in sync. @@ -255,18 +248,18 @@ function RenderGoalChartPicker({
    {type === "folder" ? ( - + ) : type === "single" ? ( - + ) : ( - + )}
    ); } -function FolderSelect({ game, playtype, onChange }: { onChange: (data: string) => void } & GamePT) { +function FolderSelect({ game, onChange }: { onChange: (data: string) => void } & GamePT) { let lastTimeout: number | null = null; const loadFolderOptions = ( @@ -279,8 +272,8 @@ function FolderSelect({ game, playtype, onChange }: { onChange: (data: string) = // debounce this query to only run after 300ms of no more user input. lastTimeout = window.setTimeout(async () => { - const res = await APIFetchV1>( - `/games/${game}/${playtype}/folders?search=${input}`, + const res = await APIFetchV1>( + `/games/${game}/folders?search=${input}`, ); if (!res.success) { throw new Error(res.description); @@ -309,7 +302,6 @@ function FolderSelect({ game, playtype, onChange }: { onChange: (data: string) = function ChartSelect({ game, - playtype, multi = false, onChange, }: { multi?: boolean; onChange: (data: string | string[]) => void } & GamePT) { @@ -326,15 +318,15 @@ function ChartSelect({ // debounce this query to only run after 300ms of no more user input. lastTimeout = window.setTimeout(async () => { const res = await APIFetchV1( - `/games/${game}/${playtype}/charts?search=${encodeURIComponent(input)}`, + `/games/${game}/charts?search=${encodeURIComponent(input)}`, ); const res2 = await APIFetchV1<{ charts: Record< - GPTString, + string, { - chart: MONGO_ChartDocument; - song: MONGO_SongDocument; + chart: ChartDocument; + song: SongDocument; }[] >; }>(`/search/chart-hash?search=${encodeURIComponent(input)}`); @@ -343,13 +335,11 @@ function ChartSelect({ throw new Error(res.description); } - const res2Data = res2.body.charts[GetGPTString(game, playtype)] ?? []; - - const songMap = CreateSongMap([...res.body.songs, ...res2Data.map((e) => e.song)]); + const res2Data = res2.body.charts[game] ?? []; const options = [...res.body.charts, ...res2Data.map((e) => e.chart)].map((e) => ({ value: e.chartID, - label: FormatChart(game, songMap.get(e.songID)!, e), + label: FormatChart(e), })); options.sort(StrSOV((x) => x.label)); diff --git a/typescript/client/src/components/targets/AddNewQuestModal.tsx b/typescript/client/src/components/targets/AddNewQuestModal.tsx index 67d137efa..57a212643 100644 --- a/typescript/client/src/components/targets/AddNewQuestModal.tsx +++ b/typescript/client/src/components/targets/AddNewQuestModal.tsx @@ -6,11 +6,13 @@ import { type RawQuestDocument } from "#types/tachi"; import React, { useState } from "react"; import { Button, Col, Modal, Row } from "react-bootstrap"; import { - FormatGameGroup, + FormatGame, type GameGroup, GetGameGroupConfig, - type GPTString, - type Playtype, + LEGACY_GameGroupPTToGame, + type LEGACY_GPTString, + type LEGACY_Playtype, + type V3Game, } from "tachi-common"; export default function AddNewQuestModal({ @@ -22,7 +24,7 @@ export default function AddNewQuestModal({ setShow: SetState; show: boolean; }) { - const [gpt, setGPT] = useState(null); + const [gpt, setGPT] = useState(null); return ( setShow(false)} show={show} size="xl"> @@ -39,7 +41,7 @@ export default function AddNewQuestModal({ unselectedName="Select a game..." value={gpt} > - {TachiConfig.GAMES.flatMap((game) => { + {TachiConfig.GAME_GROUPS.flatMap((game) => { const gameConfig = GetGameGroupConfig(game); return gameConfig.playtypes.map((playtype) => ( @@ -47,7 +49,7 @@ export default function AddNewQuestModal({ key={`${game}:${playtype}`} value={`${game}:${playtype}`} > - {FormatGameGroup(game, playtype)} + {FormatGame(LEGACY_GameGroupPTToGame(game, playtype))} )); })} @@ -63,10 +65,13 @@ export default function AddNewQuestModal({ } setShow(false); - const [game, playtype] = gpt.split(":") as [GameGroup, Playtype]; + const [game, playtype] = gpt.split(":") as [ + GameGroup, + LEGACY_Playtype, + ]; + const v3Game: V3Game = LEGACY_GameGroupPTToGame(game, playtype); onCreate({ - game, - playtype, + game: v3Game, name: "Untitled Quest", desc: "Please set a description.", rawQuestData: [], diff --git a/typescript/client/src/components/targets/Questline.tsx b/typescript/client/src/components/targets/Questline.tsx index 13c8639a2..5b8db6d0a 100644 --- a/typescript/client/src/components/targets/Questline.tsx +++ b/typescript/client/src/components/targets/Questline.tsx @@ -11,16 +11,16 @@ import { GetGoalIDsFromQuest } from "#util/data"; import { FormatTime } from "#util/time"; import React, { useContext } from "react"; import { Button, Col } from "react-bootstrap"; -import { type MONGO_QuestDocument, type MONGO_QuestlineDocument } from "tachi-common"; +import { type QuestDocument, type QuestlineDocument } from "tachi-common"; export default function Questline({ questline, quests, }: { - questline: MONGO_QuestlineDocument; - quests?: Map; + questline: QuestlineDocument; + quests?: Map; }) { - const { game, playtype } = questline; + const v3Game = questline.game; return ( - + View Quests
    @@ -75,11 +73,11 @@ export default function Questline({ ); } -export function InnerQuestInfo({ quest }: { quest: MONGO_QuestDocument }) { +export function InnerQuestInfo({ quest }: { quest: QuestDocument }) { const { settings } = useLUGPTSettings(); const { questSubs, reloadTargets } = useContext(TargetsContext); - const { game, playtype } = quest; + const v3Game = quest.game; const questSub = questSubs.get(quest.questID); if (!questSub) { @@ -102,7 +100,7 @@ export function InnerQuestInfo({ quest }: { quest: MONGO_QuestDocument }) { + + ) : ( + + Change the displayed stats to the same ones you use! + + } + placement="top" + > + + + ))} + Evaluate a one-off stat. + } + placement="top" + > + +
    } header={ @@ -123,15 +121,13 @@ export default function UGPTStatShowcase({ reqUser, game, playtype }: UGPT) { userIsReqUser ? ( ) : null } > -

    +

    {projectingStats ? `${user!.username}'s Stat Showcase (Projected onto ${ reqUser.username @@ -144,7 +140,7 @@ export default function UGPTStatShowcase({ reqUser, game, playtype }: UGPT) { { const res = await APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}/showcase${ + `/users/${reqUser.id}/games/${game}/showcase${ projectingStats ? `?projectUser=${user!.id}` : "" }`, ); @@ -155,7 +151,7 @@ export default function UGPTStatShowcase({ reqUser, game, playtype }: UGPT) { if (shouldFetchThisUserData) { const res2 = await APIFetchV1( - `/users/${user!.id}/games/${game}/${playtype}/showcase${ + `/users/${user!.id}/games/${game}/showcase${ !projectingStats ? `?projectUser=${reqUser.id}` : "" }`, ); @@ -171,61 +167,65 @@ export default function UGPTStatShowcase({ reqUser, game, playtype }: UGPT) { }} > {(data) => ( -
    +
    {customStat ? ( -
    -
    - - CUSTOM STAT{" "} - - setCustomStat(null)} - type="times" - /> +
    +
    + + + One-off preview + - +
    + +
    - +
    ) : ( <> )} {data.reqUserData.length === 0 ? ( -
    -
    +
    +

    {" "} - no stats configured. -

    + not configured showcase stats yet. +

    {userIsReqUser && ( -
    - Why not{" "} +

    - Set Some? + Configure them in settings -

    +

    )}
    ) : ( -
    +
    {data.reqUserData.map((e, i) => ( -
    +
    @@ -248,7 +247,6 @@ export default function UGPTStatShowcase({ reqUser, game, playtype }: UGPT) { setCustomStat(stat)} - playtype={playtype} reqUser={reqUser} setShow={setCustomShow} show={customShow} @@ -263,12 +261,10 @@ function StatDelta({ mode, metric: property, game, - playtype, }: { - game: GameGroup; + game: V3Game; metric: string; mode: "chart" | "folder"; - playtype: Playtype; v1: number | null; v2?: number | null; }) { @@ -282,7 +278,7 @@ function StatDelta({ v1 = 0; } - const formattedV2 = FormatValue(game, playtype, mode, property, v2); + const formattedV2 = FormatValue(game, mode, property, v2); let colour; if (v2 === v1) { @@ -303,21 +299,25 @@ function StatDelta({ property === "playcount" || mode === "folder" ) { - const d = FormatValue(game, playtype, mode, property, v2 - v1); + const d = FormatValue(game, mode, property, v2 - v1); delta = ` (${v2 > v1 ? `+${d}` : v2 === v1 ? `±${d}` : d})`; } return ( -
    - You: {formattedV2} - {delta} +
    + You: + + {formattedV2} + {delta} +
    ); } export function FormatValue( - game: GameGroup, - playtype: Playtype, + game: V3Game, mode: "chart" | "folder", metric: string, value: number | null, @@ -326,8 +326,8 @@ export function FormatValue( return value; } - const gptConfig = GetGamePTConfig(game, playtype); - const conf = GetScoreMetricConf(gptConfig, metric); + const gameConfig = GetGameConfig(game); + const conf = GetScoreMetricConf(gameConfig, metric); if (!conf) { return "UNKNOWN METRIC"; @@ -352,14 +352,14 @@ export function FormatValue( export function GetStatName( stat: ShowcaseStatDetails, - game: GameGroup, + game: V3Game, related: UGPTPreferenceStatsReturn["related"], ) { if (stat.mode === "folder") { - return (related as { folder: MONGO_FolderDocument }).folder.title; + return (related as { folder: FolderDocument }).folder.title; } else if (stat.mode === "chart") { - const r = related as { chart: MONGO_ChartDocument; song: MONGO_SongDocument }; - return FormatChart(game, r.song, r.chart); + const r = related as { chart: ChartDocument; song: SongDocument }; + return FormatChart(r.chart); } // @ts-expect-error yeah it's an error state lol @@ -371,85 +371,147 @@ export function StatDisplay({ reqUser, compareData, game, - playtype, }: { compareData?: UGPTPreferenceStatsReturn; - reqUser: MONGO_UserDocument; + reqUser: UserDocument; statData: UGPTPreferenceStatsReturn; } & GamePT) { - const { stat, result, related } = statData; const { user } = useContext(UserContext); - if (stat.mode === "chart") { - const { song, chart } = related as { chart: MONGO_ChartDocument; song: MONGO_SongDocument }; + if (statData.stat.mode === "chart") { + const { stat, result, related } = statData as UGPTPreferenceChartStatsReturn; + const { song, chart } = related; + const { playcount, pb } = result; + + const compareChart = + user && user.id !== reqUser.id && compareData?.stat.mode === "chart" + ? (compareData as UGPTPreferenceChartStatsReturn) + : undefined; return ( Chart

    } + cardBodyClassName="d-flex flex-column gap-3 text-center" + className="h-100 stat-overview-card text-center w-100" + header={
    Chart
    } > - <> - -

    {FormatChart(game, song, chart, true)}

    +
    + + {FormatChart(chart)} -

    - {UppercaseFirst(stat.metric)}:{" "} - {FormatValue(game, playtype, stat.mode, stat.metric, result.value)} -

    - {user && user.id !== reqUser.id && ( +
    + Playcount + {playcount} +
    +
    + {pb ? ( +
    + + + + + +
    + ) : ( +

    Not played

    + )} + {compareChart && ( +
    - )} - +
    +
    + Your PB +
    + {compareChart.result.pb ? ( +
    + + + + + +
    + ) : ( +

    + Not played +

    + )} +
    +
    + )} ); - } else if (stat.mode === "folder") { - const { folder } = related as { folder: MONGO_FolderDocument }; + } + + if (statData.stat.mode === "folder") { + const { stat, result, related } = statData as UGPTPreferenceFolderStatsReturn; + const { folder } = related; const headerStr = folder.title; return ( Folder

    } + cardBodyClassName="d-flex flex-column gap-3 text-center" + className="h-100 stat-overview-card text-center w-100" + header={
    Folder
    } > - <> +
    -

    {headerStr}

    + {headerStr} -
    - {UppercaseFirst(stat.metric)} >={" "} +

    + {UppercaseFirst(stat.metric)} ≥{" "} {/* basically, FormatValue is being used for two different things here: formatting Score >= 900000 for folders, and also displaying counts of how scores in this folder match that thing. Obviously, these should get different functions, but i don't care, and you don't either, because nobody will ever read this comment, or this code, or ever care. it's fine. Everything is OK. */} - {FormatValue(game, playtype, "chart", stat.metric, stat.gte)} -

    -

    - {result.value} - - {/* @ts-expect-error This property definitely exists.*/} - {/* */}/{result.outOf} ({ToPercent(result.value, result.outOf)}) - -

    + {FormatValue(game, "chart", stat.metric, stat.gte)} +

    +
    +
    +
    + {result.value} + + / {result.outOf}{" "} + ({ToPercent(result.value, result.outOf)}) + +
    +
    - {user && user.id !== reqUser.id && ( + {user && user.id !== reqUser.id && ( +
    - )} - +
    + )} ); } diff --git a/typescript/client/src/components/user/UGPTStatsOverview.tsx b/typescript/client/src/components/user/UGPTStatsOverview.tsx index 3ec8484ec..4cc9e6dd1 100644 --- a/typescript/client/src/components/user/UGPTStatsOverview.tsx +++ b/typescript/client/src/components/user/UGPTStatsOverview.tsx @@ -12,21 +12,22 @@ import { StrSOV } from "#util/sorts"; import React from "react"; import { type Classes, - GetGamePTConfig, - type GPTString, - type MONGO_UserGameStats, + GetGameConfig, type ProfileRatingAlgorithms, + type UserGameStats, + type V3Game, } from "tachi-common"; -export default function UGPTRatingsTable({ ugs }: { ugs: MONGO_UserGameStats }) { - const gptConfig = GetGamePTConfig(ugs.game, ugs.playtype); +export default function UGPTRatingsTable({ ugs }: { ugs: UserGameStats }) { + const game = ugs.game; + const gameConfig = GetGameConfig(game); - const ratings = Object.entries(ugs.ratings) as [ProfileRatingAlgorithms[GPTString], number][]; + const ratings = Object.entries(ugs.ratings) as [ProfileRatingAlgorithms[V3Game], number][]; return ( <> - {(Object.keys(gptConfig.classes) as Classes[GPTString][]) + {(Object.keys(gameConfig.classes) as Classes[V3Game][]) .sort(StrSOV((x) => x[0])) .filter((k) => ugs.classes[k] !== undefined) .map((k) => ( @@ -36,9 +37,8 @@ export default function UGPTRatingsTable({ ugs }: { ugs: MONGO_UserGameStats }) @@ -50,23 +50,18 @@ export default function UGPTRatingsTable({ ugs }: { ugs: MONGO_UserGameStats }) - {gptConfig.profileRatingAlgs[k].description} - {(gptConfig.profileRatingAlgs[k].associatedScoreAlgs ?? []) + {gameConfig.profileRatingAlgs[k].description} + {(gameConfig.profileRatingAlgs[k].associatedScoreAlgs ?? []) .length > 0 && ( <> )} - {gptConfig.profileRatingAlgs[k].associatedScoreAlgs?.map( + {gameConfig.profileRatingAlgs[k].associatedScoreAlgs?.map( (alg) => (
    - ( - {FormatGPTScoreRatingName( - ugs.game, - ugs.playtype, - alg, - )} - : {gptConfig.scoreRatingAlgs[alg].description}) + ({FormatGPTScoreRatingName(game, alg)}:{" "} + {gameConfig.scoreRatingAlgs[alg].description})
    ), )} @@ -80,11 +75,11 @@ export default function UGPTRatingsTable({ ugs }: { ugs: MONGO_UserGameStats }) textDecorationStyle: "dotted", }} > - {FormatGPTProfileRatingName(ugs.game, ugs.playtype, k)} + {FormatGPTProfileRatingName(game, k)}
    - {FormatGPTProfileRating(ugs.game, ugs.playtype, k as any, v)} + {FormatGPTProfileRating(game, k as any, v)} ))} diff --git a/typescript/client/src/components/user/UserHeader.tsx b/typescript/client/src/components/user/UserHeader.tsx index d73b2fb4f..31a90893d 100644 --- a/typescript/client/src/components/user/UserHeader.tsx +++ b/typescript/client/src/components/user/UserHeader.tsx @@ -11,12 +11,12 @@ import { APIFetchV1 } from "#util/api"; import { FormatDate } from "#util/time"; import React, { useContext, useState } from "react"; import { Button, Form, InputGroup, Modal } from "react-bootstrap"; -import { type MONGO_UserDocument, UserAuthLevels } from "tachi-common"; +import { UserAuthLevels, type UserDocument } from "tachi-common"; import ProfileBadges from "./ProfileBadges"; import ProfilePicture from "./ProfilePicture"; -export function UserHeaderBody({ reqUser }: { reqUser: MONGO_UserDocument }) { +export function UserHeaderBody({ reqUser }: { reqUser: UserDocument }) { function ConditionalSocialMediaRender({ mode, href, @@ -124,13 +124,7 @@ export function UserHeaderBody({ reqUser }: { reqUser: MONGO_UserDocument }) { ); } -export function UserBottomNav({ - baseUrl, - reqUser, -}: { - baseUrl: string; - reqUser: MONGO_UserDocument; -}) { +export function UserBottomNav({ baseUrl, reqUser }: { baseUrl: string; reqUser: UserDocument }) { const { user } = useContext(UserContext); const isRequestedUser = !!(user && user.id === reqUser.id); @@ -177,7 +171,7 @@ export function UserBottomNav({ return {navItems}; } -function StatusComponent({ reqUser }: { reqUser: MONGO_UserDocument }) { +function StatusComponent({ reqUser }: { reqUser: UserDocument }) { const { user } = useContext(UserContext); const isRequestedUser = user?.id === reqUser.id; @@ -224,7 +218,7 @@ function ChangeStatusModal({ reqUser, }: { modalShow: boolean; - reqUser: MONGO_UserDocument; + reqUser: UserDocument; setModalShow: SetState; }) { const [status, setStatus] = useState(reqUser.status); diff --git a/typescript/client/src/components/util/FollowUserButton.tsx b/typescript/client/src/components/util/FollowUserButton.tsx index 8b6e29187..980c70179 100644 --- a/typescript/client/src/components/util/FollowUserButton.tsx +++ b/typescript/client/src/components/util/FollowUserButton.tsx @@ -3,9 +3,9 @@ import { UserSettingsContext } from "#context/UserSettingsContext"; import { APIFetchV1 } from "#util/api"; import React, { useContext } from "react"; import { Button } from "react-bootstrap"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; -export default function FollowUserButton({ userToFollow }: { userToFollow: MONGO_UserDocument }) { +export default function FollowUserButton({ userToFollow }: { userToFollow: UserDocument }) { const { settings: userSettings, setSettings: setUserSettings } = useContext(UserSettingsContext); diff --git a/typescript/client/src/components/util/GoalLink.tsx b/typescript/client/src/components/util/GoalLink.tsx index 955a4ec68..b49d4b042 100644 --- a/typescript/client/src/components/util/GoalLink.tsx +++ b/typescript/client/src/components/util/GoalLink.tsx @@ -1,13 +1,14 @@ import React from "react"; import { Link } from "react-router-dom"; -import { type MONGO_GoalDocument } from "tachi-common"; +import { type GoalDocument } from "tachi-common"; import useLUGPTSettings from "./useLUGPTSettings"; -export default function GoalLink({ goal, noPad }: { goal: MONGO_GoalDocument; noPad?: boolean }) { +export default function GoalLink({ goal, noPad }: { goal: GoalDocument; noPad?: boolean }) { const { settings } = useLUGPTSettings(); const pad = noPad ? "" : "ms-2"; + const v3Game = goal.game; switch (goal.charts.type) { case "multi": @@ -16,23 +17,28 @@ export default function GoalLink({ goal, noPad }: { goal: MONGO_GoalDocument; no return ( {goal.name} ); - case "folder": + case "folder": { if (!settings) { return {goal.name}; } + const folderPath = + "folderSlug" in goal.charts && goal.charts.folderSlug !== undefined + ? goal.charts.folderSlug + : goal.charts.data; return ( {goal.name} ); + } } } diff --git a/typescript/client/src/components/util/OAuthMoreInfo.tsx b/typescript/client/src/components/util/OAuthMoreInfo.tsx index b813ac80d..2537f341d 100644 --- a/typescript/client/src/components/util/OAuthMoreInfo.tsx +++ b/typescript/client/src/components/util/OAuthMoreInfo.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { type MONGO_TachiAPIClientDocument, type MONGO_UserDocument } from "tachi-common"; +import { type TachiAPIClientDocument, type UserDocument } from "tachi-common"; import ApiError from "./ApiError"; import Loading from "./Loading"; @@ -9,9 +9,9 @@ import useApiQuery from "./query/useApiQuery"; export default function OAuthMoreInfo({ client, }: { - client: Omit; + client: Omit; }) { - const { data, error } = useApiQuery(`/users/${client.author}`); + const { data, error } = useApiQuery(`/users/${client.author}`); if (error) { return ; diff --git a/typescript/client/src/components/util/ReferToUser.tsx b/typescript/client/src/components/util/ReferToUser.tsx index 5340c6bab..51c82fa18 100644 --- a/typescript/client/src/components/util/ReferToUser.tsx +++ b/typescript/client/src/components/util/ReferToUser.tsx @@ -1,8 +1,8 @@ import { UserContext } from "#context/UserContext"; import React, { useContext } from "react"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; -export default function ReferToUser({ reqUser }: { reqUser: MONGO_UserDocument }) { +export default function ReferToUser({ reqUser }: { reqUser: UserDocument }) { const { user } = useContext(UserContext); return <>{user?.id === reqUser.id ? "You have" : `${reqUser.username} has`}; diff --git a/typescript/client/src/components/util/UpdateUserGameStats.tsx b/typescript/client/src/components/util/UpdateUserGameStats.tsx index 0df443348..5485e4399 100644 --- a/typescript/client/src/components/util/UpdateUserGameStats.tsx +++ b/typescript/client/src/components/util/UpdateUserGameStats.tsx @@ -1,9 +1,9 @@ import { type SetState } from "#types/react"; import { APIFetchV1 } from "#util/api"; -import { type MONGO_UserGameStats } from "tachi-common"; +import { type UserGameStats } from "tachi-common"; -export default async function UpdateUserGameStats(setUGS: SetState) { - const res = await APIFetchV1("/users/me/game-stats"); +export default async function UpdateUserGameStats(setUGS: SetState) { + const res = await APIFetchV1("/users/me/game-stats"); if (!res.success) { setUGS(null); diff --git a/typescript/client/src/components/util/UserIcon.tsx b/typescript/client/src/components/util/UserIcon.tsx index ced4284de..61fffe0e6 100644 --- a/typescript/client/src/components/util/UserIcon.tsx +++ b/typescript/client/src/components/util/UserIcon.tsx @@ -2,25 +2,18 @@ import ProfilePicture from "#components/user/ProfilePicture"; import { type GamePT, type JustChildren } from "#types/react"; import React from "react"; import { Link } from "react-router-dom"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; export default function UserIcon({ user, children, game, - playtype, -}: { user: MONGO_UserDocument } & Partial & Partial) { +}: { user: UserDocument } & Partial & Partial) { return (
    - +

    - + {user.username}

    diff --git a/typescript/client/src/components/util/import/useImport.ts b/typescript/client/src/components/util/import/useImport.ts index f98934018..28d44b112 100644 --- a/typescript/client/src/components/util/import/useImport.ts +++ b/typescript/client/src/components/util/import/useImport.ts @@ -1,7 +1,7 @@ import { type ImportIDReturn } from "#types/api-returns"; import { type ImportStates, NotStartedState } from "#types/import"; import { useState } from "react"; -import { type integer, type MONGO_ImportDocument } from "tachi-common"; +import { type ImportDocument, type integer } from "tachi-common"; /* eslint-disable no-await-in-loop */ import { APIFetchV1 } from "#util/api"; import { Sleep } from "#util/misc"; @@ -13,7 +13,7 @@ export interface ImportDeferred { export type ImportPollStatus = | { - import: MONGO_ImportDocument; + import: ImportDocument; importStatus: "completed"; } | { @@ -30,7 +30,7 @@ export default function useImport(url: string, options: RequestInit) { const runImport = async (overrideOptions?: RequestInit) => { setImportState({ state: "waiting_init" }); - const initRes = await APIFetchV1( + const initRes = await APIFetchV1( url, overrideOptions ?? options, ); diff --git a/typescript/client/src/components/util/modal/UserSelectModal.tsx b/typescript/client/src/components/util/modal/UserSelectModal.tsx index 09c580018..330c8c4d0 100644 --- a/typescript/client/src/components/util/modal/UserSelectModal.tsx +++ b/typescript/client/src/components/util/modal/UserSelectModal.tsx @@ -3,7 +3,7 @@ import { type SetState } from "#types/react"; import { APIFetchV1 } from "#util/api"; import React, { useEffect, useState } from "react"; import { Button, Col, Modal, Row } from "react-bootstrap"; -import { type integer, type MONGO_UserDocument } from "tachi-common"; +import { type integer, type UserDocument } from "tachi-common"; import DebounceSearch from "../DebounceSearch"; import Divider from "../Divider"; @@ -16,7 +16,7 @@ export default function UserSelectModal({ excludeSet = [], excludeMsg = "N/A", }: { - callback: (user: MONGO_UserDocument) => void; + callback: (user: UserDocument) => void; excludeMsg?: string; excludeSet?: Array; setShow: SetState; @@ -24,7 +24,7 @@ export default function UserSelectModal({ url?: string; }) { const [search, setSearch] = useState(""); - const [users, setUsers] = useState | null>(null); + const [users, setUsers] = useState | null>(null); const [errMsg, setErrMsg] = useState(null); useEffect(() => { @@ -35,7 +35,7 @@ export default function UserSelectModal({ const searchParams = new URLSearchParams(); searchParams.set("search", search); - APIFetchV1(`${url}?${searchParams.toString()}`).then((res) => { + APIFetchV1(`${url}?${searchParams.toString()}`).then((res) => { if (res.success) { setErrMsg(null); diff --git a/typescript/client/src/components/util/query/fetchUGPTData.ts b/typescript/client/src/components/util/query/fetchUGPTData.ts index 6142b5f8e..bf5cad97b 100644 --- a/typescript/client/src/components/util/query/fetchUGPTData.ts +++ b/typescript/client/src/components/util/query/fetchUGPTData.ts @@ -1,12 +1,11 @@ import { type UGPTStatsReturn } from "#types/api-returns"; import { APIFetchV1 } from "#util/api"; import { - FormatGameGroup, - type GameGroup, - type MONGO_UGPTSettingsDocument, - type MONGO_UserDocument, - type MONGO_UserGameStats, - type Playtype, + FormatGame, + type UGPTSettingsDocument, + type UserDocument, + type UserGameStats, + type V3Game, } from "tachi-common"; /** @@ -15,11 +14,10 @@ import { * It's worth keeping this around for both the currently-viewed user and the currently-logged-in user. */ export interface UGPTData { - settings: MONGO_UGPTSettingsDocument | null; - stats: MONGO_UserGameStats; - game: GameGroup; - playtype: Playtype; - user: MONGO_UserDocument; + settings: UGPTSettingsDocument | null; + stats: UserGameStats; + game: V3Game; + user: UserDocument; } /** @@ -28,12 +26,9 @@ export interface UGPTData { */ export default async function fetchUGPTData( userID: number | string, - game: GameGroup, - playtype: Playtype, + game: V3Game, ): Promise { - const statsRes = await APIFetchV1( - `/users/${userID}/games/${game}/${playtype}`, - ); + const statsRes = await APIFetchV1(`/users/${userID}/games/${game}`); // user doesn't exist or something? if (statsRes.statusCode === 404) { @@ -42,25 +37,23 @@ export default async function fetchUGPTData( if (!statsRes.success) { throw new Error( - `Failed to fetch data for ${userID} (${FormatGameGroup(game, playtype)}): ${ - statsRes.description - }`, + `Failed to fetch data for ${userID} (${FormatGame(game)}): ${statsRes.description}`, ); } - const settingsRes = await APIFetchV1( - `/users/${userID}/games/${game}/${playtype}/settings`, + const settingsRes = await APIFetchV1( + `/users/${userID}/games/${game}/settings`, ); if (!settingsRes.success) { throw new Error( - `Failed to fetch settings for ${userID} (${FormatGameGroup(game, playtype)}): ${ + `Failed to fetch settings for ${userID} (${FormatGame(game)}): ${ settingsRes.description }`, ); } - const userRes = await APIFetchV1(`/users/${userID}`); + const userRes = await APIFetchV1(`/users/${userID}`); if (!userRes.success) { throw new Error(`Failed to fetch user info for ${userID}: ${userRes.description}`); @@ -68,7 +61,6 @@ export default async function fetchUGPTData( return { game, - playtype, settings: settingsRes.body, stats: statsRes.body.gameStats, user: userRes.body, diff --git a/typescript/client/src/components/util/query/useApiQuery.tsx b/typescript/client/src/components/util/query/useApiQuery.tsx index 850803981..af5552df7 100644 --- a/typescript/client/src/components/util/query/useApiQuery.tsx +++ b/typescript/client/src/components/util/query/useApiQuery.tsx @@ -2,6 +2,10 @@ import { APIFetchV1, type UnsuccessfulAPIFetchResponse } from "#util/api"; import { type QueryKey, useQuery } from "react-query"; import { type SuccessfulAPIResponse } from "tachi-common"; +export function UnfuckURLsV3GameToV2Format(url: string) { + return url; +} + export default function useApiQuery( url: string | string[], options?: RequestInit, @@ -10,6 +14,12 @@ export default function useApiQuery( ) { const deps = []; + if (Array.isArray(url)) { + url = url.map((u) => UnfuckURLsV3GameToV2Format(u)); + } else { + url = UnfuckURLsV3GameToV2Format(url); + } + if (additionalDeps) { deps.push(...additionalDeps); } diff --git a/typescript/client/src/components/util/search/useTachiSearch.ts b/typescript/client/src/components/util/search/useTachiSearch.ts index b0355af28..eba82bd7e 100644 --- a/typescript/client/src/components/util/search/useTachiSearch.ts +++ b/typescript/client/src/components/util/search/useTachiSearch.ts @@ -1,32 +1,32 @@ import { APIFetchV1, type UnsuccessfulAPIFetchResponse } from "#util/api"; import { useEffect, useState } from "react"; import { - type GPTString, + type ChartDocument, + type FolderDocument, type integer, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, + type SongDocument, + type UserDocument, + type V3Game, } from "tachi-common"; type SearchResults = { charts: { - [GPT in GPTString]?: Array<{ - chart: MONGO_ChartDocument; + [GPT in V3Game]?: Array<{ + chart: ChartDocument; playcount: integer; - song: MONGO_SongDocument; + song: SongDocument; }>; }; - folders: Array; - users: Array; + folders: Array; + users: Array; }; type ChartHashSearchReturns = { charts: { - [GPT in GPTString]?: Array<{ - chart: MONGO_ChartDocument; + [GPT in V3Game]?: Array<{ + chart: ChartDocument; playcount: integer; - song: MONGO_SongDocument; + song: SongDocument; }>; }; }; @@ -85,17 +85,17 @@ export function useTachiSearch( } for (const [g, charts] of Object.entries(result.body.charts)) { - const gpt = g as GPTString; + const game = g as V3Game; - if (setValue.charts[gpt]) { - setValue.charts[gpt]!.push(...charts); + if (setValue.charts[game]) { + setValue.charts[game]!.push(...charts); } else { - setValue.charts[gpt] = charts; + setValue.charts[game] = charts; } } if ("users" in result.body) { - setValue.users.push(...(result.body.users as Array)); + setValue.users.push(...(result.body.users as Array)); } } diff --git a/typescript/client/src/components/util/useBucket.tsx b/typescript/client/src/components/util/useBucket.tsx index d0a8ec15f..3e7da1841 100644 --- a/typescript/client/src/components/util/useBucket.tsx +++ b/typescript/client/src/components/util/useBucket.tsx @@ -1,14 +1,14 @@ -import { type GameGroup, GetGamePTConfig, type Playtype } from "tachi-common"; +import { GetGameConfig, type V3Game } from "tachi-common"; import useLUGPTSettings from "./useLUGPTSettings"; -export function useBucket(game: GameGroup, playtype: Playtype) { +export function useBucket(game: V3Game) { const { settings } = useLUGPTSettings(); if (!settings?.preferences.preferredDefaultEnum) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - return gptConfig.preferredDefaultEnum; + return gameConfig.preferredDefaultEnum; } return settings.preferences.preferredDefaultEnum; diff --git a/typescript/client/src/components/util/useLUGPTSettings.tsx b/typescript/client/src/components/util/useLUGPTSettings.tsx index 04c1e81e9..a58ad06ad 100644 --- a/typescript/client/src/components/util/useLUGPTSettings.tsx +++ b/typescript/client/src/components/util/useLUGPTSettings.tsx @@ -1,13 +1,13 @@ import { UGPTContext } from "#context/UGPTContext"; import { useContext } from "react"; -import { type GPTString, type MONGO_UGPTSettingsDocument } from "tachi-common"; +import { type UGPTSettingsDocument, type V3Game } from "tachi-common"; -export default function useLUGPTSettings() { +export default function useLUGPTSettings() { const { loggedInData, setLoggedInData } = useContext(UGPTContext); - const settings = (loggedInData?.settings ?? null) as MONGO_UGPTSettingsDocument | null; + const settings = (loggedInData?.settings ?? null) as UGPTSettingsDocument | null; - const setSettings = (newSettings: MONGO_UGPTSettingsDocument) => { + const setSettings = (newSettings: UGPTSettingsDocument) => { if (!loggedInData) { throw new Error(`Tried to set settings while nobody was logged in?`); } diff --git a/typescript/client/src/components/util/usePreferredRanking.tsx b/typescript/client/src/components/util/usePreferredRanking.tsx index 2c012e431..2322a5da5 100644 --- a/typescript/client/src/components/util/usePreferredRanking.tsx +++ b/typescript/client/src/components/util/usePreferredRanking.tsx @@ -1,9 +1,9 @@ -import { type MONGO_UGPTSettingsDocument } from "tachi-common"; +import { type UGPTSettingsDocument } from "tachi-common"; import useLUGPTSettings from "./useLUGPTSettings"; export default function usePreferredRanking(): - | MONGO_UGPTSettingsDocument["preferences"]["preferredRanking"] + | UGPTSettingsDocument["preferences"]["preferredRanking"] | null { const { settings } = useLUGPTSettings(); diff --git a/typescript/client/src/components/util/useScoreRatingAlg.tsx b/typescript/client/src/components/util/useScoreRatingAlg.tsx index dcb550810..fbfc35abd 100644 --- a/typescript/client/src/components/util/useScoreRatingAlg.tsx +++ b/typescript/client/src/components/util/useScoreRatingAlg.tsx @@ -1,55 +1,50 @@ import { - type GameGroup, - GetGamePTConfig, - type GPTString, - type Playtype, + GetGameConfig, type ProfileRatingAlgorithms, type ScoreRatingAlgorithms, type SessionRatingAlgorithms, + type V3Game, } from "tachi-common"; import useLUGPTSettings from "./useLUGPTSettings"; -export default function useScoreRatingAlg( - game: GameGroup, - playtype: Playtype, +export default function useScoreRatingAlg( + game: GPT, ): ScoreRatingAlgorithms[GPT] { const { settings } = useLUGPTSettings(); if (!settings?.preferences.preferredScoreAlg) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - return gptConfig.defaultScoreRatingAlg as ScoreRatingAlgorithms[GPT]; + return gameConfig.defaultScoreRatingAlg as ScoreRatingAlgorithms[GPT]; } return settings.preferences.preferredScoreAlg as ScoreRatingAlgorithms[GPT]; } -export function useSessionRatingAlg( - game: GameGroup, - playtype: Playtype, +export function useSessionRatingAlg( + game: GPT, ): SessionRatingAlgorithms[GPT] { const { settings } = useLUGPTSettings(); if (!settings?.preferences.preferredSessionAlg) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - return gptConfig.defaultSessionRatingAlg as SessionRatingAlgorithms[GPT]; + return gameConfig.defaultSessionRatingAlg as SessionRatingAlgorithms[GPT]; } return settings.preferences.preferredSessionAlg as SessionRatingAlgorithms[GPT]; } -export function useProfileRatingAlg( - game: GameGroup, - playtype: Playtype, +export function useProfileRatingAlg( + game: GPT, ): ProfileRatingAlgorithms[GPT] { const { settings } = useLUGPTSettings(); if (!settings?.preferences.preferredProfileAlg) { - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - return gptConfig.defaultProfileRatingAlg as ProfileRatingAlgorithms[GPT]; + return gameConfig.defaultProfileRatingAlg as ProfileRatingAlgorithms[GPT]; } return settings.preferences.preferredProfileAlg as ProfileRatingAlgorithms[GPT]; diff --git a/typescript/client/src/components/util/useUGPTBase.tsx b/typescript/client/src/components/util/useUGPTBase.tsx index d52bddd63..937fcb840 100644 --- a/typescript/client/src/components/util/useUGPTBase.tsx +++ b/typescript/client/src/components/util/useUGPTBase.tsx @@ -1,9 +1,6 @@ import { type UGPT } from "#types/react"; import { useMemo } from "react"; -export default function useUGPTBase({ reqUser, game, playtype }: UGPT) { - return useMemo( - () => `/u/${reqUser.username}/games/${game}/${playtype}`, - [reqUser, game, playtype], - ); +export default function useUGPTBase({ reqUser, game }: UGPT) { + return useMemo(() => `/u/${reqUser.username}/games/${game}`, [reqUser, game]); } diff --git a/typescript/client/src/context/AllLUGPTStatsContext.tsx b/typescript/client/src/context/AllLUGPTStatsContext.tsx index c5c662a81..e7f002ca7 100644 --- a/typescript/client/src/context/AllLUGPTStatsContext.tsx +++ b/typescript/client/src/context/AllLUGPTStatsContext.tsx @@ -1,6 +1,6 @@ import { type JustChildren, type SetState } from "#types/react"; import React, { createContext, useState } from "react"; -import { type MONGO_UserGameStats } from "tachi-common"; +import { type UserGameStats } from "tachi-common"; /** * Contains all of the currently logged-in users GPTStats. @@ -9,13 +9,13 @@ import { type MONGO_UserGameStats } from "tachi-common"; * dashboard info. */ export const AllLUGPTStatsContext = createContext<{ - setUGS: SetState; - ugs: MONGO_UserGameStats[] | null; + setUGS: SetState; + ugs: UserGameStats[] | null; }>({ ugs: null, setUGS: () => void 0 }); AllLUGPTStatsContext.displayName = "AllLUGPTStatsContext"; export function AllLUGPTStatsContextProvider({ children }: JustChildren) { - const [ugs, setUGS] = useState(null); + const [ugs, setUGS] = useState(null); return ( diff --git a/typescript/client/src/context/NotificationsContext.tsx b/typescript/client/src/context/NotificationsContext.tsx index 07540c96a..41ea8cb93 100644 --- a/typescript/client/src/context/NotificationsContext.tsx +++ b/typescript/client/src/context/NotificationsContext.tsx @@ -1,12 +1,12 @@ import { type JustChildren } from "#types/react"; import { APIFetchV1 } from "#util/api"; import React, { createContext, useContext, useEffect, useState } from "react"; -import { type MONGO_NotificationDocument } from "tachi-common"; +import { type NotificationDocument } from "tachi-common"; import { UserContext } from "./UserContext"; export const NotificationsContext = createContext<{ - notifications: Array; + notifications: Array; reload: () => Promise; }>({ notifications: [], @@ -17,7 +17,7 @@ export const NotificationsContext = createContext<{ export function NotificationsContextProvider({ children }: JustChildren) { const { user } = useContext(UserContext); - const [notifications, setNotifications] = useState>([]); + const [notifications, setNotifications] = useState>([]); const reload = async () => { if (!user) { @@ -25,7 +25,7 @@ export function NotificationsContextProvider({ children }: JustChildren) { return; } - await APIFetchV1>(`/users/${user.id}/notifications`).then( + await APIFetchV1>(`/users/${user.id}/notifications`).then( (r) => { if (!r.success) { setNotifications([]); diff --git a/typescript/client/src/context/TargetsContext.tsx b/typescript/client/src/context/TargetsContext.tsx index 0923e16aa..3191f9334 100644 --- a/typescript/client/src/context/TargetsContext.tsx +++ b/typescript/client/src/context/TargetsContext.tsx @@ -3,14 +3,11 @@ import { type UGPTTargetSubs } from "#types/api-returns"; import { type JustChildren } from "#types/react"; import { APIFetchV1 } from "#util/api"; import React, { createContext, useEffect, useState } from "react"; -import { - type MONGO_GoalSubscriptionDocument, - type MONGO_QuestSubscriptionDocument, -} from "tachi-common"; +import { type GoalSubscriptionDocument, type QuestSubscriptionDocument } from "tachi-common"; export const TargetsContext = createContext<{ - goalSubs: Map; - questSubs: Map; + goalSubs: Map; + questSubs: Map; reloadTargets: () => Promise; }>({ questSubs: new Map(), @@ -22,12 +19,8 @@ export const TargetsContext = createContext<{ export function TargetsContextProvider({ children }: JustChildren) { const { settings } = useLUGPTSettings(); - const [questSubs, setQuestSubs] = useState>( - new Map(), - ); - const [goalSubs, setGoalSubs] = useState>( - new Map(), - ); + const [questSubs, setQuestSubs] = useState>(new Map()); + const [goalSubs, setGoalSubs] = useState>(new Map()); const reloadTargets = async () => { if (!settings) { @@ -37,7 +30,7 @@ export function TargetsContextProvider({ children }: JustChildren) { } await APIFetchV1( - `/users/${settings.userID}/games/${settings.game}/${settings.playtype}/targets/all-subs`, + `/users/${settings.userID}/games/${settings.game}/targets/all-subs`, ).then((r) => { if (!r.success) { setQuestSubs(new Map()); @@ -45,8 +38,8 @@ export function TargetsContextProvider({ children }: JustChildren) { return; } - const questSubMap = new Map(); - const goalSubMap = new Map(); + const questSubMap = new Map(); + const goalSubMap = new Map(); for (const qSub of r.body.questSubs) { questSubMap.set(qSub.questID, qSub); diff --git a/typescript/client/src/context/UGPTContext.tsx b/typescript/client/src/context/UGPTContext.tsx index a5aa7fe24..01a832e6a 100644 --- a/typescript/client/src/context/UGPTContext.tsx +++ b/typescript/client/src/context/UGPTContext.tsx @@ -1,7 +1,7 @@ import Loading from "#components/util/Loading"; import fetchUGPTData, { type UGPTData } from "#components/util/query/fetchUGPTData"; import { type JustChildren, type SetState } from "#types/react"; -import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts"; +import { IsSupportedGame } from "#util/asserts"; import React, { createContext, useContext, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; @@ -39,14 +39,13 @@ export const UGPTContext = createContext<{ UGPTContext.displayName = "UGPTContext"; export function UGPTContextProvider({ children }: JustChildren) { - const { - game, - playtype, - userID: viewingUserID, - } = useParams<{ game: string; playtype: string; userID: string | undefined }>(); + const { game, userID: viewingUserID } = useParams<{ + game: string; + userID: string | undefined; + }>(); - if (!IsSupportedGame(game) || !IsSupportedPlaytype(game, playtype)) { - throw new Error(`Invalid game of ${game} (${playtype}). Did you mess up?`); + if (!IsSupportedGame(game)) { + throw new Error(`Invalid game of ${game}. Did you mess up?`); } const { user } = useContext(UserContext); @@ -65,7 +64,7 @@ export function UGPTContextProvider({ children }: JustChildren) { if (viewingUserID === undefined) { setViewingData(null); } else { - const viewingData = await fetchUGPTData(viewingUserID, game, playtype); + const viewingData = await fetchUGPTData(viewingUserID, game); setViewingData(viewingData); @@ -92,14 +91,14 @@ export function UGPTContextProvider({ children }: JustChildren) { if (!user) { setLoggedInData(null); } else { - const loggedData = await fetchUGPTData(user.id, game, playtype); + const loggedData = await fetchUGPTData(user.id, game); setLoggedInData(loggedData); } setLoggedLoading(false); })(); - }, [user, game, playtype, viewingUserID]); + }, [user, game, viewingUserID]); if (viewingLoading || loggedLoading) { return ; diff --git a/typescript/client/src/context/UserContext.tsx b/typescript/client/src/context/UserContext.tsx index 3fdcb643e..cc42b7037 100644 --- a/typescript/client/src/context/UserContext.tsx +++ b/typescript/client/src/context/UserContext.tsx @@ -1,18 +1,18 @@ import { type JustChildren, type SetState } from "#types/react"; import React, { createContext, useState } from "react"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; /** * Contains the current user's user document. */ export const UserContext = createContext<{ - setUser: SetState; - user: MONGO_UserDocument | null; + setUser: SetState; + user: UserDocument | null; }>({ user: null, setUser: () => void 0 }); UserContext.displayName = "UserContext"; export function UserContextProvider({ children }: JustChildren) { - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); return {children}; } diff --git a/typescript/client/src/context/UserSettingsContext.tsx b/typescript/client/src/context/UserSettingsContext.tsx index a2826e9a7..3901c31fc 100644 --- a/typescript/client/src/context/UserSettingsContext.tsx +++ b/typescript/client/src/context/UserSettingsContext.tsx @@ -1,7 +1,7 @@ import { type JustChildren, type SetState } from "#types/react"; import { APIFetchV1 } from "#util/api"; import React, { createContext, useContext, useEffect, useState } from "react"; -import { type MONGO_UserSettingsDocument } from "tachi-common"; +import { type UserSettingsDocument } from "tachi-common"; import { UserContext } from "./UserContext"; @@ -9,14 +9,14 @@ import { UserContext } from "./UserContext"; * Contains the current user's settings. */ export const UserSettingsContext = createContext<{ - setSettings: SetState; - settings: MONGO_UserSettingsDocument | null; + setSettings: SetState; + settings: UserSettingsDocument | null; }>({ settings: null, setSettings: () => void 0 }); UserSettingsContext.displayName = "UserSettingsContext"; export function UserSettingsContextProvider({ children }: JustChildren) { - const [settings, setSettings] = useState(null); + const [settings, setSettings] = useState(null); const { user } = useContext(UserContext); @@ -26,7 +26,7 @@ export function UserSettingsContextProvider({ children }: JustChildren) { } (async () => { - const res = await APIFetchV1(`/users/${user.id}/settings`); + const res = await APIFetchV1(`/users/${user.id}/settings`); if (res.success) { setSettings(res.body); diff --git a/typescript/client/src/lib/config.ts b/typescript/client/src/lib/config.ts index ad4ab29fe..01508da59 100644 --- a/typescript/client/src/lib/config.ts +++ b/typescript/client/src/lib/config.ts @@ -18,9 +18,18 @@ try { } } catch (err) { const statusHelpUrl = ToAbsoluteAPIURLForHelpLink("/status"); - document.open(); - document.write(` - -
    + `; + document.body.innerHTML = `
    ${ import.meta.env.VITE_IS_LOCAL_DEV ? ` @@ -45,7 +54,8 @@ try {

    Couldn't connect to the server.

    You are in local development mode.

      -
    • Is the backend running on http://localhost:8080? Try the status endpoint. With the default Vite proxy, the API is also available at http://localhost:3000/api/v1/....
    • +
    • The backend appears to be down. Try the status endpoint.
    • +
    • Are there any errors in your terminal?
    ` : `

    Failed to connect!

    @@ -54,13 +64,19 @@ try { -
    An error message can be found in the console. (Ctrl-Shift-I)
    ` +
    An error message can be found in the browser console. (Ctrl-Shift-I)
    ` } -
    - `); - document.close(); +
    `; + // alert(`Fatal Error: Site is (probably) down. Sorry. (${(err as Error).message})`); - throw new Error(`Site is (probably) down. Sorry. (${(err as Error).message})`); + console.error(`Site is (probably) down. Sorry. (${(err as Error).message})`); + // Prevent the rest of this module from executing (configRes.body would throw), + // but do so with a promise that never settles rather than a throw or a busy-loop. + // Top-level await is valid in ES modules; this pauses execution without blocking + // the thread and without signalling a module error to Vite HMR. + await new Promise(() => { + /* intentionally never resolves */ + }); } const conf: TachiServerCoreConfig = configRes.body; diff --git a/typescript/client/src/lib/game-implementations.tsx b/typescript/client/src/lib/game-implementations.tsx index fa5e7babd..b275ebc14 100644 --- a/typescript/client/src/lib/game-implementations.tsx +++ b/typescript/client/src/lib/game-implementations.tsx @@ -22,13 +22,7 @@ import WaccaJudgementCell from "#components/tables/cells/WACCAJudgementCell"; import { ChangeOpacity } from "#util/color-opacity"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { - COLOUR_SET, - GetGPTString, - type GPTString, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, -} from "tachi-common"; +import { COLOUR_SET, type PBScoreDocument, type ScoreDocument, type V3Game } from "tachi-common"; import { bgc, RAINBOW_EX_GRADIENT, RAINBOW_GRADIENT, RAINBOW_SHINY_GRADIENT } from "./games/_util"; import { ARCAEA_TOUCH_IMPL } from "./games/arcaea"; @@ -40,7 +34,7 @@ import { SDVX_IMPL, USC_IMPL } from "./games/sdvx-usc"; import { type GPTClientImplementation } from "./types"; type GPTClientImplementations = { - [GPT in GPTString]: GPTClientImplementation; + [GPT in V3Game]: GPTClientImplementation; }; const defaultEnumIcons = { @@ -49,11 +43,11 @@ const defaultEnumIcons = { } as const; export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { - "iidx:SP": IIDX_SP_IMPL, - "iidx:DP": IIDX_DP_IMPL, - "ddr:SP": DDR_SP_IMPL, - "ddr:DP": DDR_DP_IMPL, - "chunithm:Single": { + "iidx-sp": IIDX_SP_IMPL, + "iidx-dp": IIDX_DP_IMPL, + "ddr-sp": DDR_SP_IMPL, + "ddr-dp": DDR_DP_IMPL, + chunithm: { sessionImportantScoreCount: 50, enumIcons: { grade: "sort-alpha-up", @@ -166,7 +160,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), ratingCell: ({ sc, rating }) => , }, - "jubeat:Single": { + jubeat: { sessionImportantScoreCount: 30, enumIcons: defaultEnumIcons, enumColours: { @@ -225,7 +219,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), ratingCell: ({ sc }) => , }, - "maimai:Single": { + maimai: { sessionImportantScoreCount: 30, enumIcons: defaultEnumIcons, enumColours: { @@ -326,7 +320,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), ratingCell: ({ sc, rating }) => , }, - "maimaidx:Single": { + maimaidx: { sessionImportantScoreCount: 50, enumIcons: defaultEnumIcons, enumColours: { @@ -481,7 +475,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), ratingCell: ({ sc, rating }) => , }, - "museca:Single": { + museca: { sessionImportantScoreCount: 20, enumIcons: defaultEnumIcons, enumColours: { @@ -529,7 +523,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), ratingCell: ({ sc, rating }) => , }, - "popn:9B": { + popn: { sessionImportantScoreCount: 20, enumIcons: { grade: "sort-alpha-up", @@ -605,7 +599,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), ratingCell: ({ sc, rating }) => , }, - "wacca:Single": { + wacca: { sessionImportantScoreCount: 50, enumIcons: defaultEnumIcons, enumColours: { @@ -690,7 +684,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), ratingCell: ({ sc, rating }) => , }, - "itg:Stamina": { + "itg-stamina": { sessionImportantScoreCount: 10, enumIcons: defaultEnumIcons, enumColours: { @@ -764,7 +758,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), }, - "ongeki:Single": { + ongeki: { sessionImportantScoreCount: 45, enumIcons: { grade: "sort-alpha-up", @@ -890,23 +884,21 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { ), }, - "arcaea:Touch": ARCAEA_TOUCH_IMPL, - "gitadora:Dora": GITADORA_DORA_IMPL, - "gitadora:Gita": GITADORA_GITA_IMPL, - "bms:14K": BMS_14K_IMPL, - "bms:7K": BMS_7K_IMPL, - "pms:Controller": PMS_IMPL, - "pms:Keyboard": PMS_IMPL, - "sdvx:Single": SDVX_IMPL, - "usc:Controller": USC_IMPL, - "usc:Keyboard": USC_IMPL, + arcaea: ARCAEA_TOUCH_IMPL, + "gitadora-dora": GITADORA_DORA_IMPL, + "gitadora-gita": GITADORA_GITA_IMPL, + "bms-14k": BMS_14K_IMPL, + "bms-7k": BMS_7K_IMPL, + "pms-controller": PMS_IMPL, + "pms-keyboard": PMS_IMPL, + sdvx: SDVX_IMPL, + "usc-controller": USC_IMPL, + "usc-keyboard": USC_IMPL, }; -export function GetEnumColour( - score: MONGO_PBScoreDocument | MONGO_ScoreDocument, - enumName: string, -) { - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(score.game, score.playtype)]; +export function GetEnumColour(score: PBScoreDocument | ScoreDocument, enumName: string) { + const game = score.game; + const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; // @ts-expect-error lol return gptImpl.enumColours[enumName][score.scoreData[enumName]]; diff --git a/typescript/client/src/lib/games/_util.ts b/typescript/client/src/lib/games/_util.ts index cbfb3c494..c9e8e0877 100644 --- a/typescript/client/src/lib/games/_util.ts +++ b/typescript/client/src/lib/games/_util.ts @@ -1,8 +1,8 @@ import { type GPTRatingSystem } from "#lib/types"; import { type CSSProperties } from "react"; -import { type GPTString } from "tachi-common"; +import { type V3Game } from "tachi-common"; -export function CreateRatingSys( +export function CreateRatingSys( name: string, description: string, enumName: string, diff --git a/typescript/client/src/lib/games/arcaea.tsx b/typescript/client/src/lib/games/arcaea.tsx index 637df90a1..04bdd6385 100644 --- a/typescript/client/src/lib/games/arcaea.tsx +++ b/typescript/client/src/lib/games/arcaea.tsx @@ -6,19 +6,20 @@ import { GetEnumColour } from "#lib/game-implementations"; import { type GPTClientImplementation } from "#lib/types"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { COLOUR_SET, type GPTStrings } from "tachi-common"; +import { COLOUR_SET, type GamesForGroup } from "tachi-common"; import { bgc } from "./_util"; -const ARCAEA_DIFFICULTY_COLORS: GPTClientImplementation["difficultyColours"] = - { - Past: COLOUR_SET.paleBlue, - Present: COLOUR_SET.paleGreen, - Future: COLOUR_SET.purple, - Beyond: COLOUR_SET.vibrantRed, - }; +const ARCAEA_DIFFICULTY_COLORS: GPTClientImplementation< + GamesForGroup["arcaea"] +>["difficultyColours"] = { + Past: COLOUR_SET.paleBlue, + Present: COLOUR_SET.paleGreen, + Future: COLOUR_SET.purple, + Beyond: COLOUR_SET.vibrantRed, +}; -const ARCAEA_ENUM_COLORS: GPTClientImplementation["enumColours"] = { +const ARCAEA_ENUM_COLORS: GPTClientImplementation["enumColours"] = { lamp: { LOST: COLOUR_SET.red, "EASY CLEAR": COLOUR_SET.green, @@ -38,7 +39,7 @@ const ARCAEA_ENUM_COLORS: GPTClientImplementation["enumCol }, }; -const ARCAEA_COLORS: GPTClientImplementation["classColours"] = { +const ARCAEA_COLORS: GPTClientImplementation["classColours"] = { badge: { BLUE: bgc("midnightblue", "var(--bs-light)"), GREEN: bgc("darkgreen", "var(--bs-light)"), @@ -64,13 +65,13 @@ const ARCAEA_COLORS: GPTClientImplementation["classColours }, }; -const ARCAEA_SCORE_HEADERS: GPTClientImplementation["scoreHeaders"] = [ +const ARCAEA_SCORE_HEADERS: GPTClientImplementation["scoreHeaders"] = [ ["Score", "Score", NumericSOV((x) => x.scoreData.score)], ["Far - Lost", "Far - Lost", NumericSOV((x) => x.scoreData.score)], ["Lamp", "Lamp", NumericSOV((x) => x.scoreData.enumIndexes.lamp)], ]; -const ArcaeaCoreCells: GPTClientImplementation["scoreCoreCells"] = ({ +const ArcaeaCoreCells: GPTClientImplementation["scoreCoreCells"] = ({ sc, }) => ( <> @@ -84,12 +85,12 @@ const ArcaeaCoreCells: GPTClientImplementation["scoreCoreC ); -const ArcaeaRatingCell: GPTClientImplementation["ratingCell"] = ({ +const ArcaeaRatingCell: GPTClientImplementation["ratingCell"] = ({ sc, rating, }) => ; -export const ARCAEA_TOUCH_IMPL: GPTClientImplementation<"arcaea:Touch"> = { +export const ARCAEA_TOUCH_IMPL: GPTClientImplementation<"arcaea"> = { sessionImportantScoreCount: 30, ratingSystems: [], enumIcons: { diff --git a/typescript/client/src/lib/games/bms-pms.tsx b/typescript/client/src/lib/games/bms-pms.tsx index 7b8f551a2..2ad62a7fa 100644 --- a/typescript/client/src/lib/games/bms-pms.tsx +++ b/typescript/client/src/lib/games/bms-pms.tsx @@ -7,12 +7,12 @@ import { type GPTClientImplementation } from "#lib/types"; import { IsNullish } from "#util/misc"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { COLOUR_SET, type GPTStrings, IIDXLIKE_GBOUNDARIES } from "tachi-common"; +import { COLOUR_SET, type GamesForGroup, IIDXLIKE_GBOUNDARIES } from "tachi-common"; import { FormatSieglindeBMS } from "tachi-common/config/game-support/bms"; import { bgc, CreateRatingSys } from "./_util"; -const BASE_IMPL: GPTClientImplementation = { +const BASE_IMPL: GPTClientImplementation = { sessionImportantScoreCount: 20, difficultyColours: { CHART: COLOUR_SET.gray, // lol @@ -95,7 +95,7 @@ const BASE_IMPL: GPTClientImplementation = { ratingCell: ({ sc, rating }) => , }; -export const BMS_7K_IMPL: GPTClientImplementation<"bms:7K"> = { +export const BMS_7K_IMPL: GPTClientImplementation<"bms-7k"> = { ...BASE_IMPL, classColours: { genocideDan: { @@ -212,7 +212,7 @@ export const BMS_7K_IMPL: GPTClientImplementation<"bms:7K"> = { }, }; -export const BMS_14K_IMPL: GPTClientImplementation<"bms:14K"> = { +export const BMS_14K_IMPL: GPTClientImplementation<"bms-14k"> = { ...BASE_IMPL, classColours: { genocideDan: BMS_7K_IMPL.classColours.genocideDan, @@ -234,7 +234,7 @@ export const BMS_14K_IMPL: GPTClientImplementation<"bms:14K"> = { }, }; -export const PMS_IMPL: GPTClientImplementation = { +export const PMS_IMPL: GPTClientImplementation = { ...BASE_IMPL, classColours: { dan: { diff --git a/typescript/client/src/lib/games/ddr.tsx b/typescript/client/src/lib/games/ddr.tsx index ef2f7d982..9f67d80f8 100644 --- a/typescript/client/src/lib/games/ddr.tsx +++ b/typescript/client/src/lib/games/ddr.tsx @@ -1,16 +1,16 @@ +import DDRScoreCell from "#components/tables/cells/DDRScoreCell"; +import FlareCell from "#components/tables/cells/FlareCell"; +import LampCell from "#components/tables/cells/LampCell"; import RatingCell from "#components/tables/cells/RatingCell"; import { GetEnumColour } from "#lib/game-implementations"; import { type GPTClientImplementation } from "#lib/types"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { COLOUR_SET, type GPTStrings } from "tachi-common"; +import { COLOUR_SET, type GamesForGroup } from "tachi-common"; -import DDRScoreCell from "../../components/tables/cells/DDRScoreCell"; -import FlareCell from "../../components/tables/cells/FlareCell"; -import LampCell from "../../components/tables/cells/LampCell"; import { bgc } from "./_util"; -const DDR_ENUM_COLOURS: GPTClientImplementation["enumColours"] = { +const DDR_ENUM_COLOURS: GPTClientImplementation["enumColours"] = { grade: { E: COLOUR_SET.gray, D: COLOUR_SET.paleBlue, @@ -41,7 +41,7 @@ const DDR_ENUM_COLOURS: GPTClientImplementation["enumColours" }, }; -const DDR_DIFF_COLOURS: GPTClientImplementation["difficultyColours"] = { +const DDR_DIFF_COLOURS: GPTClientImplementation["difficultyColours"] = { BEGINNER: COLOUR_SET.blue, BASIC: COLOUR_SET.paleGreen, DIFFICULT: COLOUR_SET.red, @@ -49,13 +49,13 @@ const DDR_DIFF_COLOURS: GPTClientImplementation["difficultyCo CHALLENGE: COLOUR_SET.purple, }; -const DDR_HEADERS: GPTClientImplementation<"ddr:DP" | "ddr:SP">["scoreHeaders"] = [ +const DDR_HEADERS: GPTClientImplementation<"ddr-dp" | "ddr-sp">["scoreHeaders"] = [ ["Score", "Score", NumericSOV((x) => x.scoreData.score)], ["Flare", "Flare", NumericSOV((x) => x.scoreData.optional.enumIndexes.flare ?? 0)], ["Lamp", "Lamp", NumericSOV((x) => x.scoreData.enumIndexes.lamp)], ]; -const DDR_COLOURS: GPTClientImplementation<"ddr:DP" | "ddr:SP">["classColours"] = { +const DDR_COLOURS: GPTClientImplementation<"ddr-dp" | "ddr-sp">["classColours"] = { flare: { NONE: bgc("gray", "black"), "NONE+": bgc("gray", "black"), @@ -101,7 +101,7 @@ const DDR_COLOURS: GPTClientImplementation<"ddr:DP" | "ddr:SP">["classColours"] }, }; -const DDRCoreCells: GPTClientImplementation["scoreCoreCells"] = ({ +const DDRCoreCells: GPTClientImplementation["scoreCoreCells"] = ({ sc, chart: _chart, }) => ( @@ -116,7 +116,7 @@ const DDRCoreCells: GPTClientImplementation["scoreCoreCells"] ); -const DDRRatingCell: GPTClientImplementation["ratingCell"] = ({ +const DDRRatingCell: GPTClientImplementation["ratingCell"] = ({ sc, chart: _chart, rating, @@ -126,7 +126,7 @@ const DDRRatingCell: GPTClientImplementation["ratingCell"] = ); -export const DDR_SP_IMPL: GPTClientImplementation<"ddr:SP"> = { +export const DDR_SP_IMPL: GPTClientImplementation<"ddr-sp"> = { sessionImportantScoreCount: 20, difficultyColours: DDR_DIFF_COLOURS, enumColours: DDR_ENUM_COLOURS, @@ -141,7 +141,7 @@ export const DDR_SP_IMPL: GPTClientImplementation<"ddr:SP"> = { ratingCell: DDRRatingCell, }; -export const DDR_DP_IMPL: GPTClientImplementation<"ddr:DP"> = { +export const DDR_DP_IMPL: GPTClientImplementation<"ddr-dp"> = { sessionImportantScoreCount: 20, difficultyColours: DDR_DIFF_COLOURS, enumColours: DDR_ENUM_COLOURS, diff --git a/typescript/client/src/lib/games/gitadora.tsx b/typescript/client/src/lib/games/gitadora.tsx index efd95241f..81f096860 100644 --- a/typescript/client/src/lib/games/gitadora.tsx +++ b/typescript/client/src/lib/games/gitadora.tsx @@ -6,12 +6,12 @@ import { GetEnumColour } from "#lib/game-implementations"; import { type GPTClientImplementation } from "#lib/types"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { COLOUR_SET, type GPTStrings } from "tachi-common"; +import { COLOUR_SET, type GamesForGroup } from "tachi-common"; import { bgc } from "./_util"; const GITADORA_ENUM_COLOURS: GPTClientImplementation< - "gitadora:Dora" | "gitadora:Gita" + "gitadora-dora" | "gitadora-gita" >["enumColours"] = { grade: { C: COLOUR_SET.purple, @@ -29,14 +29,14 @@ const GITADORA_ENUM_COLOURS: GPTClientImplementation< }, }; -const GITADORA_HEADERS: GPTClientImplementation<"gitadora:Dora" | "gitadora:Gita">["scoreHeaders"] = +const GITADORA_HEADERS: GPTClientImplementation<"gitadora-dora" | "gitadora-gita">["scoreHeaders"] = [ ["Percent", "Percent", NumericSOV((x) => x.scoreData.percent)], ["Judgements", "Hits", NumericSOV((x) => x.scoreData.percent)], ["Lamp", "Lamp", NumericSOV((x) => x.scoreData.enumIndexes.lamp)], ]; -const GITADORA_COLOURS: GPTClientImplementation["classColours"] = { +const GITADORA_COLOURS: GPTClientImplementation["classColours"] = { colour: { WHITE: bgc("white", "var(--bs-dark)"), ORANGE: bgc("orange", "var(--bs-dark)"), @@ -63,7 +63,7 @@ const GITADORA_COLOURS: GPTClientImplementation["classCo }, }; -const GITADORACoreCells: GPTClientImplementation["scoreCoreCells"] = ({ +const GITADORACoreCells: GPTClientImplementation["scoreCoreCells"] = ({ sc, }) => ( <> @@ -77,12 +77,12 @@ const GITADORACoreCells: GPTClientImplementation["scoreC ); -const GITADORARatingCell: GPTClientImplementation["ratingCell"] = ({ +const GITADORARatingCell: GPTClientImplementation["ratingCell"] = ({ sc, rating, }) => ; -export const GITADORA_GITA_IMPL: GPTClientImplementation<"gitadora:Gita"> = { +export const GITADORA_GITA_IMPL: GPTClientImplementation<"gitadora-gita"> = { sessionImportantScoreCount: 50, enumIcons: { grade: "sort-alpha-up", @@ -106,7 +106,7 @@ export const GITADORA_GITA_IMPL: GPTClientImplementation<"gitadora:Gita"> = { ratingCell: GITADORARatingCell, }; -export const GITADORA_DORA_IMPL: GPTClientImplementation<"gitadora:Dora"> = { +export const GITADORA_DORA_IMPL: GPTClientImplementation<"gitadora-dora"> = { sessionImportantScoreCount: 50, enumIcons: { grade: "sort-alpha-up", diff --git a/typescript/client/src/lib/games/iidx.tsx b/typescript/client/src/lib/games/iidx.tsx index 6a19927b0..032cab206 100644 --- a/typescript/client/src/lib/games/iidx.tsx +++ b/typescript/client/src/lib/games/iidx.tsx @@ -7,11 +7,11 @@ import { GetEnumColour } from "#lib/game-implementations"; import { type GPTClientImplementation } from "#lib/types"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { COLOUR_SET, type GPTStrings, IIDX_LAMPS, IIDXLIKE_GBOUNDARIES } from "tachi-common"; +import { COLOUR_SET, type GamesForGroup, IIDX_LAMPS, IIDXLIKE_GBOUNDARIES } from "tachi-common"; import { bgc, CreateRatingSys } from "./_util"; -const IIDX_ENUM_COLOURS: GPTClientImplementation["enumColours"] = { +const IIDX_ENUM_COLOURS: GPTClientImplementation["enumColours"] = { grade: { F: COLOUR_SET.gray, E: COLOUR_SET.red, @@ -36,7 +36,7 @@ const IIDX_ENUM_COLOURS: GPTClientImplementation["enumColour }, }; -const IIDX_DIFF_COLOURS: GPTClientImplementation["difficultyColours"] = { +const IIDX_DIFF_COLOURS: GPTClientImplementation["difficultyColours"] = { NORMAL: COLOUR_SET.blue, HYPER: COLOUR_SET.orange, ANOTHER: COLOUR_SET.red, @@ -55,13 +55,13 @@ const IIDX_DIFF_COLOURS: GPTClientImplementation["difficulty "Kiraku LEGGENDARIA": COLOUR_SET.purple, }; -const IIDX_HEADERS: GPTClientImplementation<"iidx:DP" | "iidx:SP">["scoreHeaders"] = [ +const IIDX_HEADERS: GPTClientImplementation["scoreHeaders"] = [ ["Score", "Score", NumericSOV((x) => x.scoreData.percent)], ["Deltas", "Deltas", NumericSOV((x) => x.scoreData.percent)], ["Lamp", "Lamp", NumericSOV((x) => x.scoreData.enumIndexes.lamp)], ]; -const IIDX_COLOURS: GPTClientImplementation<"iidx:DP" | "iidx:SP">["classColours"] = { +const IIDX_COLOURS: GPTClientImplementation["classColours"] = { dan: { KYU_7: bgc("green", "var(--bs-light)"), KYU_6: bgc("green", "var(--bs-light)"), @@ -85,7 +85,7 @@ const IIDX_COLOURS: GPTClientImplementation<"iidx:DP" | "iidx:SP">["classColours }, }; -const IIDXCoreCells: GPTClientImplementation["scoreCoreCells"] = ({ +const IIDXCoreCells: GPTClientImplementation["scoreCoreCells"] = ({ sc, chart, }) => ( @@ -114,7 +114,7 @@ const IIDXCoreCells: GPTClientImplementation["scoreCoreCells ); -const IIDXRatingCell: GPTClientImplementation["ratingCell"] = ({ +const IIDXRatingCell: GPTClientImplementation["ratingCell"] = ({ sc, chart, rating, @@ -128,7 +128,7 @@ const IIDXRatingCell: GPTClientImplementation["ratingCell"] ); -export const IIDX_SP_IMPL: GPTClientImplementation<"iidx:SP"> = { +export const IIDX_SP_IMPL: GPTClientImplementation<"iidx-sp"> = { sessionImportantScoreCount: 20, difficultyColours: IIDX_DIFF_COLOURS, enumColours: IIDX_ENUM_COLOURS, @@ -171,7 +171,7 @@ export const IIDX_SP_IMPL: GPTClientImplementation<"iidx:SP"> = { ratingCell: IIDXRatingCell, }; -export const IIDX_DP_IMPL: GPTClientImplementation<"iidx:DP"> = { +export const IIDX_DP_IMPL: GPTClientImplementation<"iidx-dp"> = { sessionImportantScoreCount: 20, difficultyColours: IIDX_DIFF_COLOURS, enumColours: IIDX_ENUM_COLOURS, diff --git a/typescript/client/src/lib/games/sdvx-usc.tsx b/typescript/client/src/lib/games/sdvx-usc.tsx index ce433dc74..250f7de20 100644 --- a/typescript/client/src/lib/games/sdvx-usc.tsx +++ b/typescript/client/src/lib/games/sdvx-usc.tsx @@ -8,11 +8,11 @@ import { ChangeOpacity } from "#util/color-opacity"; import { FormatMillions } from "#util/misc"; import { NumericSOV } from "#util/sorts"; import React from "react"; -import { COLOUR_SET, type GPTStrings, SDVX_GRADES, SDVX_LAMPS } from "tachi-common"; +import { COLOUR_SET, type GamesForGroup, SDVX_GRADES, SDVX_LAMPS } from "tachi-common"; import { bgc, CreateRatingSys } from "./_util"; -type SDVXLikes = GPTStrings["sdvx" | "usc"]; +type SDVXLikes = GamesForGroup["sdvx" | "usc"]; const SDVXLIKE_ENUM_COLOURS: GPTClientImplementation["enumColours"] = { grade: { @@ -38,7 +38,7 @@ const SDVXLIKE_ENUM_COLOURS: GPTClientImplementation["enumColours"] = }, }; -const USCCoreCells: GPTClientImplementation["scoreCoreCells"] = ({ sc }) => ( +const USCCoreCells: GPTClientImplementation["scoreCoreCells"] = ({ sc }) => ( <> ["scoreCoreCells"] ); -const SDVXCoreCells: GPTClientImplementation<"sdvx:Single">["scoreCoreCells"] = ({ sc }) => ( +const SDVXCoreCells: GPTClientImplementation<"sdvx">["scoreCoreCells"] = ({ sc }) => ( <> ["ratingCell"] = ({ sc, ); -export const SDVX_IMPL: GPTClientImplementation<"sdvx:Single"> = { +export const SDVX_IMPL: GPTClientImplementation<"sdvx"> = { sessionImportantScoreCount: 50, enumColours: SDVXLIKE_ENUM_COLOURS, enumIcons: { @@ -193,7 +193,7 @@ export const SDVX_IMPL: GPTClientImplementation<"sdvx:Single"> = { scoreCoreCells: SDVXCoreCells, ratingCell: SDVXRatingCell, }; -export const USC_IMPL: GPTClientImplementation = { +export const USC_IMPL: GPTClientImplementation = { sessionImportantScoreCount: 50, enumColours: SDVXLIKE_ENUM_COLOURS, enumIcons: { diff --git a/typescript/client/src/lib/types.ts b/typescript/client/src/lib/types.ts index 3de3b432c..4c6b523ad 100644 --- a/typescript/client/src/lib/types.ts +++ b/typescript/client/src/lib/types.ts @@ -1,34 +1,34 @@ import { type Header } from "#components/tables/components/TachiTable"; import { type CSSProperties } from "react"; import { + type ChartDocument, type ClassConfigs, type ConfScoreMetrics, type Difficulties, - type GPTString, type integer, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, + type PBScoreDocument, + type ScoreDocument, type ScoreRatingAlgorithms, + type V3Game, } from "tachi-common"; import { type ExtractEnumMetricNames, type GetEnumValue } from "tachi-common/types/metrics"; -export type GPTEnumColours = { +export type GPTEnumColours = { // @ts-expect-error this is fine please do not worry [M in ExtractEnumMetricNames]: Record, string>; }; -export type GPTEnumIcons = { +export type GPTEnumIcons = { [M in ExtractEnumMetricNames]: string; }; -export type GPTDifficultyColours = Record; +export type GPTDifficultyColours = Record; /** * Every GPT has to have some sort of "rating system" for charts defined. * The UI uses this to handle things like sorting on the difficulty cell. */ -export type GPTRatingSystem = { +export type GPTRatingSystem = { /** * What qualifies as "achieving" the band in this rating system? * For example, a clear tierlist would use this to discriminate clears @@ -38,9 +38,7 @@ export type GPTRatingSystem = { * end user (i.e. the string lamp when the target is hard clear) * the second is whether they achieved this or not. */ - achievementFn?: ( - p: MONGO_PBScoreDocument | MONGO_ScoreDocument, - ) => [number | string, boolean]; + achievementFn?: (p: PBScoreDocument | ScoreDocument) => [number | string, boolean]; description: string; enumName: string; @@ -48,15 +46,15 @@ export type GPTRatingSystem = { * Does this rating system say this chart has strong individual differences * between players? */ - idvDifference: (c: MONGO_ChartDocument) => boolean | null | undefined; + idvDifference: (c: ChartDocument) => boolean | null | undefined; name: string; - toNumber: (c: MONGO_ChartDocument) => number | null | undefined; + toNumber: (c: ChartDocument) => number | null | undefined; - toString: (c: MONGO_ChartDocument) => string | null | undefined; + toString: (c: ChartDocument) => string | null | undefined; }; -export type GPTClassColours = { +export type GPTClassColours = { [C in keyof ClassConfigs[GPT]]: { // @ts-expect-error it's kinda cool how TS lets me just uhh // ignore, errors @@ -78,7 +76,7 @@ export type GPTClassColours = { }; }; -export interface GPTClientImplementation { +export interface GPTClientImplementation { enumColours: GPTEnumColours; /** @@ -111,7 +109,7 @@ export interface GPTClientImplementation { /** * What headers should be used when rendering scores in a table for this game? */ - scoreHeaders: Array | MONGO_ScoreDocument>>; + scoreHeaders: Array | ScoreDocument>>; /** * How should we render the "core cells" for a score row in this game? @@ -119,8 +117,8 @@ export interface GPTClientImplementation { * This should render exactly the same amount of cells as there are headers. */ scoreCoreCells: (props: { - chart: MONGO_ChartDocument; - sc: MONGO_PBScoreDocument | MONGO_ScoreDocument; + chart: ChartDocument; + sc: PBScoreDocument | ScoreDocument; }) => JSX.Element; /** @@ -130,13 +128,13 @@ export interface GPTClientImplementation { * necessarily display all four cells at once. * * You can use this to stylise certain things about your game's rating system, like - * colouring in jubility with the appropriate colour, or indicating why a user + * colouring in jubility with the important colour, or indicating why a user * got 0 points on an unranked chart, etc.. */ ratingCell: (props: { - chart: MONGO_ChartDocument; + chart: ChartDocument; rating: ScoreRatingAlgorithms[GPT]; - sc: MONGO_PBScoreDocument | MONGO_ScoreDocument; + sc: PBScoreDocument | ScoreDocument; }) => JSX.Element; /** diff --git a/typescript/client/src/styles/legacy/tachi-custom/_custom.scss b/typescript/client/src/styles/legacy/tachi-custom/_custom.scss index 5fa4d51c7..7659999a3 100644 --- a/typescript/client/src/styles/legacy/tachi-custom/_custom.scss +++ b/typescript/client/src/styles/legacy/tachi-custom/_custom.scss @@ -8,14 +8,86 @@ } .stat-overview-card { - box-shadow: black 0px 0px 2px 1px !important; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius-lg); + box-shadow: var(--bs-box-shadow-sm); + overflow: hidden; + transition: + box-shadow 0.15s ease, + border-color 0.15s ease; + + &:hover { + box-shadow: var(--bs-box-shadow); + } + + .card-header { + background-color: var(--bs-secondary-bg); + border-bottom: 1px solid var(--bs-border-color-translucent); + padding: 0.5rem 1rem; + + h5 { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + } + } + + .card-body { + padding: 1.1rem 1.25rem; + } +} + +.showcase-pb-table { + font-size: 0.9rem; + + thead th { + border-bottom: 1px solid var(--bs-border-color-translucent); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.04em; + padding: 0.35rem 0.5rem; + text-transform: uppercase; + } + + tbody td { + padding: 0.5rem 0.35rem; + vertical-align: middle; + } +} + +.showcase-compare-block { + background-color: var(--bs-secondary-bg); + border-radius: var(--bs-border-radius); + margin-top: 0.75rem; + padding: 0.85rem 0.75rem; +} + +.showcase-main-card { + border: 1px solid var(--bs-border-color-translucent); + border-radius: var(--bs-border-radius-lg); + box-shadow: var(--bs-box-shadow-sm); + + /* Avoid a loud default card-footer rule + duplicate borders; keep separation subtle */ + .card-footer { + background-color: transparent; + border-top: 1px solid var(--bs-border-color-translucent); + padding-top: 1rem; + } +} + +.showcase-pb-table.table-striped > tbody > tr:nth-of-type(odd) > * { + --bs-table-accent-bg: transparent; } .gentle-link { color: var(--bs-body-color); + text-decoration: none; - &:hover { + &:hover, + &:focus-visible { color: var(--bs-primary); + text-decoration: none; } } diff --git a/typescript/client/src/types/api-returns.ts b/typescript/client/src/types/api-returns.ts index 179363446..468403e03 100644 --- a/typescript/client/src/types/api-returns.ts +++ b/typescript/client/src/types/api-returns.ts @@ -1,38 +1,38 @@ import { type APIPermissions, - type GamePTConfig, - type GPTString, - type GPTStringToGame, + type ChallengeSubscriptionDocument, + type ChartDocument, + type ClassAchievementDocument, + type FolderDocument, + type GameConfig, + type GameGroup, + type GoalDocument, + type GoalSubscriptionDocument, + type ImportDocument, type ImportTrackerFailed, type integer, - type MONGO_ChallengeSubscriptionDocument, - type MONGO_ChartDocument, - type MONGO_ClassAchievementDocument, - type MONGO_FolderDocument, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_ImportDocument, - type MONGO_PBScoreDocument, - type MONGO_QuestDocument, - type MONGO_QuestlineDocument, - type MONGO_QuestSubscriptionDocument, - type MONGO_ScoreDocument, - type MONGO_SessionDocument, - type MONGO_SongDocument, - type MONGO_TableDocument, - type MONGO_UserDocument, - type MONGO_UserGameStats, - type MONGO_UserGameStatsSnapshotDocument, + type PBScoreDocument, type ProfileRatingAlgorithms, + type QuestDocument, + type QuestlineDocument, + type QuestSubscriptionDocument, + type ScoreDocument, + type SessionDocument, type SessionScoreInfo, type ShowcaseStatChart, type ShowcaseStatFolder, + type SongDocument, + type TableDocument, + type UserDocument, + type UserGameStats, + type UserGameStatsSnapshotDocument, + type V3Game, } from "tachi-common"; -export interface UGPTStatsReturn { - gameStats: MONGO_UserGameStats; - firstScore: MONGO_ScoreDocument; - mostRecentScore: MONGO_ScoreDocument; +export interface UGPTStatsReturn { + gameStats: UserGameStats; + firstScore: ScoreDocument; + mostRecentScore: ScoreDocument; totalScores: number; rankingData: Record< ProfileRatingAlgorithms[GPT], @@ -45,10 +45,10 @@ export interface UGPTStatsReturn { } export interface UGPTLeaderboardAdjacent { - above: MONGO_UserGameStats[]; - below: MONGO_UserGameStats[]; - users: MONGO_UserDocument[]; - thisUsersStats: MONGO_UserGameStats; + above: UserGameStats[]; + below: UserGameStats[]; + users: UserDocument[]; + thisUsersStats: UserGameStats; thisUsersRanking: { outOf: integer; ranking: integer; @@ -56,133 +56,140 @@ export interface UGPTLeaderboardAdjacent { } export interface GPTLeaderboard { - gameStats: MONGO_UserGameStats[]; - users: MONGO_UserDocument[]; + gameStats: UserGameStats[]; + users: UserDocument[]; } export type UGPTPreferenceStatsReturn = | { related: { - chart: MONGO_ChartDocument; - song: MONGO_SongDocument; + chart: ChartDocument; + song: SongDocument; }; - result: { value: number | null }; + result: { pb: PBScoreDocument | null; playcount: number }; stat: ShowcaseStatChart; } | { - related: { folder: MONGO_FolderDocument }; + related: { folder: FolderDocument }; result: { outOf: integer; value: integer }; stat: ShowcaseStatFolder; }; -export type UGPTHistory = Omit< - MONGO_UserGameStatsSnapshotDocument, - "game" | "playtype" | "userID" ->[]; +export type UGPTPreferenceChartStatsReturn = Extract< + UGPTPreferenceStatsReturn, + { stat: ShowcaseStatChart } +>; -export interface SessionReturns { - session: MONGO_SessionDocument; - scores: MONGO_ScoreDocument[]; +export type UGPTPreferenceFolderStatsReturn = Extract< + UGPTPreferenceStatsReturn, + { stat: ShowcaseStatFolder } +>; + +export type UGPTHistory = Omit[]; + +export interface SessionReturns { + session: SessionDocument; + scores: ScoreDocument[]; scoreInfo: Array; - songs: MONGO_SongDocument[]; - charts: MONGO_ChartDocument[]; - user: MONGO_UserDocument; + songs: SongDocument[]; + charts: ChartDocument[]; + user: UserDocument; } -export interface UGPTChartPBComposition { - scores: MONGO_ScoreDocument[]; - chart: MONGO_ChartDocument; - pb: MONGO_PBScoreDocument; +export interface UGPTChartPBComposition { + scores: ScoreDocument[]; + chart: ChartDocument; + pb: PBScoreDocument; } -export type UGSWithRankingData = { +export type UGSWithRankingData = { __rankingData: Record; -} & MONGO_UserGameStats; +} & UserGameStats; -export interface SongChartsSearch { - songs: MONGO_SongDocument[]; - charts: MONGO_ChartDocument[]; +export interface SongChartsSearch { + songs: SongDocument[]; + charts: ChartDocument[]; } export interface FolderStatsInfo { stats: Record>; - folderID: string; + slug: string; chartCount: integer; } export interface UGPTFolderSearch { - folders: MONGO_FolderDocument[]; + folders: FolderDocument[]; stats: FolderStatsInfo[]; } export interface UGPTTableReturns { - folders: MONGO_FolderDocument[]; + folders: FolderDocument[]; stats: FolderStatsInfo[]; - table: MONGO_TableDocument; + table: TableDocument; } -export interface UGPTFolderReturns { - folder: MONGO_FolderDocument; - songs: MONGO_SongDocument[]; - charts: MONGO_ChartDocument[]; - pbs: MONGO_PBScoreDocument[]; +export interface UGPTFolderReturns { + folder: FolderDocument; + songs: SongDocument[]; + charts: ChartDocument[]; + pbs: PBScoreDocument[]; } -export interface GPTFolderReturns { - folder: MONGO_FolderDocument; - songs: MONGO_SongDocument[]; - charts: MONGO_ChartDocument[]; +export interface GPTFolderReturns { + folder: FolderDocument; + songs: SongDocument[]; + charts: ChartDocument[]; } export interface GPTStatsReturn { - config: GamePTConfig; + config: GameConfig; playerCount: integer; chartCount: integer; scoreCount: integer; } export interface RecentClassesReturn { - classes: MONGO_ClassAchievementDocument[]; - users: MONGO_UserDocument[]; + classes: ClassAchievementDocument[]; + users: UserDocument[]; } -export interface SongsReturn { - song: MONGO_SongDocument; - charts: MONGO_ChartDocument[]; +export interface SongsReturn { + song: SongDocument; + charts: ChartDocument[]; } -export interface ChartPBLeaderboardReturn { - users: MONGO_UserDocument[]; - pbs: MONGO_PBScoreDocument[]; +export interface ChartPBLeaderboardReturn { + users: UserDocument[]; + pbs: PBScoreDocument[]; } -export interface UGPTChartLeaderboardAdjacent { - users: MONGO_UserDocument[]; - pb: MONGO_PBScoreDocument; - adjacentAbove: MONGO_PBScoreDocument[]; - adjacentBelow: MONGO_PBScoreDocument[]; +export interface UGPTChartLeaderboardAdjacent { + users: UserDocument[]; + pb: PBScoreDocument; + adjacentAbove: PBScoreDocument[]; + adjacentBelow: PBScoreDocument[]; } -export interface ScoreLeaderboardReturns { - users: MONGO_UserDocument[]; - songs: MONGO_SongDocument[]; - charts: MONGO_ChartDocument[]; - pbs: MONGO_PBScoreDocument[]; +export interface ScoreLeaderboardReturns { + users: UserDocument[]; + songs: SongDocument[]; + charts: ChartDocument[]; + pbs: PBScoreDocument[]; } export interface UserLeaderboardReturns { - users: MONGO_UserDocument[]; - gameStats: MONGO_UserGameStats[]; + users: UserDocument[]; + gameStats: UserGameStats[]; } export interface UserRecentSummary { recentPlaycount: integer; - recentSessions: MONGO_SessionDocument[]; - recentFolders: MONGO_FolderDocument[]; + recentSessions: SessionDocument[]; + recentFolders: FolderDocument[]; recentFolderStats: FolderStatsInfo[]; - recentGoals: MONGO_GoalDocument[]; - recentImprovedGoals: MONGO_GoalSubscriptionDocument[]; - recentAchievedGoals: MONGO_GoalSubscriptionDocument[]; + recentGoals: GoalDocument[]; + recentImprovedGoals: GoalSubscriptionDocument[]; + recentAchievedGoals: GoalSubscriptionDocument[]; } export interface ServerStatus { @@ -194,101 +201,101 @@ export interface ServerStatus { } export interface ChallengeSubsReturn { - rivals: Array; - pbs: Array; - challengeSubs: Array; - songs: Array; - charts: Array; + rivals: Array; + pbs: Array; + challengeSubs: Array; + songs: Array; + charts: Array; } export interface ChartRivalsReturn { - rivals: Array; - pbs: Array; + rivals: Array; + pbs: Array; } export interface ImportIDReturn { - scores: MONGO_ScoreDocument[]; - songs: MONGO_SongDocument[]; - charts: MONGO_ChartDocument[]; - sessions: MONGO_SessionDocument[]; - import: MONGO_ImportDocument; - user: MONGO_UserDocument; + scores: ScoreDocument[]; + songs: SongDocument[]; + charts: ChartDocument[]; + sessions: SessionDocument[]; + import: ImportDocument; + user: UserDocument; } export interface FailedImportsReturn { failedImports: Array; - users: Array; + users: Array; } export interface ImportsReturn { - imports: Array; - users: Array; + imports: Array; + users: Array; } export interface ActivityReturn { - recentSessions: Array; + recentSessions: Array; - songs: Array; - charts: Array; - recentlyHighlightedScores: Array; - achievedClasses: Array; + songs: Array; + charts: Array; + recentlyHighlightedScores: Array; + achievedClasses: Array; - goals: Array; - quests: Array; + goals: Array; + quests: Array; // recently achieved goal/quest subs - goalSubs: Array; - questSubs: Array; + goalSubs: Array; + questSubs: Array; - users: Array; + users: Array; } -export type RecordActivityReturn = Partial>; +export type RecordActivityReturn = Partial>; export interface GoalsOnChartReturn { - goals: Array; - goalSubs: Array; - quests: Array; - questSubs: Array; + goals: Array; + goalSubs: Array; + quests: Array; + questSubs: Array; } export type GoalsOnFolderReturn = GoalsOnChartReturn; export type AllUGPTGoalsReturn = GoalsOnChartReturn; export interface RecentlyAchievedOrRaisedTargets { - goals: Array; - quests: Array; - goalSubs: Array; - questSubs: Array; - user: MONGO_UserDocument; + goals: Array; + quests: Array; + goalSubs: Array; + questSubs: Array; + user: UserDocument; } export interface GPTQuestsReturn { - goals: Array; - quests: Array; + goals: Array; + quests: Array; } export interface UGPTTargetSubs { - goalSubs: Array; - questSubs: Array; + goalSubs: Array; + questSubs: Array; } export interface QuestlineReturn { - questline: MONGO_QuestlineDocument; - quests: Array; - goals: Array; + questline: QuestlineDocument; + quests: Array; + goals: Array; } export interface QuestReturn { - quest: MONGO_QuestDocument; - questSubs: Array; - users: Array; - goals: Array; - parentQuestlines: Array; + quest: QuestDocument; + questSubs: Array; + users: Array; + goals: Array; + parentQuestlines: Array; } export type SessionFolderRaises = { - folder: MONGO_FolderDocument; + folder: FolderDocument; previousCount: integer; // how many AAAs/HARD CLEARs/whatevers was on this raisedCharts: Array; // Array; // folder before this session? diff --git a/typescript/client/src/types/import.ts b/typescript/client/src/types/import.ts index c5add956b..fa003e717 100644 --- a/typescript/client/src/types/import.ts +++ b/typescript/client/src/types/import.ts @@ -1,8 +1,8 @@ -import { type MONGO_ImportDocument } from "tachi-common"; +import { type ImportDocument } from "tachi-common"; export type ImportStates = | { error: string; state: "failed" } - | { import: MONGO_ImportDocument; state: "done" } + | { import: ImportDocument; state: "done" } | { progressInfo: { description: string }; state: "waiting_processing" } | { state: "not_started" } | { state: "waiting_init" }; diff --git a/typescript/client/src/types/react.ts b/typescript/client/src/types/react.ts index 103d19011..6892d7425 100644 --- a/typescript/client/src/types/react.ts +++ b/typescript/client/src/types/react.ts @@ -9,7 +9,7 @@ import { type FormikState, type FormikTouched, } from "formik"; -import { type GameGroup, type MONGO_UserDocument, type Playtype } from "tachi-common"; +import { type UserDocument, type V3Game } from "tachi-common"; export interface JustChildren { children: ReactNode; @@ -86,12 +86,10 @@ export type UseFormik = { }; export interface GamePT { - game: GameGroup; - playtype: Playtype; + game: V3Game; } export interface UGPT { - reqUser: MONGO_UserDocument; - game: GameGroup; - playtype: Playtype; + reqUser: UserDocument; + game: V3Game; } diff --git a/typescript/client/src/types/seeds.ts b/typescript/client/src/types/seeds.ts index cb6dc029b..1a8dc7eb5 100644 --- a/typescript/client/src/types/seeds.ts +++ b/typescript/client/src/types/seeds.ts @@ -1,17 +1,15 @@ import { type JSONAttributeDiff } from "#util/misc"; import { + type BMSCourseDocument, + type ChartDocument, + type FolderDocument, type GameGroup, - type GPTString, - type GPTStrings, - type GPTStringToGame, - type MONGO_BMSCourseDocument, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_GoalDocument, - type MONGO_QuestDocument, - type MONGO_QuestlineDocument, - type MONGO_SongDocument, - type MONGO_TableDocument, + type GoalDocument, + type QuestDocument, + type QuestlineDocument, + type SongDocument, + type TableDocument, + type V3Game, } from "tachi-common"; // To render seeds with their tables properly, we need to conjoin our data with @@ -25,49 +23,50 @@ export type BMSCourseWithRelated = { entries: Array< | string | { - chart: MONGO_ChartDocument<"bms:7K" | "bms:14K">; - song: MONGO_SongDocument<"bms">; + chart: ChartDocument<"bms-7k" | "bms-14k">; + song: SongDocument<"bms">; } >; }; -} & MONGO_BMSCourseDocument; +} & BMSCourseDocument; export type TableWithRelated = { __related: { + /** Keys match `tables.json` folder slug strings (per game). */ folders: { - [folderID: string]: MONGO_FolderDocument | undefined; + [folderSlug: string]: FolderDocument | undefined; }; }; -} & MONGO_TableDocument; +} & TableDocument; export type QuestlineWithRelated = { __related: { quests: { - [questID: string]: MONGO_QuestDocument | undefined; + [questID: string]: QuestDocument | undefined; }; }; -} & MONGO_QuestlineDocument; +} & QuestlineDocument; export type QuestWithRelated = { __related: { goals: { - [goalID: string]: MONGO_GoalDocument | undefined; + [goalID: string]: GoalDocument | undefined; }; }; -} & MONGO_QuestDocument; +} & QuestDocument; -export type ChartWithRelated = { +export type ChartWithRelated = { __related: { - song: MONGO_SongDocument | undefined; + song: SongDocument | undefined; }; -} & MONGO_ChartDocument; +} & ChartDocument; type SongSeedsWithRelated = { - [G in GameGroup as `songs-${G}.json`]: Array>; + [G in GameGroup as `songs-${G}.json`]: Array>; }; type ChartSeedsWithRelated = { - [G in GameGroup as `charts-${G}.json`]: Array>; + [G in V3Game as `charts-${G}.json`]: Array>; }; /** @@ -79,9 +78,9 @@ export type DatabaseSeedsWithRelated = { "bms-course-lookup.json": Array; // intentional: folders don't need to be joined with anything. - "folders.json": Array; + "folders.json": Array; - "goals.json": Array; + "goals.json": Array; "questlines.json": Array; "quests.json": Array; "tables.json": Array; diff --git a/typescript/client/src/types/tables.ts b/typescript/client/src/types/tables.ts index 587597c89..90ea9f107 100644 --- a/typescript/client/src/types/tables.ts +++ b/typescript/client/src/types/tables.ts @@ -1,94 +1,94 @@ import { - type GPTString, - type GPTStringToGame, + type ChartDocument, + type GameGroup, + type GoalDocument, + type GoalSubscriptionDocument, + type ImportDocument, type ImportTrackerFailed, type integer, - type MONGO_ChartDocument, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_ImportDocument, - type MONGO_PBScoreDocument, - type MONGO_QuestDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, - type MONGO_UserGameStats, + type PBScoreDocument, + type QuestDocument, + type ScoreDocument, + type SongDocument, + type UserDocument, + type UserGameStats, + type V3Game, } from "tachi-common"; -export type PBDataset = ({ +export type PBDataset = ({ __playcount?: integer; __related: { - chart: MONGO_ChartDocument; + chart: ChartDocument; index: integer; - song: MONGO_SongDocument; - user?: MONGO_UserDocument; + song: SongDocument; + user?: UserDocument; }; -} & MONGO_PBScoreDocument)[]; +} & PBScoreDocument)[]; -export type ScoreDataset = ({ +export type ScoreDataset = ({ __related: { - chart: MONGO_ChartDocument; + chart: ChartDocument; index: integer; - song: MONGO_SongDocument; - user: MONGO_UserDocument; + song: SongDocument; + user: UserDocument; }; -} & MONGO_ScoreDocument)[]; +} & ScoreDocument)[]; -export type FolderDataset = ({ +export type FolderDataset = ({ __related: { - pb: MONGO_PBScoreDocument | null; - song: MONGO_SongDocument; - user: MONGO_UserDocument; + pb: PBScoreDocument | null; + song: SongDocument; + user: UserDocument; }; -} & MONGO_ChartDocument)[]; +} & ChartDocument)[]; -export type ChartLeaderboardDataset = ({ +export type ChartLeaderboardDataset = ({ __related: { - user: MONGO_UserDocument; + user: UserDocument; }; -} & MONGO_PBScoreDocument)[]; +} & PBScoreDocument)[]; export type UGSDataset = ({ __related: { index: integer; - user: MONGO_UserDocument; + user: UserDocument; }; -} & MONGO_UserGameStats)[]; +} & UserGameStats)[]; -export type RivalChartDataset = ({ +export type RivalChartDataset = ({ __related: { index: number; - pb: MONGO_PBScoreDocument | null; + pb: PBScoreDocument | null; }; -} & MONGO_UserDocument)[]; +} & UserDocument)[]; -export type ComparePBsDataset = Array<{ - base: MONGO_PBScoreDocument | null; - chart: MONGO_ChartDocument; - compare: MONGO_PBScoreDocument | null; - song: MONGO_SongDocument; +export type ComparePBsDataset = Array<{ + base: PBScoreDocument | null; + chart: ChartDocument; + compare: PBScoreDocument | null; + song: SongDocument; }>; export type ImportDataset = Array< { __related: { - user: MONGO_UserDocument; + user: UserDocument; }; - } & MONGO_ImportDocument + } & ImportDocument >; export type FailedImportDataset = Array< { __related: { - user: MONGO_UserDocument; + user: UserDocument; }; } & ImportTrackerFailed >; export type GoalSubDataset = ({ __related: { - goal: MONGO_GoalDocument; - parentQuests: Array; - user: MONGO_UserDocument; + goal: GoalDocument; + parentQuests: Array; + user: UserDocument; }; -} & MONGO_GoalSubscriptionDocument)[]; +} & GoalSubscriptionDocument)[]; diff --git a/typescript/client/src/types/tachi.ts b/typescript/client/src/types/tachi.ts index 3c6c67324..04e9097ee 100644 --- a/typescript/client/src/types/tachi.ts +++ b/typescript/client/src/types/tachi.ts @@ -1,43 +1,43 @@ import { + type ChartDocument, + type ClassAchievementDocument, + type GoalDocument, + type GoalSubscriptionDocument, type integer, - type MONGO_ChartDocument, - type MONGO_ClassAchievementDocument, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, - type MONGO_ScoreDocument, - type MONGO_SessionDocument, - type MONGO_SongDocument, + type QuestDocument, + type QuestSubscriptionDocument, + type ScoreDocument, + type SessionDocument, + type SongDocument, } from "tachi-common"; export type ClumpedActivityScores = { scores: Array< { - __related: { chart: MONGO_ChartDocument; song: MONGO_SongDocument }; - } & MONGO_ScoreDocument + __related: { chart: ChartDocument; song: SongDocument }; + } & ScoreDocument >; type: "SCORES"; }; export type ClumpedActivitySession = { type: "SESSION"; -} & MONGO_SessionDocument; +} & SessionDocument; export type ClumpedActivityClassAchievement = { type: "CLASS_ACHIEVEMENT"; -} & MONGO_ClassAchievementDocument; +} & ClassAchievementDocument; export type ClumpedActivityGoalAchievement = { - goals: Array<{ __related: { goal: MONGO_GoalDocument } } & MONGO_GoalSubscriptionDocument>; + goals: Array<{ __related: { goal: GoalDocument } } & GoalSubscriptionDocument>; type: "GOAL_ACHIEVEMENTS"; // redundant, but convenient. userID: integer; }; export type ClumpedActivityQuestAchievement = { - quest: MONGO_QuestDocument; - sub: MONGO_QuestSubscriptionDocument; + quest: QuestDocument; + sub: QuestSubscriptionDocument; type: "QUEST_ACHIEVEMENT"; userID: integer; }; @@ -59,7 +59,7 @@ export type ClumpedActivity = Array< */ export type RawQuestDocument = { rawQuestData: Array; -} & Omit; +} & Omit; export type RawQuestSection = { desc: string; @@ -68,6 +68,6 @@ export type RawQuestSection = { }; export type RawQuestGoal = { - goal: Pick; + goal: Pick; note?: string; }; diff --git a/typescript/client/src/util/asserts.ts b/typescript/client/src/util/asserts.ts index e18494375..23060523c 100644 --- a/typescript/client/src/util/asserts.ts +++ b/typescript/client/src/util/asserts.ts @@ -1,29 +1,12 @@ -import { TachiConfig } from "#lib/config"; -import { - type GameGroup, - GetGameGroupConfig, - type GPTString, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, - type Playtypes, -} from "tachi-common"; +import { IsValidGame, type PBScoreDocument, type ScoreDocument, type V3Game } from "tachi-common"; -export function IsSupportedGame(str: string): str is GameGroup { - return TachiConfig.GAMES.includes(str as unknown as GameGroup); +export function IsSupportedGame(str: string): str is V3Game { + return IsValidGame(str); } -export function IsSupportedPlaytype( - game: G, - str: string, -): str is Playtypes[G] { - const gameConfig = GetGameGroupConfig(game); - - return gameConfig.playtypes.includes(str as unknown as Playtypes[G]); -} - -export function IsScore( - pbOrScore: MONGO_PBScoreDocument | MONGO_ScoreDocument, -): pbOrScore is MONGO_ScoreDocument { +export function IsScore( + pbOrScore: PBScoreDocument | ScoreDocument, +): pbOrScore is ScoreDocument { // @ts-expect-error thats the test... return !!pbOrScore.scoreMeta; } diff --git a/typescript/client/src/util/data.ts b/typescript/client/src/util/data.ts index 3b0c4a1b7..14b85d0cf 100644 --- a/typescript/client/src/util/data.ts +++ b/typescript/client/src/util/data.ts @@ -1,15 +1,15 @@ import { type GoalsOnChartReturn, type GoalsOnFolderReturn } from "#types/api-returns"; import { + type ChartDocument, type GameGroup, - type GPTString, + type GoalDocument, type integer, - type MONGO_ChartDocument, - type MONGO_GoalDocument, - type MONGO_QuestDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, + type QuestDocument, + type ScoreDocument, type SessionScoreInfo, + type SongDocument, + type UserDocument, + type V3Game, } from "tachi-common"; export function GetPBs(scoreInfo: SessionScoreInfo[]) { @@ -28,8 +28,8 @@ export function GetPBs(scoreInfo: SessionScoreInfo[]) { }); } -export function CreateSongMap(songs: MONGO_SongDocument[]) { - const songMap = new Map>(); +export function CreateSongMap(songs: SongDocument[]) { + const songMap = new Map>(); for (const song of songs) { songMap.set(song.id, song); @@ -38,8 +38,8 @@ export function CreateSongMap(songs: MONGO_Song return songMap; } -export function CreateUserMap(users: MONGO_UserDocument[]) { - const userMap = new Map(); +export function CreateUserMap(users: UserDocument[]) { + const userMap = new Map(); for (const user of users) { userMap.set(user.id, user); @@ -48,8 +48,8 @@ export function CreateUserMap(users: MONGO_UserDocument[]) { return userMap; } -export function CreateGoalMap(goals: MONGO_GoalDocument[]) { - const goalMap = new Map(); +export function CreateGoalMap(goals: GoalDocument[]) { + const goalMap = new Map(); for (const goal of goals) { goalMap.set(goal.goalID, goal); @@ -68,10 +68,8 @@ export function CreateChartIDMap(arr: T[]): Map( - charts: MONGO_ChartDocument[], -) { - const chartMap = new Map>(); +export function CreateChartMap(charts: ChartDocument[]) { + const chartMap = new Map>(); for (const chart of charts) { chartMap.set(chart.chartID, chart); @@ -80,10 +78,8 @@ export function CreateChartMap( return chartMap; } -export function CreateScoreIDMap( - scores: MONGO_ScoreDocument[], -) { - const scoreMap = new Map>(); +export function CreateScoreIDMap(scores: ScoreDocument[]) { + const scoreMap = new Map>(); for (const score of scores) { scoreMap.set(score.scoreID, score); @@ -92,12 +88,12 @@ export function CreateScoreIDMap( return scoreMap; } -export function CreateChartLink(chart: MONGO_ChartDocument, game: GameGroup) { - return `/games/${game}/${chart.playtype}/charts/${chart.chartID}`; +export function CreateChartLink(chart: ChartDocument) { + return `/games/${chart.game}/charts/${chart.chartID}`; } // stolen from server -export function GetGoalIDsFromQuest(quest: MONGO_QuestDocument) { +export function GetGoalIDsFromQuest(quest: QuestDocument) { // this sucks - maybe a nicer way to do this, because nested // maps are just ugly return quest.questData.map((e) => e.goals.map((e) => e.goalID)).flat(1); @@ -105,7 +101,7 @@ export function GetGoalIDsFromQuest(quest: MONGO_QuestDocument) { export function CreateGoalSubDataset( data: GoalsOnChartReturn | GoalsOnFolderReturn, - userMap: Map, + userMap: Map, ) { const dataset = []; const goalMap = CreateGoalMap(data.goals); diff --git a/typescript/client/src/util/misc.ts b/typescript/client/src/util/misc.ts index 989281bd3..965810b0c 100644 --- a/typescript/client/src/util/misc.ts +++ b/typescript/client/src/util/misc.ts @@ -6,16 +6,14 @@ import { type AnyProfileRatingAlg, type AnySessionRatingAlg, type APIPermissions, - type GameGroup, - type GamePTConfig, - GetGamePTConfig, - GetGPTString, + type ChartDocument, + type GameConfig, + GetGameConfig, type integer, - type MONGO_ChartDocument, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, - type MONGO_ScoreDocument, - type Playtype, + type QuestDocument, + type QuestSubscriptionDocument, + type ScoreDocument, + type V3Game, } from "tachi-common"; export function RFA(arr: T[]): T { @@ -57,8 +55,7 @@ export function FormatTables(tables: Record) { } export function FormatGPTProfileRating( - game: GameGroup, - playtype: Playtype, + game: V3Game, key: AnyProfileRatingAlg, value: number | null, ) { @@ -66,18 +63,17 @@ export function FormatGPTProfileRating( return "No Data."; } - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - if (gptConfig.profileRatingAlgs[key].formatter) { - return gptConfig.profileRatingAlgs[key].formatter!(value); + if (gameConfig.profileRatingAlgs[key].formatter) { + return gameConfig.profileRatingAlgs[key].formatter!(value); } return ToFixedFloor(value, 2); } export function FormatGPTSessionRating( - game: GameGroup, - playtype: Playtype, + game: V3Game, key: AnySessionRatingAlg, value: number | null | undefined, ) { @@ -85,28 +81,28 @@ export function FormatGPTSessionRating( return "No Data."; } - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - if (gptConfig.sessionRatingAlgs[key].formatter) { - return gptConfig.sessionRatingAlgs[key].formatter!(value); + if (gameConfig.sessionRatingAlgs[key].formatter) { + return gameConfig.sessionRatingAlgs[key].formatter!(value); } return value.toFixed(2); } -export function FormatGPTProfileRatingName(game: GameGroup, playtype: Playtype, key: string) { - const gptConfig = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)]; - return gptConfig.ratingAlgNameOverrides?.profile?.[key] ?? UppercaseFirst(key); +export function FormatGPTProfileRatingName(game: V3Game, key: string) { + const gameConfig = GPT_CLIENT_IMPLEMENTATIONS[game]; + return gameConfig.ratingAlgNameOverrides?.profile?.[key] ?? UppercaseFirst(key); } -export function FormatGPTSessionRatingName(game: GameGroup, playtype: Playtype, key: string) { - const gptConfig = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)]; - return gptConfig.ratingAlgNameOverrides?.session?.[key] ?? UppercaseFirst(key); +export function FormatGPTSessionRatingName(game: V3Game, key: string) { + const gameConfig = GPT_CLIENT_IMPLEMENTATIONS[game]; + return gameConfig.ratingAlgNameOverrides?.session?.[key] ?? UppercaseFirst(key); } -export function FormatGPTScoreRatingName(game: GameGroup, playtype: Playtype, key: string) { - const gptConfig = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)]; - return gptConfig.ratingAlgNameOverrides?.score?.[key] ?? UppercaseFirst(key); +export function FormatGPTScoreRatingName(game: V3Game, key: string) { + const gameConfig = GPT_CLIENT_IMPLEMENTATIONS[game]; + return gameConfig.ratingAlgNameOverrides?.score?.[key] ?? UppercaseFirst(key); } export function ReverseStr(str: string) { @@ -183,9 +179,9 @@ export function ComposeLogFn(base: number) { } export function SelectRightChart( - _gptConfig: GamePTConfig, + _gameConfig: GameConfig, chartID: string, - charts: MONGO_ChartDocument[], + charts: ChartDocument[], ) { // try matching difficulty names, if it's not any of those, it's probably a chartID. for (const chart of charts) { @@ -261,16 +257,15 @@ export function CountElements(data: T[], collector: (element: T) => string | } export function FormatScoreRating( - game: GameGroup, - playtype: Playtype, - rating: keyof MONGO_ScoreDocument["calculatedData"], + game: V3Game, + rating: keyof ScoreDocument["calculatedData"], value: number | null | undefined, ) { if (value === null || value === undefined) { return "No Data."; } - const formatter = GetGamePTConfig(game, playtype).scoreRatingAlgs[rating].formatter; + const formatter = GetGameConfig(game).scoreRatingAlgs[rating].formatter; if (!formatter) { return value.toFixed(2); @@ -280,8 +275,7 @@ export function FormatScoreRating( } export function FormatSessionRating( - game: GameGroup, - playtype: Playtype, + game: V3Game, rating: AnySessionRatingAlg, value: number | null | undefined, ) { @@ -289,7 +283,7 @@ export function FormatSessionRating( return "No Data."; } - const formatter = GetGamePTConfig(game, playtype).sessionRatingAlgs[rating].formatter; + const formatter = GetGameConfig(game).sessionRatingAlgs[rating].formatter; if (!formatter) { return value.toFixed(2); @@ -421,7 +415,7 @@ export function FlattenValue( ]; } -export function ExtractGameFromFile(file: string): GameGroup { +export function ExtractGameFromFile(file: string): V3Game { const match = /-(.*?).json$/u.exec(file); if (match === null) { @@ -430,7 +424,7 @@ export function ExtractGameFromFile(file: string): GameGroup { const [_, game] = match; - return game as GameGroup; + return game as V3Game; } /** @@ -550,8 +544,8 @@ export function clamp(a: number, low: number, up: number) { return a; } -export function CreateQuestMap(quests: Array) { - const map = new Map(); +export function CreateQuestMap(quests: Array) { + const map = new Map(); for (const q of quests) { map.set(q.questID, q); @@ -560,8 +554,8 @@ export function CreateQuestMap(quests: Array) { return map; } -export function CreateQuestSubMap(questSubs: Array) { - const map = new Map(); +export function CreateQuestSubMap(questSubs: Array) { + const map = new Map(); for (const q of questSubs) { map.set(q.questID, q); diff --git a/typescript/client/src/util/seeds.ts b/typescript/client/src/util/seeds.ts index 67e1ca8ed..5a9d3c1ca 100644 --- a/typescript/client/src/util/seeds.ts +++ b/typescript/client/src/util/seeds.ts @@ -12,17 +12,19 @@ import { } from "#types/seeds"; import { type AllDatabaseSeeds, + type BMSCourseDocument, + type ChartDocument, + computeFolderSlug, CreateSongMap, DatabaseSeedNames, + type FolderDocument, type GameGroup, - type MONGO_BMSCourseDocument, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_GoalDocument, - type MONGO_QuestDocument, - type MONGO_QuestlineDocument, - type MONGO_SongDocument, - type MONGO_TableDocument, + type GoalDocument, + type QuestDocument, + type QuestlineDocument, + type SeedFolderRow, + type SongDocument, + type TableDocument, } from "tachi-common"; import { APIFetchV1 } from "./api"; @@ -200,14 +202,16 @@ function RelateBMSCourses(data: Partial): BMSCourseWithRelated } const bmsSongs = data["songs-bms.json"]; - const bmsCharts = data["charts-bms.json"]; + const bms7kCharts = data["charts-bms-7k.json"] ?? []; + const bms14kCharts = data["charts-bms-14k.json"] ?? []; + const bmsCharts = [...bms7kCharts, ...bms14kCharts]; - if (!bmsSongs || !bmsCharts) { + if (!bmsSongs) { return []; } const songMap = CreateSongMap(bmsSongs); - const chartMap = new Map>(); + const chartMap = new Map>(); for (const chart of bmsCharts) { chartMap.set(chart.data.hashMD5, chart); @@ -232,7 +236,7 @@ function RelateBMSCourses(data: Partial): BMSCourseWithRelated return { chart: chartMap.get(e)!, - song: songMap.get(chartMap.get(e)!.songID)!, + song: songMap.get(chartMap.get(e)!.song.id)!, }; }), }, @@ -249,10 +253,34 @@ function RelateTables(data: Partial): TableWithRelated[] { return []; } - const folderMap = new Map(); + const folderById = new Map(); + const folderByGameSlug = new Map(); for (const folder of data["folders.json"] ?? []) { - folderMap.set(folder.folderID, folder); + const raw = folder as { + id?: string; + slug?: string; + versionFilter?: Array; + where?: string; + } & FolderDocument; + const id = raw.folderID ?? raw.id; + + if (id !== undefined) { + folderById.set(id, folder); + } + + const slug = + typeof raw.slug === "string" && raw.slug !== "" + ? raw.slug + : computeFolderSlug({ + game: String(raw.game), + id: id ?? "", + title: raw.title, + where: raw.where ?? "", + versionFilter: raw.versionFilter, + } as SeedFolderRow); + + folderByGameSlug.set(`${String(raw.game)}:${slug}`, folder); } return base.map((e) => ({ @@ -262,7 +290,15 @@ function RelateTables(data: Partial): TableWithRelated[] { // (which is entirely possible and has happened before) // then when the table renderer goes to render its folders, it will get // undefined anyway. - folders: Object.fromEntries(e.folders.map((e) => [e, folderMap.get(e)])), + folders: Object.fromEntries( + e.folders.map((ref) => { + const bySlug = folderByGameSlug.get(`${e.game}:${ref}`); + const byId = folderById.get(ref); + const doc = bySlug ?? byId; + + return [ref, doc]; + }), + ), }, })); } @@ -305,9 +341,9 @@ function RelateCharts(data: Partial, file: string): ChartWithR const [_, game] = /charts-(.*?)\.json/u.exec(file)!; // @ts-expect-error too lazy to fix this properly - const songs: MONGO_SongDocument[] = data[`songs-${game}.json`]; + const songs: SongDocument[] = data[`songs-${game}.json`]; // @ts-expect-error too lazy to fix this properly - const charts: MONGO_ChartDocument[] = data[`charts-${game}.json`]; + const charts: ChartDocument[] = data[`charts-${game}.json`]; if (!charts) { return []; @@ -322,7 +358,7 @@ function RelateCharts(data: Partial, file: string): ChartWithR return charts.map((e) => ({ ...e, __related: { - song: songMap.get(e.songID), + song: songMap.get(e.song.id), }, })); } @@ -443,12 +479,12 @@ function DiffCollection( // create lookup tables on UNIQ(T) -> T for the entire set. const headMap = new Map(); for (const d of headState) { - headMap.set(GetUniqID(collection, d), d); + headMap.set(GetUniqID(collection, d)!, d); } const baseMap = new Map(); for (const d of baseState) { - baseMap.set(GetUniqID(collection, d), d); + baseMap.set(GetUniqID(collection, d)!, d); } const allIds = Dedupe([...headMap.keys(), ...baseMap.keys()]); @@ -504,36 +540,36 @@ function DiffCollection( */ function GetUniqID(collection: K, value: AllDatabaseSeeds[K][0]) { if (collection.startsWith("songs-")) { - return (value as MONGO_SongDocument).id.toString(); + return (value as SongDocument).id.toString(); } else if (collection.startsWith("charts-")) { - return (value as MONGO_ChartDocument).chartID; + return (value as ChartDocument).chartID; } const c = collection as NotSongsChartsSeeds; switch (c) { case "bms-course-lookup.json": { - const v = value as MONGO_BMSCourseDocument; - return `${v.set}-${v.playtype}-${v.value}`; + const v = value as BMSCourseDocument; + return `${v.set}-${v.game}-${v.value}`; } case "folders.json": { - const v = value as MONGO_FolderDocument; - return v.folderID; + const v = value as { id?: string } & FolderDocument; + return v.folderID ?? v.id ?? ""; } case "tables.json": { - const v = value as MONGO_TableDocument; + const v = value as TableDocument; return v.tableID; } case "goals.json": { - const v = value as MONGO_GoalDocument; + const v = value as GoalDocument; return v.goalID; } case "quests.json": { - const v = value as MONGO_QuestDocument; + const v = value as QuestDocument; return v.questID; } case "questlines.json": { - const v = value as MONGO_QuestlineDocument; + const v = value as QuestlineDocument; return v.questlineID; } } diff --git a/typescript/client/src/util/site.ts b/typescript/client/src/util/site.ts index 343482a66..2f1148b4b 100644 --- a/typescript/client/src/util/site.ts +++ b/typescript/client/src/util/site.ts @@ -3,7 +3,7 @@ import { GetGameGroupConfig } from "tachi-common"; export function GetSortedGPTs() { const arr = []; - for (const game of TachiConfig.GAMES) { + for (const game of TachiConfig.GAME_GROUPS) { const gameConfig = GetGameGroupConfig(game); for (const playtype of gameConfig.playtypes) { arr.push({ game, playtype }); diff --git a/typescript/client/src/util/str-to-num.ts b/typescript/client/src/util/str-to-num.ts index c4306fd8d..1560a6d4c 100644 --- a/typescript/client/src/util/str-to-num.ts +++ b/typescript/client/src/util/str-to-num.ts @@ -1,13 +1,9 @@ -import { type GameGroup, GetGamePTConfig, GetScoreMetricConf, type Playtypes } from "tachi-common"; +import { GetGameConfig, GetScoreMetricConf, type V3Game } from "tachi-common"; -export function HumanFriendlyStrToEnumIndex( - game: GameGroup, - playtype: Playtypes[GameGroup], - enumMetric: string, -) { - const gptConfig = GetGamePTConfig(game, playtype); +export function HumanFriendlyStrToEnumIndex(game: V3Game, enumMetric: string) { + const gameConfig = GetGameConfig(game); - const conf = GetScoreMetricConf(gptConfig, enumMetric); + const conf = GetScoreMetricConf(gameConfig, enumMetric); if (conf?.type !== "ENUM") { return () => 0; // wut diff --git a/typescript/client/src/util/submit-file.ts b/typescript/client/src/util/submit-file.ts index 3ce96ff00..42d850b68 100644 --- a/typescript/client/src/util/submit-file.ts +++ b/typescript/client/src/util/submit-file.ts @@ -1,6 +1,6 @@ import { type ImportStates } from "#types/import"; import { type SetState } from "#types/react"; -import { type FileUploadImportTypes, type MONGO_ImportDocument } from "tachi-common"; +import { type FileUploadImportTypes, type ImportDocument } from "tachi-common"; import { APIFetchV1 } from "./api"; @@ -20,7 +20,7 @@ export default async function SubmitFile( setState({ state: "waiting_init" }); - const res = await APIFetchV1( + const res = await APIFetchV1( "/import/file", { method: "POST", diff --git a/typescript/client/src/util/tables/create-search.ts b/typescript/client/src/util/tables/create-search.ts index ca2fb67f8..2fd11782c 100644 --- a/typescript/client/src/util/tables/create-search.ts +++ b/typescript/client/src/util/tables/create-search.ts @@ -8,17 +8,15 @@ import { HumanFriendlyStrToEnumIndex } from "#util/str-to-num"; import { type ValueGetterOrHybrid } from "#util/ztable/search"; import { BMS_TABLES, - type GameGroup, - type GamePTConfig, - GetGamePTConfig, - type GPTString, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, - type Playtype, + type ChartDocument, + type GameConfig, + GetGameConfig, + type PBScoreDocument, + type ScoreDocument, + type V3Game, } from "tachi-common"; -function GetBMSTableVal(chart: MONGO_ChartDocument<"bms:7K" | "bms:14K">, key: string) { +function GetBMSTableVal(chart: ChartDocument<"bms-7k" | "bms-14k">, key: string) { for (const [table, level] of Object.entries(chart.data.tableFolders)) { if (table === key) { return Number(level); @@ -28,11 +26,8 @@ function GetBMSTableVal(chart: MONGO_ChartDocument<"bms:7K" | "bms:14K">, key: s return null; } -export function CreateDefaultScoreSearchParams( - game: GameGroup, - playtype: Playtype, -) { - const gptConfig = GetGamePTConfig(game, playtype); +export function CreateDefaultScoreSearchParams(game: GPT) { + const gameConfig = GetGameConfig(game); const searchFunctions: Record[0]>> = { artist: (x) => x.__related.song.artist, @@ -41,32 +36,28 @@ export function CreateDefaultScoreSearchParams x.__related.chart.levelNum, highlight: (x) => !!x.highlight, service: (x) => x.service, - ...GetMetricSearchParams(game, playtype), - ...CreateCalcDataSearchFns(gptConfig), + ...GetMetricSearchParams(game), + ...CreateCalcDataSearchFns(gameConfig), }; - if (game === "bms") { - HandleBMSNonsense(searchFunctions, playtype, (k) => k.__related.chart); + if (game === "bms-7k" || game === "bms-14k") { + HandleBMSNonsense(searchFunctions, game as "bms-7k" | "bms-14k", (k) => k.__related.chart); } return searchFunctions; } export function GetMetricSearchParams( - game: GameGroup, - playtype: Playtype, - kMapper: (v: any) => MONGO_PBScoreDocument | MONGO_ScoreDocument = (v) => v, + game: V3Game, + kMapper: (v: any) => PBScoreDocument | ScoreDocument = (v) => v, ) { - const searchFns: Record< - string, - ValueGetterOrHybrid - > = {}; + const searchFns: Record> = {}; - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); for (const [metric, conf] of Object.entries({ - ...gptConfig.providedMetrics, - ...gptConfig.derivedMetrics, + ...gameConfig.providedMetrics, + ...gameConfig.derivedMetrics, })) { switch (conf.type) { case "ENUM": @@ -88,7 +79,7 @@ export function GetMetricSearchParams( return [sv, dv]; }, - strToNum: HumanFriendlyStrToEnumIndex(game, playtype, metric), + strToNum: HumanFriendlyStrToEnumIndex(game, metric), }; break; case "INTEGER": @@ -101,11 +92,8 @@ export function GetMetricSearchParams( return searchFns; } -export function CreateDefaultPBSearchParams( - game: GameGroup, - playtype: Playtype, -) { - const gptConfig = GetGamePTConfig(game, playtype); +export function CreateDefaultPBSearchParams(game: GPT) { + const gameConfig = GetGameConfig(game); const searchFunctions: Record[0]>> = { artist: (x) => x.__related.song.artist, @@ -116,21 +104,18 @@ export function CreateDefaultPBSearchParams( rivalRanking: (x) => x.rankingData.rivalRank, highlight: (x) => !!x.highlight, username: (x) => x.__related.user?.username ?? null, - ...GetMetricSearchParams(game, playtype), - ...CreateCalcDataSearchFns(gptConfig), + ...GetMetricSearchParams(game), + ...CreateCalcDataSearchFns(gameConfig), }; - if (game === "bms") { - HandleBMSNonsense(searchFunctions, playtype, (k) => k.__related.chart); + if (game === "bms-7k" || game === "bms-14k") { + HandleBMSNonsense(searchFunctions, game as "bms-7k" | "bms-14k", (k) => k.__related.chart); } return searchFunctions; } -export function CreatePBCompareSearchParams( - game: GameGroup, - playtype: Playtype, -) { +export function CreatePBCompareSearchParams(game: GPT) { const searchFunctions: Record[0]>> = { artist: (x) => x.song.artist, title: (x) => x.song.title, @@ -138,18 +123,15 @@ export function CreatePBCompareSearchParams( level: (x) => x.chart.levelNum, }; - if (game === "bms") { - HandleBMSNonsense(searchFunctions, playtype, (k) => k.chart); + if (game === "bms-7k" || game === "bms-14k") { + HandleBMSNonsense(searchFunctions, game as "bms-7k" | "bms-14k", (k) => k.chart); } return searchFunctions; } -export function CreateDefaultFolderSearchParams( - game: GameGroup, - playtype: Playtype, -) { - const gptConfig = GetGamePTConfig(game, playtype); +export function CreateDefaultFolderSearchParams(game: GPT) { + const gameConfig = GetGameConfig(game); const searchFunctions: Record[0]>> = { artist: (x) => x.__related.song.artist, @@ -160,20 +142,20 @@ export function CreateDefaultFolderSearchParams x.__related.pb?.rankingData.rivalRank ?? null, highlight: (x) => !!x.__related.pb?.highlight, played: (x) => !!x.__related.pb, - ...GetMetricSearchParams(game, playtype, (k) => k.__related.pb), - ...CreateFolderCalcDataSearchFns(gptConfig), + ...GetMetricSearchParams(game, (k) => k.__related.pb), + ...CreateFolderCalcDataSearchFns(gameConfig), }; - if (game === "bms") { - HandleBMSNonsense(searchFunctions, playtype, (k) => k); + if (game === "bms-7k" || game === "bms-14k") { + HandleBMSNonsense(searchFunctions, game as "bms-7k" | "bms-14k", (k) => k); } return searchFunctions; } -function CreateFolderCalcDataSearchFns(gptConfig: GamePTConfig) { +function CreateFolderCalcDataSearchFns(gameConfig: GameConfig) { return Object.fromEntries( - Object.keys(gptConfig.scoreRatingAlgs).map((e) => [ + Object.keys(gameConfig.scoreRatingAlgs).map((e) => [ e.toLowerCase(), // @ts-expect-error this is fine please leave me alone (x: FolderDataset[0]) => x.__related.pb?.calculatedData[e] ?? null, @@ -181,9 +163,9 @@ function CreateFolderCalcDataSearchFns(gptConfig: GamePTConfig) { ); } -function CreateCalcDataSearchFns(gptConfig: GamePTConfig) { +function CreateCalcDataSearchFns(gameConfig: GameConfig) { return Object.fromEntries( - Object.keys(gptConfig.scoreRatingAlgs).map( + Object.keys(gameConfig.scoreRatingAlgs).map( // @ts-expect-error this is fine please leave me alone (e) => [e.toLowerCase(), (x: PBDataset[0]) => x.calculatedData[e]], ), @@ -195,11 +177,11 @@ function CreateCalcDataSearchFns(gptConfig: GamePTConfig) { */ function HandleBMSNonsense( searchFunctions: Record, - playtype: Playtype, - chartGetter: (u: any) => MONGO_ChartDocument<"bms:7K" | "bms:14K">, + game: "bms-7k" | "bms-14k", + chartGetter: (u: any) => ChartDocument<"bms-7k" | "bms-14k">, ) { const appendSearches: Record> = Object.fromEntries( - BMS_TABLES.filter((e) => e.playtype === playtype).map((e) => [ + BMS_TABLES.filter((e) => e.game === game).map((e) => [ e.asciiPrefix, (x) => GetBMSTableVal(chartGetter(x), e.prefix), ]), diff --git a/typescript/client/src/util/ztable/search.ts b/typescript/client/src/util/ztable/search.ts index 8ee6c3751..5dd026b26 100644 --- a/typescript/client/src/util/ztable/search.ts +++ b/typescript/client/src/util/ztable/search.ts @@ -51,7 +51,6 @@ export function ParseDirectives(search: string): Directive[] { continue; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [wholeMatch, key, mode, value] = ps; let v = value; diff --git a/typescript/common/package.json b/typescript/common/package.json index 67e9716dd..9c83798b9 100644 --- a/typescript/common/package.json +++ b/typescript/common/package.json @@ -13,7 +13,7 @@ }, "scripts": { "test": "vitest", - "bench": "vitest bench --passWithNoTests", + "bench": "vitest bench", "build": "tsgo -p tsconfig.build.json", "prepublishOnly": "tsgo -p tsconfig.build.json", "lint": "eslint .", diff --git a/typescript/common/src/config/config.test.ts b/typescript/common/src/config/config.test.ts index ed507ded7..2e62532ad 100644 --- a/typescript/common/src/config/config.test.ts +++ b/typescript/common/src/config/config.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "vitest"; -import { allSupportedGameGroups, GetGameGroupConfig, GetGamePTConfig } from "./config"; +import { IsValidGame } from "../utils/util"; +import { allSupportedGameGroups, GetGameGroupConfig, LEGACY_GetGamePTConfig } from "./config"; + +describe("#IsValidGame", () => { + it("accepts games and rejects unknown strings", () => { + expect(IsValidGame("iidx-sp")).toBe(true); + expect(IsValidGame("not-a-real-game")).toBe(false); + expect(IsValidGame("")).toBe(false); + }); +}); describe("#GetGameGroupConfig", () => { it("defines configs for every supported game group with valid IDs", () => { @@ -34,7 +43,7 @@ describe("#GetGamePTConfig", () => { const gameConfig = GetGameGroupConfig(game); for (const playtype of gameConfig.playtypes) { - const conf = GetGamePTConfig(game, playtype); + const conf = LEGACY_GetGamePTConfig(game, playtype); expect(conf, `'${game}:${playtype}' should have a config defined.`).toBeDefined(); if (!conf) { diff --git a/typescript/common/src/config/config.ts b/typescript/common/src/config/config.ts index 3c839f51a..e9052ca46 100644 --- a/typescript/common/src/config/config.ts +++ b/typescript/common/src/config/config.ts @@ -1,37 +1,47 @@ import { p } from "prudence"; import type { + GameConfig, GameGroup, GameGroupConfig, - GamePTConfig, - GPTString, - Playtype, - Playtypes, + LEGACY_GPTString, + LEGACY_Playtype, + LEGACY_Playtypes, + SpecificGameConfig, V3Game, } from "../types/game-config"; -import type { - INTERNAL_GAME_CONFIG as INTERNAL_GAME_GROUP_CONFIG, - INTERNAL_GAME_PT_CONFIG, -} from "../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../types/internals"; import type { ConfEnumScoreMetric, ConfScoreMetric } from "../types/metrics"; -import { ARCAEA_CONF, ARCAEA_TOUCH_CONF } from "./game-support/arcaea"; -import { BMS_7K_CONF, BMS_14K_CONF, BMS_CONF } from "./game-support/bms"; -import { CHUNITHM_CONF, CHUNITHM_SINGLE_CONF } from "./game-support/chunithm"; -import { DDR_CONF, DDR_DP_CONF, DDR_SP_CONF } from "./game-support/ddr"; -import { GITADORA_CONF, GITADORA_DORA_CONF, GITADORA_GITA_CONF } from "./game-support/gitadora"; -import { IIDX_CONF, IIDX_DP_CONF, IIDX_SP_CONF } from "./game-support/iidx"; -import { ITG_CONF, ITG_STAMINA_CONF } from "./game-support/itg"; -import { JUBEAT_CONF, JUBEAT_SINGLE_CONF } from "./game-support/jubeat"; -import { MAIMAI_CONF, MAIMAI_SINGLE_CONF } from "./game-support/maimai"; -import { MAIMAI_DX_CONF, MAIMAI_DX_SINGLE_CONF } from "./game-support/maimai-dx"; -import { MUSECA_CONF, MUSECA_SINGLE_CONF } from "./game-support/museca"; -import { ONGEKI_CONF, ONGEKI_SINGLE_CONF } from "./game-support/ongeki"; -import { PMS_CONF, PMS_CONTROLLER_CONF, PMS_KEYBOARD_CONF } from "./game-support/pms"; -import { POPN_9B_CONF, POPN_CONF } from "./game-support/popn"; -import { SDVX_CONF, SDVX_SINGLE_CONF } from "./game-support/sdvx"; -import { USC_CONF, USC_CONTROLLER_CONF, USC_KEYBOARD_CONF } from "./game-support/usc"; -import { WACCA_CONF, WACCA_SINGLE_CONF } from "./game-support/wacca"; +import { GAME_ARCAEA_CONF, GAME_GROUP_ARCAEA_CONF } from "./game-support/arcaea"; +import { GAME_BMS_7K_CONF, GAME_BMS_14K_CONF, GAME_GROUP_BMS_CONF } from "./game-support/bms"; +import { GAME_CHUNITHM_CONF, GAME_GROUP_CHUNITHM_CONF } from "./game-support/chunithm"; +import { GAME_DDR_DP_CONF, GAME_DDR_SP_CONF, GAME_GROUP_DDR_CONF } from "./game-support/ddr"; +import { + GAME_GITADORA_DORA_CONF, + GAME_GITADORA_GITA_CONF, + GAME_GROUP_GITADORA_CONF, +} from "./game-support/gitadora"; +import { GAME_GROUP_IIDX_CONF, GAME_IIDX_DP_CONF, GAME_IIDX_SP_CONF } from "./game-support/iidx"; +import { GAME_GROUP_ITG_CONF, GAME_ITG_STAMINA_CONF } from "./game-support/itg"; +import { GAME_GROUP_JUBEAT_CONF, GAME_JUBEAT_SINGLE_CONF } from "./game-support/jubeat"; +import { GAME_GROUP_MAIMAI_CONF, GAME_MAIMAI_CONF } from "./game-support/maimai"; +import { GAME_GROUP_MAIMAI_DX_CONF, GAME_MAIMAI_DX_CONF } from "./game-support/maimai-dx"; +import { GAME_GROUP_MUSECA_CONF, GAME_MUSECA_CONF } from "./game-support/museca"; +import { GAME_GROUP_ONGEKI_CONF, GAME_ONGEKI_CONF } from "./game-support/ongeki"; +import { + GAME_PMS_CONF, + GAME_PMS_CONTROLLER_CONF, + GAME_PMS_KEYBOARD_CONF, +} from "./game-support/pms"; +import { GAME_GROUP_POPN_CONF, GAME_POPN_CONF } from "./game-support/popn"; +import { GAME_GROUP_SDVX_CONF, GAME_SDVX_CONF } from "./game-support/sdvx"; +import { + GAME_GROUP_USC_CONF, + GAME_USC_CONTROLLER_CONF, + GAME_USC_KEYBOARD_CONF, +} from "./game-support/usc"; +import { GAME_GROUP_WACCA_CONF, GAME_WACCA_CONF } from "./game-support/wacca"; /** * All game groups that Tachi supports. @@ -39,23 +49,23 @@ import { WACCA_CONF, WACCA_SINGLE_CONF } from "./game-support/wacca"; * @warn DO NOT ACCESS THIS DIRECTLY! Use @see {GetGameGroupConfig} for better type safety. */ export const GAME_GROUP_CONFIGS = { - iidx: IIDX_CONF, - museca: MUSECA_CONF, - chunithm: CHUNITHM_CONF, - bms: BMS_CONF, - gitadora: GITADORA_CONF, - jubeat: JUBEAT_CONF, - maimai: MAIMAI_CONF, - maimaidx: MAIMAI_DX_CONF, - popn: POPN_CONF, - sdvx: SDVX_CONF, - usc: USC_CONF, - wacca: WACCA_CONF, - pms: PMS_CONF, - itg: ITG_CONF, - arcaea: ARCAEA_CONF, - ongeki: ONGEKI_CONF, - ddr: DDR_CONF, + iidx: GAME_GROUP_IIDX_CONF, + museca: GAME_GROUP_MUSECA_CONF, + chunithm: GAME_GROUP_CHUNITHM_CONF, + bms: GAME_GROUP_BMS_CONF, + gitadora: GAME_GROUP_GITADORA_CONF, + jubeat: GAME_GROUP_JUBEAT_CONF, + maimai: GAME_GROUP_MAIMAI_CONF, + maimaidx: GAME_GROUP_MAIMAI_DX_CONF, + popn: GAME_GROUP_POPN_CONF, + sdvx: GAME_GROUP_SDVX_CONF, + usc: GAME_GROUP_USC_CONF, + wacca: GAME_GROUP_WACCA_CONF, + pms: GAME_PMS_CONF, + itg: GAME_GROUP_ITG_CONF, + arcaea: GAME_GROUP_ARCAEA_CONF, + ongeki: GAME_GROUP_ONGEKI_CONF, + ddr: GAME_GROUP_DDR_CONF, } as const satisfies Record; /** @@ -70,45 +80,45 @@ export function GetGameGroupConfig(game: G): GameGroupConfi /** * Given a game and playtype, combine them into a GPTString. */ -export function GetGPTString(game: GameGroup, playtype: Playtype): GPTString { - return `${game}:${playtype}` as GPTString; +export function LEGACY_GetGPTString(game: GameGroup, playtype: LEGACY_Playtype): LEGACY_GPTString { + return `${game}:${playtype}` as LEGACY_GPTString; } -export function SplitGPT(gpt: GPTString) { - return gpt.split(":") as [GameGroup, Playtype]; +export function LEGACY_SplitGPT(gpt: LEGACY_GPTString) { + return gpt.split(":") as [GameGroup, LEGACY_Playtype]; } /** * Based on every declared playtype for every declared game, they all need a GPT * config. This controls almost everything about each GPT. */ -export const GAME_PT_CONFIGS = { - "iidx:SP": IIDX_SP_CONF, - "iidx:DP": IIDX_DP_CONF, - "museca:Single": MUSECA_SINGLE_CONF, - "sdvx:Single": SDVX_SINGLE_CONF, - "bms:14K": BMS_14K_CONF, - "bms:7K": BMS_7K_CONF, - "gitadora:Dora": GITADORA_DORA_CONF, - "gitadora:Gita": GITADORA_GITA_CONF, - "chunithm:Single": CHUNITHM_SINGLE_CONF, - "wacca:Single": WACCA_SINGLE_CONF, - "jubeat:Single": JUBEAT_SINGLE_CONF, - "popn:9B": POPN_9B_CONF, - "maimai:Single": MAIMAI_SINGLE_CONF, - "maimaidx:Single": MAIMAI_DX_SINGLE_CONF, - "pms:Controller": PMS_CONTROLLER_CONF, - "pms:Keyboard": PMS_KEYBOARD_CONF, - "usc:Controller": USC_CONTROLLER_CONF, - "usc:Keyboard": USC_KEYBOARD_CONF, - "itg:Stamina": ITG_STAMINA_CONF, - "arcaea:Touch": ARCAEA_TOUCH_CONF, - "ongeki:Single": ONGEKI_SINGLE_CONF, - "ddr:SP": DDR_SP_CONF, - "ddr:DP": DDR_DP_CONF, -} as const satisfies Record; +export const GAME_CONFIGS = { + "iidx-sp": GAME_IIDX_SP_CONF, + "iidx-dp": GAME_IIDX_DP_CONF, + museca: GAME_MUSECA_CONF, + sdvx: GAME_SDVX_CONF, + "bms-14k": GAME_BMS_14K_CONF, + "bms-7k": GAME_BMS_7K_CONF, + "gitadora-dora": GAME_GITADORA_DORA_CONF, + "gitadora-gita": GAME_GITADORA_GITA_CONF, + chunithm: GAME_CHUNITHM_CONF, + wacca: GAME_WACCA_CONF, + jubeat: GAME_JUBEAT_SINGLE_CONF, + popn: GAME_POPN_CONF, + maimai: GAME_MAIMAI_CONF, + maimaidx: GAME_MAIMAI_DX_CONF, + "pms-controller": GAME_PMS_CONTROLLER_CONF, + "pms-keyboard": GAME_PMS_KEYBOARD_CONF, + "usc-controller": GAME_USC_CONTROLLER_CONF, + "usc-keyboard": GAME_USC_KEYBOARD_CONF, + "itg-stamina": GAME_ITG_STAMINA_CONF, + arcaea: GAME_ARCAEA_CONF, + ongeki: GAME_ONGEKI_CONF, + "ddr-sp": GAME_DDR_SP_CONF, + "ddr-dp": GAME_DDR_DP_CONF, +} as const satisfies Record; -const v3GameMappings: Record = { +const v3GameMappings: Record = { "iidx:SP": "iidx-sp", "iidx:DP": "iidx-dp", "museca:Single": "museca", @@ -134,16 +144,16 @@ const v3GameMappings: Record = { "ddr:DP": "ddr-dp", }; -export function GamePTToV3(game: GameGroup, playtype: Playtype): V3Game { - return GPTStringToV3(GetGPTString(game, playtype)); +export function LEGACY_GameGroupPTToGame(game: GameGroup, playtype: LEGACY_Playtype): V3Game { + return GPTStringToGame(LEGACY_GetGPTString(game, playtype)); } -export function GPTStringToV3(gptString: GPTString): V3Game { +export function GPTStringToGame(gptString: LEGACY_GPTString): V3Game { return v3GameMappings[gptString]; } -export function V3ToGPTString(v3Game: V3Game): GPTString { - const mapping: Record = { +export function LEGACY_GameToGPTString(game: V3Game): LEGACY_GPTString { + const mapping: Record = { "iidx-sp": "iidx:SP", "iidx-dp": "iidx:DP", museca: "museca:Single", @@ -169,12 +179,12 @@ export function V3ToGPTString(v3Game: V3Game): GPTString { "ddr-sp": "ddr:SP", }; - return mapping[v3Game]; + return mapping[game]; } -export const v3AllGames = Object.values(v3GameMappings); +export const ALL_GAMES = Object.keys(GAME_CONFIGS) as Array; -export function V3ToGameGroup(v3Game: V3Game): GameGroup { +export function GameToGameGroup(game: V3Game): GameGroup { const mapping: Record = { "iidx-sp": "iidx", "iidx-dp": "iidx", @@ -201,51 +211,62 @@ export function V3ToGameGroup(v3Game: V3Game): GameGroup { "ddr-dp": "ddr", }; - return mapping[v3Game]; + return mapping[game]; } -export function V3ToGamePT(v3Game: V3Game): { game: GameGroup; playtype: Playtype } { - const gptString = V3ToGPTString(v3Game); - const [game, playtype] = SplitGPT(gptString); - return { game, playtype }; +export function LEGACY_GameToGameGroupPT(v3Game: V3Game): { + gameGroup: GameGroup; + playtype: LEGACY_Playtype; +} { + const gptString = LEGACY_GameToGPTString(v3Game); + const [gameGroup, playtype] = LEGACY_SplitGPT(gptString); + return { gameGroup, playtype }; +} + +export function LEGACY_GameToPlaytypeFn(v3Game: V3Game): LEGACY_Playtype { + return LEGACY_GameToGameGroupPT(v3Game).playtype; } /** * Returns the configuration for this Game + Playtype. The type here is expanded to * its most generic form, for easiest interaction. */ -export function GetGamePTConfig(game: GameGroup, playtype: Playtypes[GameGroup]): GamePTConfig { - const gptString = GetGPTString(game, playtype); +export function LEGACY_GetGamePTConfig( + gameGroup: GameGroup, + playtype: LEGACY_Playtypes[GameGroup], +): GameConfig { + const game = LEGACY_GameGroupPTToGame(gameGroup, playtype); - return GAME_PT_CONFIGS[gptString] as unknown as GamePTConfig; + return GAME_CONFIGS[game] as unknown as GameConfig; } -export function GetGPTConfig(gptString: GPTString): GamePTConfig { - return GAME_PT_CONFIGS[gptString] as unknown as GamePTConfig; +export function LEGACY_GetGPTConfig(gptString: LEGACY_GPTString): GameConfig { + const game = GPTStringToGame(gptString); + return GAME_CONFIGS[game] as unknown as GameConfig; } -export function V3GetGameConfig(game: V3Game): GamePTConfig { - return GAME_PT_CONFIGS[V3ToGPTString(game)] as unknown as GamePTConfig; +export function GetGameConfig(game: V3Game): GameConfig { + return GAME_CONFIGS[game] as unknown as GameConfig; } /** * Returns the configuration for this specific Game + Playtype. This type is narrowed * down to its least generic form, and is instead for gpt-specific use cases. */ -export function GetSpecificGPTConfig(gpt: GPT) { - return GAME_PT_CONFIGS[gpt]; +export function GetSpecificGameConfig(game: TGame) { + return GAME_CONFIGS[game] as unknown as SpecificGameConfig; } export const allSupportedGameGroups = Object.keys(GAME_GROUP_CONFIGS) as Array; -export const allGPTStrings = Object.keys(GAME_PT_CONFIGS) as Array; +export const allGPTStrings = Object.keys(GAME_CONFIGS) as Array; export function GetScoreMetrics( - gptConfig: GamePTConfig, + gameConfig: GameConfig, type?: Array | ConfScoreMetric["type"], ) { let metrics = [ - ...Object.entries(gptConfig.providedMetrics), - ...Object.entries(gptConfig.derivedMetrics), + ...Object.entries(gameConfig.providedMetrics), + ...Object.entries(gameConfig.derivedMetrics), ]; if (Array.isArray(type)) { @@ -257,10 +278,10 @@ export function GetScoreMetrics( return metrics.map((e) => e[0]); } -export function GetScoreEnumConfs(gptConfig: GamePTConfig) { +export function GetScoreEnumConfs(gameConfig: GameConfig) { const scoreMetrics = { - ...gptConfig.providedMetrics, - ...gptConfig.derivedMetrics, + ...gameConfig.providedMetrics, + ...gameConfig.derivedMetrics, }; const enumMetrics: Record> = {}; @@ -282,10 +303,10 @@ export function GetScoreEnumConfs(gptConfig: GamePTConfig) { * * @note GRAPH and NULLABLE_GRAPH types are never valid here. */ -export function ValidateMetric(gptConfig: GamePTConfig, metricName: string, metricValue: number) { - const scoreMetrics = GetScoreMetrics(gptConfig, ["DECIMAL", "INTEGER", "ENUM"]); +export function ValidateMetric(gameConfig: GameConfig, metricName: string, metricValue: number) { + const scoreMetrics = GetScoreMetrics(gameConfig, ["DECIMAL", "INTEGER", "ENUM"]); - const conf = gptConfig.providedMetrics[metricName] ?? gptConfig.derivedMetrics[metricName]; + const conf = gameConfig.providedMetrics[metricName] ?? gameConfig.derivedMetrics[metricName]; if (!conf || !scoreMetrics.includes(metricName)) { return `Invalid metric ${metricName}, Expected any of ${scoreMetrics.join(", ")}.`; @@ -306,6 +327,6 @@ export function ValidateMetric(gptConfig: GamePTConfig, metricName: string, metr return conf.validate(metricValue); } -export function GetScoreMetricConf(gptConfig: GamePTConfig, metric: string) { - return gptConfig.providedMetrics[metric] ?? gptConfig.derivedMetrics[metric]; +export function GetScoreMetricConf(gameConfig: GameConfig, metric: string) { + return gameConfig.providedMetrics[metric] ?? gameConfig.derivedMetrics[metric]; } diff --git a/typescript/common/src/config/game-support/arcaea.ts b/typescript/common/src/config/game-support/arcaea.ts index a9cef7d64..3079fd9ff 100644 --- a/typescript/common/src/config/game-support/arcaea.ts +++ b/typescript/common/src/config/game-support/arcaea.ts @@ -1,20 +1,21 @@ import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum } from "../../utils/util"; import { ClassValue, ToDecimalPlaces, zodNonNegativeInt } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const ARCAEA_CONF = { +export const GAME_GROUP_ARCAEA_CONF = { name: "Arcaea", + games: ["arcaea"], // Potential future controller playtype support? playtypes: ["Touch"], songData: z.strictObject({ displayVersion: z.string(), songPack: z.string(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; const ArcaeaBadges = [ ClassValue("BLUE", "Blue", "0.00 - 3.49 Potential"), @@ -41,7 +42,7 @@ const ArcaeaClasses = [ ClassValue("PHASE_11", "Phase 11", "Radiant Genesis"), ]; -export const ARCAEA_TOUCH_CONF = { +export const GAME_ARCAEA_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -100,7 +101,7 @@ export const ARCAEA_TOUCH_CONF = { difficulties: { type: "FIXED", order: ["Past", "Present", "Future", "Beyond"], - shorthand: { + format: { Past: "PST", Present: "PRS", Future: "FTR", @@ -136,4 +137,4 @@ export const ARCAEA_TOUCH_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["inGameStrID", "songTitle", "tachiSongID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/bms.ts b/typescript/common/src/config/game-support/bms.ts index daf5318e8..9bc2eae34 100644 --- a/typescript/common/src/config/game-support/bms.ts +++ b/typescript/common/src/config/game-support/bms.ts @@ -1,14 +1,15 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtPercent, FmtScoreNoCommas } from "../../utils/util"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const BMS_CONF = { +export const GAME_GROUP_BMS_CONF = { name: "BMS", + games: ["bms-7k", "bms-14k"], playtypes: ["7K", "14K"], songData: z.strictObject({ genre: z.nullable(z.string()), @@ -16,7 +17,7 @@ export const BMS_CONF = { subartist: z.nullable(z.string()), tableString: z.nullable(z.string()), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; export function FormatSieglindeBMS(sgl: number): string { if (sgl < 13) { @@ -161,7 +162,7 @@ export const BMSScratchDans = [ const RANDOM_SCHEMA = z.enum(["MIRROR", "NONRAN", "R-RANDOM", "RANDOM", "S-RANDOM"]); -export const BMS_7K_CONF = { +export const GAME_BMS_7K_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -351,7 +352,7 @@ export const BMS_7K_CONF = { // "CHART" isn't a very creative name, but this is in a similar vein to how // games with no playtypes use the value "Single". order: ["CHART"], - shorthand: {}, + format: {}, default: "CHART", }, @@ -389,10 +390,10 @@ export const BMS_7K_CONF = { }), supportedMatchTypes: ["bmsChartHash", "tachiSongID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; -export const BMS_14K_CONF = { - ...BMS_7K_CONF, +export const GAME_BMS_14K_CONF = { + ...GAME_BMS_7K_CONF, classes: { genocideDan: { type: "PROVIDED", values: BMSGenocideDans }, @@ -405,4 +406,4 @@ export const BMS_14K_CONF = { client: z.enum(["lr2oraja", "LR2"]).optional().nullable(), gauge: z.enum(["EASY", "NORMAL", "HARD", "EX-HARD"]).optional().nullable(), }), -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/chunithm.ts b/typescript/common/src/config/game-support/chunithm.ts index 3ae753a4e..b2ee61098 100644 --- a/typescript/common/src/config/game-support/chunithm.ts +++ b/typescript/common/src/config/game-support/chunithm.ts @@ -1,21 +1,22 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum } from "../../utils/util"; import { ClassValue, ToDecimalPlaces, zodNonNegativeInt } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const CHUNITHM_CONF = { +export const GAME_GROUP_CHUNITHM_CONF = { name: "CHUNITHM", + games: ["chunithm"], playtypes: ["Single"], songData: z.strictObject({ genre: z.string(), displayVersion: z.string(), duration: z.number().optional(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; export const CHUNITHMColours = [ ClassValue("BLUE", "青", "Blue: 0 - 1.99 Rating"), @@ -45,7 +46,7 @@ export const CHUNITHMClasses = [ ClassValue("DAN_INFINITE", "∞", "Infinite Class"), ]; -export const CHUNITHM_SINGLE_CONF = { +export const GAME_CHUNITHM_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -139,7 +140,7 @@ export const CHUNITHM_SINGLE_CONF = { difficulties: { type: "FIXED", order: ["BASIC", "ADVANCED", "EXPERT", "MASTER", "ULTIMA"], - shorthand: { + format: { BASIC: "B", ADVANCED: "A", EXPERT: "E", @@ -203,4 +204,4 @@ export const CHUNITHM_SINGLE_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["inGameID", "songTitle", "tachiSongID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/ddr.ts b/typescript/common/src/config/game-support/ddr.ts index 68ec769e2..40ea6c63f 100644 --- a/typescript/common/src/config/game-support/ddr.ts +++ b/typescript/common/src/config/game-support/ddr.ts @@ -1,7 +1,7 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum, FmtScoreNoCommas } from "../../utils/util"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; @@ -55,8 +55,9 @@ export const DDRFlare = [ ClassValue("WORLD", "世界 / WORLD"), ]; -export const DDR_CONF = { +export const GAME_GROUP_DDR_CONF = { name: "DDR", + games: ["ddr-sp", "ddr-dp"], playtypes: ["SP", "DP"], songData: z.strictObject({ inGameID: zodNonNegativeInt, @@ -64,9 +65,9 @@ export const DDR_CONF = { basename: z.string().optional(), ddrSongHash: z.string().optional(), // optional because konaste-only songs have no hashes }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; -export const DDR_SP_CONF = { +export const GAME_DDR_SP_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -174,7 +175,7 @@ export const DDR_SP_CONF = { difficulties: { type: "FIXED", order: DDR_DIFFICULTIES, - shorthand: { + format: { BEGINNER: "BEG", BASIC: "BAS", DIFFICULT: "DIF", @@ -212,8 +213,8 @@ export const DDR_SP_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["inGameID", "songTitle", "tachiSongID", "ddrSongHash"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; -export const DDR_DP_CONF = { - ...DDR_SP_CONF, -} as const satisfies INTERNAL_GAME_PT_CONFIG; +export const GAME_DDR_DP_CONF = { + ...GAME_DDR_SP_CONF, +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/gitadora.ts b/typescript/common/src/config/game-support/gitadora.ts index 3f1c774a0..f2594c44c 100644 --- a/typescript/common/src/config/game-support/gitadora.ts +++ b/typescript/common/src/config/game-support/gitadora.ts @@ -1,17 +1,18 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtPercent } from "../../utils/util"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const GITADORA_CONF = { +export const GAME_GROUP_GITADORA_CONF = { name: "GITADORA", + games: ["gitadora-gita", "gitadora-dora"], playtypes: ["Gita", "Dora"], songData: z.strictObject({}), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; const GitadoraColours = [ ClassValue("WHITE", "白", "White"), @@ -33,7 +34,7 @@ const GitadoraColours = [ ClassValue("RAINBOW", "虹", "Rainbow"), ]; -export const GITADORA_GITA_CONF = { +export const GAME_GITADORA_GITA_CONF = { providedMetrics: { percent: { type: "DECIMAL", @@ -94,7 +95,7 @@ export const GITADORA_GITA_CONF = { "BASS EXTREME", "BASS MASTER", ], - shorthand: { + format: { BASIC: "G-BSC", ADVANCED: "G-ADV", EXTREME: "G-EXT", @@ -131,15 +132,15 @@ export const GITADORA_GITA_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["inGameID", "songTitle", "tachiSongID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; -export const GITADORA_DORA_CONF = { - ...GITADORA_GITA_CONF, +export const GAME_GITADORA_DORA_CONF = { + ...GAME_GITADORA_GITA_CONF, difficulties: { type: "FIXED", order: ["BASIC", "ADVANCED", "EXTREME", "MASTER"], - shorthand: { + format: { BASIC: "D-BSC", ADVANCED: "D-ADV", EXTREME: "D-EXT", @@ -147,4 +148,4 @@ export const GITADORA_DORA_CONF = { }, default: "EXTREME", }, -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/iidx.ts b/typescript/common/src/config/game-support/iidx.ts index 4c99466b5..46febbe87 100644 --- a/typescript/common/src/config/game-support/iidx.ts +++ b/typescript/common/src/config/game-support/iidx.ts @@ -1,13 +1,14 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum, FmtPercent, FmtScoreNoCommas } from "../../utils/util"; import { ClassValue, zodNonNegativeInt, zodTierlistData } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const IIDX_CONF = { +export const GAME_GROUP_IIDX_CONF = { + games: ["iidx-sp", "iidx-dp"], name: "beatmania IIDX", playtypes: ["SP", "DP"], songData: z.strictObject({ @@ -19,7 +20,7 @@ export const IIDX_CONF = { eamusementCsvArtist: z.optional(z.string()), eamusementCsvGenre: z.optional(z.string()), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; export const IIDXDans = [ ClassValue("KYU_7", "七級", "7th Kyu"), @@ -55,7 +56,7 @@ const BASE_IIDX_CHART_DATA = { const RANDOM_SCHEMA = z.enum(["NONRAN", "MIRROR", "R-RANDOM", "RANDOM", "S-RANDOM"]); -export const IIDX_SP_CONF = { +export const GAME_IIDX_SP_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -209,23 +210,23 @@ export const IIDX_SP_CONF = { "Kiraku ANOTHER", "Kiraku LEGGENDARIA", ], - shorthand: { - NORMAL: "N", - HYPER: "H", - ANOTHER: "A", - LEGGENDARIA: "L", - "All Scratch NORMAL": "N (Scr.)", - "All Scratch HYPER": "H (Scr.)", - "All Scratch ANOTHER": "A (Scr.)", - "All Scratch LEGGENDARIA": "L (Scr.)", - "Kichiku NORMAL": "N (Kc.)", - "Kichiku HYPER": "H (Kc.)", - "Kichiku ANOTHER": "A (Kc.)", - "Kichiku LEGGENDARIA": "L (Kc.)", - "Kiraku NORMAL": "N (Kr.)", - "Kiraku HYPER": "H (Kr.)", - "Kiraku ANOTHER": "A (Kr.)", - "Kiraku LEGGENDARIA": "L (Kr.)", + format: { + NORMAL: "SPN", + HYPER: "SPH", + ANOTHER: "SPA", + LEGGENDARIA: "SPL", + "All Scratch NORMAL": "SPN (Scr.)", + "All Scratch HYPER": "SPH (Scr.)", + "All Scratch ANOTHER": "SPA (Scr.)", + "All Scratch LEGGENDARIA": "SPL (Scr.)", + "Kichiku NORMAL": "SPN (Kc.)", + "Kichiku HYPER": "SPH (Kc.)", + "Kichiku ANOTHER": "SPA (Kc.)", + "Kichiku LEGGENDARIA": "SPL (Kc.)", + "Kiraku NORMAL": "SPN (Kr.)", + "Kiraku HYPER": "SPH (Kr.)", + "Kiraku ANOTHER": "SPA (Kr.)", + "Kiraku LEGGENDARIA": "SPL (Kr.)", }, default: "ANOTHER", }, @@ -305,10 +306,10 @@ export const IIDX_SP_CONF = { }), supportedMatchTypes: ["inGameID", "tachiSongID", "songTitle"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; -export const IIDX_DP_CONF = { - ...IIDX_SP_CONF, +export const GAME_IIDX_DP_CONF = { + ...GAME_IIDX_SP_CONF, chartData: z.strictObject({ ...BASE_IIDX_CHART_DATA, @@ -322,4 +323,45 @@ export const IIDX_DP_CONF = { range: z.enum(["NONE", "HIDDEN+", "SUDDEN+", "LIFT", "LIFT SUD+", "SUD+ HID+"]).optional(), gauge: z.enum(["ASSISTED EASY", "EASY", "NORMAL", "HARD", "EX-HARD"]).optional(), }), -} as const satisfies INTERNAL_GAME_PT_CONFIG; + + difficulties: { + type: "FIXED", + order: [ + "NORMAL", + "HYPER", + "ANOTHER", + "LEGGENDARIA", + "All Scratch NORMAL", + "All Scratch HYPER", + "All Scratch ANOTHER", + "All Scratch LEGGENDARIA", + "Kichiku NORMAL", + "Kichiku HYPER", + "Kichiku ANOTHER", + "Kichiku LEGGENDARIA", + "Kiraku NORMAL", + "Kiraku HYPER", + "Kiraku ANOTHER", + "Kiraku LEGGENDARIA", + ], + format: { + NORMAL: "DPN", + HYPER: "DPH", + ANOTHER: "DPA", + LEGGENDARIA: "DPL", + "All Scratch NORMAL": "DPN (Scr.)", + "All Scratch HYPER": "DPH (Scr.)", + "All Scratch ANOTHER": "DPA (Scr.)", + "All Scratch LEGGENDARIA": "DPL (Scr.)", + "Kichiku NORMAL": "DPN (Kc.)", + "Kichiku HYPER": "DPH (Kc.)", + "Kichiku ANOTHER": "DPA (Kc.)", + "Kichiku LEGGENDARIA": "DPL (Kc.)", + "Kiraku NORMAL": "DPN (Kr.)", + "Kiraku HYPER": "DPH (Kr.)", + "Kiraku ANOTHER": "DPA (Kr.)", + "Kiraku LEGGENDARIA": "DPL (Kr.)", + }, + default: "ANOTHER", + }, +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/itg.ts b/typescript/common/src/config/game-support/itg.ts index 4f9dc3d10..8f3564e82 100644 --- a/typescript/common/src/config/game-support/itg.ts +++ b/typescript/common/src/config/game-support/itg.ts @@ -1,20 +1,21 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtPercent } from "../../utils/util"; -import { NoDecimalPlace, zodNonNegativeInt } from "../config-utils"; +import { NoDecimalPlace } from "../config-utils"; -export const ITG_CONF = { +export const GAME_GROUP_ITG_CONF = { name: "ITG", + games: ["itg-stamina"], playtypes: ["Stamina"], songData: z.strictObject({ subtitle: z.string().nullable(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; -export const ITG_STAMINA_CONF = { +export const GAME_ITG_STAMINA_CONF = { providedMetrics: { scorePercent: { type: "DECIMAL", @@ -192,4 +193,4 @@ export const ITG_STAMINA_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["itgChartHash"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/jubeat.ts b/typescript/common/src/config/game-support/jubeat.ts index 9f5e102b7..b4875ce1c 100644 --- a/typescript/common/src/config/game-support/jubeat.ts +++ b/typescript/common/src/config/game-support/jubeat.ts @@ -1,19 +1,20 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum, FmtPercent } from "../../utils/util"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const JUBEAT_CONF = { +export const GAME_GROUP_JUBEAT_CONF = { name: "jubeat", + games: ["jubeat"], playtypes: ["Single"], songData: z.strictObject({ displayVersion: z.string(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; const JubeatColours = [ ClassValue("BLACK", "Black"), @@ -28,7 +29,7 @@ const JubeatColours = [ ClassValue("GOLD", "Gold"), ]; -export const JUBEAT_SINGLE_CONF = { +export const GAME_JUBEAT_SINGLE_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -97,7 +98,7 @@ export const JUBEAT_SINGLE_CONF = { difficulties: { type: "FIXED", order: ["BSC", "ADV", "EXT", "HARD BSC", "HARD ADV", "HARD EXT"], - shorthand: { + format: { BSC: "BSC", ADV: "ADV", EXT: "EXT", @@ -139,4 +140,4 @@ export const JUBEAT_SINGLE_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["inGameID", "tachiSongID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/maimai-dx.ts b/typescript/common/src/config/game-support/maimai-dx.ts index e31d0c98b..d1238fe0d 100644 --- a/typescript/common/src/config/game-support/maimai-dx.ts +++ b/typescript/common/src/config/game-support/maimai-dx.ts @@ -1,20 +1,21 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtPercent } from "../../utils/util"; import { ClassValue, NoDecimalPlace } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const MAIMAI_DX_CONF = { +export const GAME_GROUP_MAIMAI_DX_CONF = { name: "maimai DX", + games: ["maimaidx"], playtypes: ["Single"], songData: z.strictObject({ genre: z.string(), duration: z.number().optional(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; const MaimaiDXDans = [ ClassValue("DAN_1", "初段", "1st Dan"), @@ -65,7 +66,6 @@ const MaimaiDXMatchingClasses = [ ClassValue("B2", "B2"), ClassValue("B1", "B1"), - ClassValue("A5", "A5"), ClassValue("A5", "A5"), ClassValue("A4", "A4"), ClassValue("A3", "A3"), @@ -93,7 +93,7 @@ const MaimaiDXMatchingClasses = [ ClassValue("LEGEND", "Legend"), ]; -export const MAIMAI_DX_SINGLE_CONF = { +export const GAME_MAIMAI_DX_CONF = { providedMetrics: { percent: { type: "DECIMAL", @@ -187,7 +187,7 @@ export const MAIMAI_DX_SINGLE_CONF = { "DX Master", "DX Re:Master", ], - shorthand: { + format: { Basic: "BAS", Advanced: "ADV", Expert: "EXP", @@ -244,4 +244,4 @@ export const MAIMAI_DX_SINGLE_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["songTitle", "tachiSongID", "inGameID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/maimai.ts b/typescript/common/src/config/game-support/maimai.ts index a2668f1ee..b26d37444 100644 --- a/typescript/common/src/config/game-support/maimai.ts +++ b/typescript/common/src/config/game-support/maimai.ts @@ -1,20 +1,21 @@ import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtPercent } from "../../utils/util"; import { ClassValue, ToDecimalPlaces } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const MAIMAI_CONF = { +export const GAME_GROUP_MAIMAI_CONF = { name: "maimai", + games: ["maimai"], playtypes: ["Single"], songData: z.strictObject({ titleJP: z.string(), artistJP: z.string(), displayVersion: z.string(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; const MaimaiDans = [ ClassValue("DAN_1", "初段", "1st Dan"), @@ -55,7 +56,7 @@ const MaimaiColours = [ ClassValue("RAINBOW", "Rainbow", ">=15 Rating"), ]; -export const MAIMAI_SINGLE_CONF = { +export const GAME_MAIMAI_CONF = { providedMetrics: { percent: { type: "DECIMAL", @@ -130,7 +131,7 @@ export const MAIMAI_SINGLE_CONF = { difficulties: { type: "FIXED", order: ["Easy", "Basic", "Advanced", "Expert", "Master", "Re:Master"], - shorthand: { + format: { Easy: "ESY", Basic: "BAS", Advanced: "ADV", @@ -169,4 +170,4 @@ export const MAIMAI_SINGLE_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["songTitle", "tachiSongID", "inGameID", "inGameStrID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/museca.ts b/typescript/common/src/config/game-support/museca.ts index ec2292844..36fa3d1a0 100644 --- a/typescript/common/src/config/game-support/museca.ts +++ b/typescript/common/src/config/game-support/museca.ts @@ -1,23 +1,24 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum } from "../../utils/util"; import { NoDecimalPlace, zodNonNegativeInt } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const MUSECA_CONF = { +export const GAME_GROUP_MUSECA_CONF = { name: "MÚSECA", + games: ["museca"], playtypes: ["Single"], songData: z.strictObject({ titleJP: z.string(), artistJP: z.string(), displayVersion: z.string(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; -export const MUSECA_SINGLE_CONF = { +export const GAME_MUSECA_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -84,7 +85,7 @@ export const MUSECA_SINGLE_CONF = { difficulties: { type: "FIXED", order: ["Green", "Yellow", "Red"], - shorthand: { Green: "G", Yellow: "Y", Red: "R" }, + format: { Green: "G", Yellow: "Y", Red: "R" }, default: "Red", }, @@ -106,4 +107,4 @@ export const MUSECA_SINGLE_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["songTitle", "tachiSongID", "inGameID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/ongeki.ts b/typescript/common/src/config/game-support/ongeki.ts index 7eac60691..b4ae84dba 100644 --- a/typescript/common/src/config/game-support/ongeki.ts +++ b/typescript/common/src/config/game-support/ongeki.ts @@ -1,14 +1,15 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum, FmtStars } from "../../utils/util"; import { ClassValue, ToDecimalPlaces } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const ONGEKI_CONF = { +export const GAME_GROUP_ONGEKI_CONF = { name: "O.N.G.E.K.I.", + games: ["ongeki"], playtypes: ["Single"], songData: z.strictObject({ genre: z.enum([ @@ -23,7 +24,7 @@ export const ONGEKI_CONF = { ]), duration: z.number(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; export const OngekiColours = [ ClassValue("BLUE", "水", "Blue: 0.000~3.999 Rating"), @@ -40,7 +41,7 @@ export const OngekiColours = [ ClassValue("RAINBOW_EX", "虹(極)", "Rainbow Extreme: 21.000~ Rating"), ]; -export const ONGEKI_SINGLE_CONF = { +export const GAME_ONGEKI_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -183,7 +184,7 @@ export const ONGEKI_SINGLE_CONF = { difficulties: { type: "FIXED", order: ["BASIC", "ADVANCED", "EXPERT", "MASTER", "LUNATIC"], - shorthand: { + format: { BASIC: "BAS", ADVANCED: "ADV", EXPERT: "EXP", @@ -236,4 +237,4 @@ export const ONGEKI_SINGLE_CONF = { scoreMeta: z.strictObject({}), supportedMatchTypes: ["songTitle", "tachiSongID", "inGameID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/pms.ts b/typescript/common/src/config/game-support/pms.ts index 74d9311eb..6f691f027 100644 --- a/typescript/common/src/config/game-support/pms.ts +++ b/typescript/common/src/config/game-support/pms.ts @@ -1,14 +1,15 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtPercent, FmtScoreNoCommas } from "../../utils/util"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; -import { BMS_7K_CONF } from "./bms"; +import { GAME_BMS_7K_CONF } from "./bms"; -export const PMS_CONF = { +export const GAME_PMS_CONF = { name: "PMS", + games: ["pms-controller", "pms-keyboard"], playtypes: ["Controller", "Keyboard"], songData: z.strictObject({ genre: z.nullable(z.string()), @@ -16,7 +17,7 @@ export const PMS_CONF = { subartist: z.nullable(z.string()), tableString: z.nullable(z.string()), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; const PMSDans = [ ClassValue("INSANE_1", "●1", "Insane 1st Dan"), @@ -42,7 +43,7 @@ function FormatSieglindePMS(sgl: number): string { return `●${(sgl - 12).toFixed(2)}`; } -export const PMS_CONTROLLER_CONF = { +export const GAME_PMS_CONTROLLER_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -93,7 +94,7 @@ export const PMS_CONTROLLER_CONF = { preferredDefaultEnum: "lamp", optionalMetrics: { - ...BMS_7K_CONF.optionalMetrics, + ...GAME_BMS_7K_CONF.optionalMetrics, }, scoreRatingAlgs: { @@ -125,7 +126,7 @@ export const PMS_CONTROLLER_CONF = { difficulties: { type: "FIXED", order: ["CHART"], - shorthand: {}, + format: {}, default: "CHART", }, @@ -154,6 +155,6 @@ export const PMS_CONTROLLER_CONF = { }), supportedMatchTypes: ["bmsChartHash", "tachiSongID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; -export const PMS_KEYBOARD_CONF = PMS_CONTROLLER_CONF; +export const GAME_PMS_KEYBOARD_CONF = GAME_PMS_CONTROLLER_CONF; diff --git a/typescript/common/src/config/game-support/popn.ts b/typescript/common/src/config/game-support/popn.ts index 78153dc6b..b37a0f264 100644 --- a/typescript/common/src/config/game-support/popn.ts +++ b/typescript/common/src/config/game-support/popn.ts @@ -1,21 +1,22 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum, FmtPercent } from "../../utils/util"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const POPN_CONF = { +export const GAME_GROUP_POPN_CONF = { name: "pop'n music", + games: ["popn"], playtypes: ["9B"], songData: z.strictObject({ displayVersion: z.nullable(z.string()), genre: z.string(), genreEN: z.nullable(z.string()), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; const PopnClasses = [ ClassValue("KITTY", "にゃんこ", "Kitty"), @@ -28,7 +29,7 @@ const PopnClasses = [ ClassValue("GOD", "神", "God"), ]; -export const POPN_9B_CONF = { +export const GAME_POPN_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -118,7 +119,7 @@ export const POPN_9B_CONF = { difficulties: { type: "FIXED", order: ["Easy", "Normal", "Hyper", "EX"], - shorthand: { + format: { Easy: "E", Normal: "N", Hyper: "H", @@ -160,4 +161,4 @@ export const POPN_9B_CONF = { preferences: z.strictObject({}), supportedMatchTypes: ["inGameID", "tachiSongID", "popnChartHash"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/sdvx.ts b/typescript/common/src/config/game-support/sdvx.ts index a86580239..86451a0ea 100644 --- a/typescript/common/src/config/game-support/sdvx.ts +++ b/typescript/common/src/config/game-support/sdvx.ts @@ -1,19 +1,20 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum, FmtPercent } from "../../utils/util"; import { ClassValue, ToDecimalPlaces, zodNonNegativeInt, zodTierlistData } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const SDVX_CONF = { +export const GAME_GROUP_SDVX_CONF = { name: "SOUND VOLTEX", + games: ["sdvx"], playtypes: ["Single"], songData: z.strictObject({ displayVersion: z.string(), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; export const SDVXDans = [ ClassValue("DAN_1", "LV.01", "1st Dan"), @@ -73,7 +74,7 @@ export const SDVXVFClasses = [ ClassValue("IMPERIAL_IV", "Imperial IV", ">23VF"), ]; -export const SDVX_SINGLE_CONF = { +export const GAME_SDVX_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -158,7 +159,7 @@ export const SDVX_SINGLE_CONF = { difficulties: { type: "FIXED", order: ["NOV", "ADV", "EXH", "INF", "GRV", "HVN", "VVD", "XCD", "MXM", "ULT"], - shorthand: {}, // they're all already short enough + format: {}, // they're all already short enough default: "EXH", }, @@ -199,4 +200,4 @@ export const SDVX_SINGLE_CONF = { }), supportedMatchTypes: ["sdvxInGameID", "songTitle", "tachiSongID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/config/game-support/usc.ts b/typescript/common/src/config/game-support/usc.ts index 6dd6ed61c..ac9f6426b 100644 --- a/typescript/common/src/config/game-support/usc.ts +++ b/typescript/common/src/config/game-support/usc.ts @@ -1,20 +1,21 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum, FmtPercent } from "../../utils/util"; import { ToDecimalPlaces } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; import { SDVXVFClasses } from "./sdvx"; -export const USC_CONF = { +export const GAME_GROUP_USC_CONF = { name: "USC", + games: ["usc-controller", "usc-keyboard"], playtypes: ["Controller", "Keyboard"], songData: z.strictObject({}), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; -export const USC_CONTROLLER_CONF = { +export const GAME_USC_CONTROLLER_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -87,7 +88,9 @@ export const USC_CONTROLLER_CONF = { difficulties: { type: "FIXED", order: ["NOV", "ADV", "EXH", "INF"], - shorthand: {}, // they're all already short enough. + // deliberately not ported to v3 - formatting of charts as + // "CON EXH" etc. + format: {}, default: "EXH", }, @@ -117,6 +120,6 @@ export const USC_CONTROLLER_CONF = { }), supportedMatchTypes: ["uscChartHash", "tachiSongID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; -export const USC_KEYBOARD_CONF = USC_CONTROLLER_CONF; +export const GAME_USC_KEYBOARD_CONF = GAME_USC_CONTROLLER_CONF; diff --git a/typescript/common/src/config/game-support/wacca.ts b/typescript/common/src/config/game-support/wacca.ts index 6d00e3d3e..6a4d21cf9 100644 --- a/typescript/common/src/config/game-support/wacca.ts +++ b/typescript/common/src/config/game-support/wacca.ts @@ -1,20 +1,21 @@ import { p } from "prudence"; import { z } from "zod"; -import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; +import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../../types/internals"; import { FmtNum } from "../../utils/util"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; import { FAST_SLOW_MAXCOMBO } from "./_common"; -export const WACCA_CONF = { +export const GAME_GROUP_WACCA_CONF = { name: "WACCA", + games: ["wacca"], playtypes: ["Single"], songData: z.strictObject({ genre: z.string(), displayVersion: z.nullable(z.string()), }), -} as const satisfies INTERNAL_GAME_CONFIG; +} as const satisfies INTERNAL_GAME_GROUP_CONFIG; export const WaccaStageUps = [ ClassValue("I", "I"), @@ -45,7 +46,7 @@ export const WaccaColours = [ ClassValue("RAINBOW", "Rainbow"), ]; -export const WACCA_SINGLE_CONF = { +export const GAME_WACCA_CONF = { providedMetrics: { score: { type: "INTEGER", @@ -113,7 +114,7 @@ export const WACCA_SINGLE_CONF = { difficulties: { type: "FIXED", order: ["NORMAL", "HARD", "EXPERT", "INFERNO"], - shorthand: { + format: { NORMAL: "NRM", HARD: "HRD", EXPERT: "EXP", @@ -149,4 +150,4 @@ export const WACCA_SINGLE_CONF = { scoreMeta: z.strictObject({ mirror: z.boolean().optional() }), supportedMatchTypes: ["songTitle", "tachiSongID", "inGameID"], -} as const satisfies INTERNAL_GAME_PT_CONFIG; +} as const satisfies INTERNAL_GAME_CONFIG; diff --git a/typescript/common/src/constants/bms-tables.test.ts b/typescript/common/src/constants/bms-tables.test.ts index 7602e91fb..9e82dd2ea 100644 --- a/typescript/common/src/constants/bms-tables.test.ts +++ b/typescript/common/src/constants/bms-tables.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; import { BMS_TABLES } from "./bms-tables"; describe("BMS_TABLES", () => { - it("should have unique playtype-prefix keys", () => { - const allKeys = BMS_TABLES.map((e) => `${e.playtype}-${e.prefix}`); + it("should have unique game-prefix keys", () => { + const allKeys = BMS_TABLES.map((e) => `${e.game}-${e.prefix}`); expect(allKeys).toStrictEqual([...new Set(allKeys)]); }); diff --git a/typescript/common/src/constants/bms-tables.ts b/typescript/common/src/constants/bms-tables.ts index 88d762acc..91760fa31 100644 --- a/typescript/common/src/constants/bms-tables.ts +++ b/typescript/common/src/constants/bms-tables.ts @@ -1,4 +1,4 @@ -import type { Playtypes } from "../types"; +import type { BMSGames } from "../types"; import { COLOUR_SET } from "./colour-set"; @@ -6,13 +6,14 @@ export interface BMSTableInfo { prefix: string; /** - * Used to generate tableIDs. This stops us from having characters like "◎" in tableIDs - * which would undoubtedly break some URLs. + * Used to generate tableIDs and folder slugs. This stops us from having characters like "◎" in tableIDs + * which would undoubtedly break some URLs. (Also referred to as an ASCII-friendly identifier for the + * `prefix`.) */ asciiPrefix: string; name: string; description: string; - playtype: Playtypes["bms"]; + game: BMSGames; url: string; // Should this table be hidden in the UI by default? @@ -29,7 +30,7 @@ export interface BMSTableInfo { export const BMS_TABLES: Array = [ { name: "Insane", - playtype: "7K", + game: "bms-7k", description: "The 7K GENOSIDE insane table.", url: "https://darksabun.github.io/table/archive/insane1/", prefix: "★", @@ -38,7 +39,7 @@ export const BMS_TABLES: Array = [ }, { name: "Normal", - playtype: "7K", + game: "bms-7k", description: "The 7K GENOSIDE normal table.", url: "https://darksabun.github.io/table/archive/normal1/", prefix: "☆", @@ -47,7 +48,7 @@ export const BMS_TABLES: Array = [ }, { name: "Stella", - playtype: "7K", + game: "bms-7k", description: "The stella table, from Stellaverse.", url: "https://stellabms.xyz/st/table.html", prefix: "st", @@ -56,7 +57,7 @@ export const BMS_TABLES: Array = [ }, { name: "Satellite", - playtype: "7K", + game: "bms-7k", description: "The satellite table, from Stellaverse.", url: "https://stellabms.xyz/sl/table.html", prefix: "sl", @@ -65,7 +66,7 @@ export const BMS_TABLES: Array = [ }, { name: "Insane2", - playtype: "7K", + game: "bms-7k", description: "A successor to the original insane table, but is generally less consistent.", url: "https://rattoto10.github.io/second_table/insane_header.json", prefix: "▼", @@ -74,7 +75,7 @@ export const BMS_TABLES: Array = [ }, { name: "Normal2", - playtype: "7K", + game: "bms-7k", description: "A successor to the original normal table.", url: "https://rattoto10.github.io/second_table/header.json", prefix: "▽", @@ -84,7 +85,7 @@ export const BMS_TABLES: Array = [ { name: "Overjoy", description: "The overjoy table. Level 1 is roughly equivalent to Insane 20.", - playtype: "7K", + game: "bms-7k", url: "http://rattoto10.jounin.jp/table_overjoy.html", prefix: "★★", asciiPrefix: "overjoy", @@ -93,7 +94,7 @@ export const BMS_TABLES: Array = [ { name: "DP Insane", description: "The 14K Insane table.", - playtype: "14K", + game: "bms-14k", prefix: "★", asciiPrefix: "dpInsane", url: "http://dpbmsdelta.web.fc2.com/table/insane.html", @@ -102,7 +103,7 @@ export const BMS_TABLES: Array = [ { name: "DP Normal", description: "The 14K Normal table, Sometimes called the delta table.", - playtype: "14K", + game: "bms-14k", prefix: "δ", asciiPrefix: "dpNormal", url: "http://dpbmsdelta.web.fc2.com/table/dpdelta.html", @@ -111,7 +112,7 @@ export const BMS_TABLES: Array = [ { name: "DP Satellite", description: "The 14K Satellite table.", - playtype: "14K", + game: "bms-14k", prefix: "sl", asciiPrefix: "dpSatellite", url: "https://stellabms.xyz/dp/table.html", @@ -122,7 +123,7 @@ export const BMS_TABLES: Array = [ description: "The 7K Sara 3 table.", prefix: "h◎", asciiPrefix: "scratch", - playtype: "7K", + game: "bms-7k", url: "http://minddnim.web.fc2.com/sara/3rd_hard/bms_sara_3rd_hard.html", colour: COLOUR_SET.vibrantRed, }, @@ -131,7 +132,7 @@ export const BMS_TABLES: Array = [ description: "The 7K LN table.", prefix: "◆", asciiPrefix: "ln", - playtype: "7K", + game: "bms-7k", url: "http://flowermaster.web.fc2.com/lrnanido/gla/LN.html", colour: COLOUR_SET.pink, }, @@ -139,7 +140,7 @@ export const BMS_TABLES: Array = [ name: "Stardust", prefix: "ξ", asciiPrefix: "stardust", - playtype: "7K", + game: "bms-7k", url: "https://mqppppp.neocities.org/StardustTable.html", description: "The 7K Stardust table. This table covers ☆1 to ☆7.", colour: COLOUR_SET.paleBlue, @@ -148,7 +149,7 @@ export const BMS_TABLES: Array = [ name: "Starlight", prefix: "sr", asciiPrefix: "starlight", - playtype: "7K", + game: "bms-7k", url: "https://djkuroakari.github.io/starlighttable.html", description: "The 7K Starlight table. This table covers ☆7 to ☆12.", colour: COLOUR_SET.blue, @@ -157,7 +158,7 @@ export const BMS_TABLES: Array = [ name: "LN Overjoy", prefix: "◆◆", asciiPrefix: "lnOverjoy", - playtype: "7K", + game: "bms-7k", url: "https://notepara.com/glassist/lnoj", description: "The 7K LN Overjoy table.", notDefault: true, @@ -167,7 +168,7 @@ export const BMS_TABLES: Array = [ name: "Luminous", prefix: "ln", asciiPrefix: "luminous", - playtype: "7K", + game: "bms-7k", url: "https://ladymade-star.github.io/luminous/", description: "The 7K Luminous Table. This is an alternative to the mainline LN tables.", notDefault: true, @@ -177,7 +178,7 @@ export const BMS_TABLES: Array = [ name: "Gachimjoy", prefix: "双", asciiPrefix: "gachimjoy", - playtype: "7K", + game: "bms-7k", url: "http://su565fx.web.fc2.com/Gachimijoy/gachimijoy.html", notDefault: true, description: @@ -189,7 +190,7 @@ export const BMS_TABLES: Array = [ // these trailing slashes are important. keep them. url: "https://lets-go-time-hell.github.io/Delay-joy-table/", - playtype: "7K", + game: "bms-7k", prefix: "dl", notDefault: true, description: @@ -199,7 +200,7 @@ export const BMS_TABLES: Array = [ name: "Arm Shougakkou", asciiPrefix: "armShougakkou", url: "https://lets-go-time-hell.github.io/Arm-Shougakkou-table/", - playtype: "7K", + game: "bms-7k", prefix: "Ude", notDefault: true, description: @@ -209,7 +210,7 @@ export const BMS_TABLES: Array = [ name: "Exoplanet", asciiPrefix: "exoplanet", url: "https://stellabms.xyz/fr/table.html", - playtype: "7K", + game: "bms-7k", prefix: "fr", notDefault: true, description: @@ -219,14 +220,14 @@ export const BMS_TABLES: Array = [ name: "DP Library", asciiPrefix: "dpLibrary", url: "https://yaruki0.net/DPlibrary/", - playtype: "14K", + game: "bms-14k", prefix: "☆", notDefault: true, description: "A huge collection of Normal-Scale 14K charts.", }, { name: "Dystopia", - playtype: "7K", + game: "bms-7k", description: "The dystopia table, dy0-dy7 == st5-st12.", url: "https://monibms.github.io/Dystopia/dystopia.html", prefix: "dy", @@ -237,7 +238,7 @@ export const BMS_TABLES: Array = [ name: "Scramble", prefix: "SB", asciiPrefix: "Scramble", - playtype: "7K", + game: "bms-7k", description: "The Scramble table, a 7k scratch table", url: "https://egret9.github.io/Scramble/", colour: COLOUR_SET.vibrantOrange, @@ -246,7 +247,7 @@ export const BMS_TABLES: Array = [ name: "Supernova", asciiPrefix: "supernova", url: "https://stellabms.xyz/sn/table.html", - playtype: "7K", + game: "bms-7k", prefix: "sn", description: "Supernova, Stellaverse's alternative table for Insane1-esque charts. Follows the Stella table in difficulty.", @@ -255,7 +256,7 @@ export const BMS_TABLES: Array = [ name: "Solar", asciiPrefix: "solar", url: "https://stellabms.xyz/so/table.html", - playtype: "7K", + game: "bms-7k", prefix: "so", description: "Solar, Stellaverse's alternative table for Insane1-esque charts. Follows the Satellite table in difficulty.", @@ -264,7 +265,7 @@ export const BMS_TABLES: Array = [ name: "Code Stream (st)", asciiPrefix: "csst", url: "https://air-afother.github.io/chordstream-table-split/chordstreamST.html", - playtype: "7K", + game: "bms-7k", prefix: "重発狂", description: "16th Chordstream table, st0+ difficulty", }, @@ -272,8 +273,58 @@ export const BMS_TABLES: Array = [ name: "Code Stream (sl)", asciiPrefix: "cssl", url: "https://air-afother.github.io/chordstream-table-split/chordstream.html", - playtype: "7K", + game: "bms-7k", prefix: "乱打", description: "16th Chordstream table, sl0-sl12 difficulty", }, + + // PMS `tableFolders` keys (seeds/folders.json); no public table URL. + { + name: "PMS ● (controller)", + game: "pms-controller", + prefix: "●", + asciiPrefix: "pmsCircle", + description: "PMS tableFolders key ● (dan / level charts).", + url: "https://github.com/TachiServices/Tachi", + }, + { + name: "PMS P● (controller)", + game: "pms-controller", + prefix: "P●", + asciiPrefix: "pmsPStar", + description: "PMS tableFolders key P●.", + url: "https://github.com/TachiServices/Tachi", + }, + { + name: "PMS PLv (controller)", + game: "pms-controller", + prefix: "PLv", + asciiPrefix: "pmsPLv", + description: "PMS tableFolders key PLv.", + url: "https://github.com/TachiServices/Tachi", + }, + { + name: "PMS ● (keyboard)", + game: "pms-keyboard", + prefix: "●", + asciiPrefix: "pmsCircle", + description: "PMS tableFolders key ● (dan / level charts).", + url: "https://github.com/TachiServices/Tachi", + }, + { + name: "PMS P● (keyboard)", + game: "pms-keyboard", + prefix: "P●", + asciiPrefix: "pmsPStar", + description: "PMS tableFolders key P●.", + url: "https://github.com/TachiServices/Tachi", + }, + { + name: "PMS PLv (keyboard)", + game: "pms-keyboard", + prefix: "PLv", + asciiPrefix: "pmsPLv", + description: "PMS tableFolders key PLv.", + url: "https://github.com/TachiServices/Tachi", + }, ]; diff --git a/typescript/common/src/constants/grade-boundaries.ts b/typescript/common/src/constants/grade-boundaries.ts index afd26c876..d2563a682 100644 --- a/typescript/common/src/constants/grade-boundaries.ts +++ b/typescript/common/src/constants/grade-boundaries.ts @@ -1,4 +1,4 @@ -import type { GPTStrings } from "../types/game-config"; +import type { GamesForGroup } from "../types/game-config"; import type { GetEnumValue } from "../types/metrics"; export interface GradeBoundary { @@ -20,7 +20,7 @@ export function MakeGradeBoundaries(rec: Record) { return out; } -export type IIDXLikes = GPTStrings["bms" | "iidx" | "pms"]; +export type IIDXLikes = GamesForGroup["bms" | "iidx" | "pms"]; /** * @note - These are *also* grade boundaries for BMS and PMS. @@ -47,7 +47,7 @@ export const IIDXLIKE_GBOUNDARIES = MakeGradeBoundaries>({ +export const CHUNITHM_GBOUNDARIES = MakeGradeBoundaries>({ D: 0, C: 500_000, B: 600_000, @@ -64,7 +64,7 @@ export const CHUNITHM_GBOUNDARIES = MakeGradeBoundaries>({ +export const WACCA_GBOUNDARIES = MakeGradeBoundaries>({ D: 0, C: 1, // LOL B: 300_100, @@ -80,7 +80,7 @@ export const WACCA_GBOUNDARIES = MakeGradeBoundaries>({ +export const JUBEAT_GBOUNDARIES = MakeGradeBoundaries>({ E: 0, D: 500_000, C: 700_000, @@ -92,7 +92,7 @@ export const JUBEAT_GBOUNDARIES = MakeGradeBoundaries>({ +export const ITG_GBOUNDARIES = MakeGradeBoundaries>({ // cannot be achieved. This can only be attained if the player failed the chart. F: -Infinity, D: 0, // The lowest score you can get in ITG is -400%. Scores "can" go negative. @@ -109,7 +109,7 @@ export const ITG_GBOUNDARIES = MakeGradeBoundaries + GetEnumValue<"gitadora-dora" | "gitadora-gita", "grade"> >({ C: 0, B: 63, @@ -119,7 +119,7 @@ export const GITADORA_GBOUNDARIES = MakeGradeBoundaries< MAX: 100, }); -export const MUSECA_GBOUNDARIES = MakeGradeBoundaries>({ +export const MUSECA_GBOUNDARIES = MakeGradeBoundaries>({ 没: 0, 拙: 600_000, 凡: 700_000, @@ -135,7 +135,7 @@ export const MUSECA_GBOUNDARIES = MakeGradeBoundaries>({ +export const MAIMAI_GBOUNDARIES = MakeGradeBoundaries>({ F: 0, E: 10, D: 20, @@ -153,7 +153,7 @@ export const MAIMAI_GBOUNDARIES = MakeGradeBoundaries>({ +export const MAIMAIDX_GBOUNDARIES = MakeGradeBoundaries>({ D: 0, C: 50, B: 60, @@ -170,7 +170,7 @@ export const MAIMAIDX_GBOUNDARIES = MakeGradeBoundaries>({ +export const POPN_GBOUNDARIES = MakeGradeBoundaries>({ E: 0, D: 50_000, C: 62_000, @@ -181,7 +181,7 @@ export const POPN_GBOUNDARIES = MakeGradeBoundaries>({ D: 0, @@ -197,7 +197,7 @@ export const SDVXLIKE_GBOUNDARIES = MakeGradeBoundaries>({ +export const ARCAEA_GBOUNDARIES = MakeGradeBoundaries>({ D: 0, C: 8_600_000, B: 8_900_000, @@ -207,7 +207,7 @@ export const ARCAEA_GBOUNDARIES = MakeGradeBoundaries>({ +export const ONGEKI_GBOUNDARIES = MakeGradeBoundaries>({ D: 0, C: 500_000, B: 700_000, @@ -222,7 +222,7 @@ export const ONGEKI_GBOUNDARIES = MakeGradeBoundaries>({ +export const DDR_GBOUNDARIES = MakeGradeBoundaries>({ AAA: 990000, "AA+": 950000, AA: 900000, diff --git a/typescript/common/src/index.test.ts b/typescript/common/src/index.test.ts index 7844083c3..2ecbf33cb 100644 --- a/typescript/common/src/index.test.ts +++ b/typescript/common/src/index.test.ts @@ -6,7 +6,7 @@ import * as tachiCommon from "./index"; describe("Property Checks", () => { it("exports core APIs and constants", () => { expect(typeof tachiCommon.GetGameGroupConfig).toBe("function"); - expect(typeof tachiCommon.GetGamePTConfig).toBe("function"); + expect(typeof tachiCommon.LEGACY_GetGamePTConfig).toBe("function"); expect(typeof tachiCommon.COLOUR_SET).toBe("object"); }); }); diff --git a/typescript/common/src/index.ts b/typescript/common/src/index.ts index 5c0bdfffc..de79fa2e7 100644 --- a/typescript/common/src/index.ts +++ b/typescript/common/src/index.ts @@ -7,6 +7,7 @@ export * from "./constants/game"; export * from "./constants/grade-boundaries"; export * as StaticConfig from "./constants/import-types"; export * from "./constants/permissions"; +export * from "./lib/folder-slug"; export * as Schemas from "./lib/schemas"; export * from "./lib/zod-schemas"; export * from "./seeds/types"; diff --git a/typescript/common/src/lib/folder-slug.ts b/typescript/common/src/lib/folder-slug.ts new file mode 100644 index 000000000..93e5b6c8f --- /dev/null +++ b/typescript/common/src/lib/folder-slug.ts @@ -0,0 +1,475 @@ +/** + * Computes URL-safe folder slugs for `db/seeds/folders.json` rows. + * Slugs are lowercase ASCII [a-z0-9-] only. + */ + +import { BMS_TABLES } from "../constants/bms-tables"; + +export type SeedFolderRow = { + game: string; + id: string; + slug?: string; + title: string; + versionFilter?: Array; + where: string; +}; + +const CHART_LEVEL_EQ_RE = /^chart\.level = '([^']+)'$/u; + +const LEVEL_NUM_GE_LT_RE = /chart\.level_num >= ([0-9.]+) AND chart\.level_num < ([0-9.]+)/u; + +const LEVEL_NUM_GE_LE_RE = /chart\.level_num >= ([0-9.]+) AND chart\.level_num <= ([0-9.]+)/u; + +const CHART_LEVEL_IN_RE = /chart\.level IN \(([^)]+)\)/u; + +/** IIDX: maps `versionFilter[0]` → slug segment (see add-iidx-folder-slugs history). */ +export const IIDX_VERSION_SLUG: Record = { + 1: "1st", + substream: "substream", + 2: "2nd", + 3: "3rd", + 4: "4th", + 5: "5th", + 6: "6th", + 7: "7th", + 8: "8th", + 9: "9th", + 10: "10th", + + 11: "red", + 12: "happysky", + 13: "distorted", + 14: "gold", + 15: "djtroopers", + 16: "empress", + 17: "sirius", + 18: "resortanthem", + 19: "lincle", + + "3-cs": "3rd-cs", + "4-cs": "4th-cs", + "5-cs": "5th-cs", + "6-cs": "6th-cs", + "7-cs": "7th-cs", + "8-cs": "8th-cs", + "9-cs": "9th-cs", + "10-cs": "10th-cs", + "11-cs": "11th-cs", + "12-cs": "12th-cs", + "13-cs": "13th-cs", + "14-cs": "14th-cs", + "15-cs": "15th-cs", + "16-cs": "16th-cs", + + 20: "tricoro", + 21: "spada", + 22: "pendual", + 23: "copula", + 24: "sinobuz", + 25: "cannonballers", + 26: "rootage", + "26-omni": "rootage-omni", + 27: "heroicverse", + "27-2dxtra": "heroicverse-2dxtra", + "27-omni": "heroicverse-omni", + 28: "bistrover", + "28-omni": "bistrover-omni", + 29: "casthour", + "29-omni": "casthour-omni", + 30: "resident", + "30-omni": "resident-omni", + 31: "epolis", + "31-omni": "epolis-omni", + 32: "pinkycrush", + "32-omni": "pinkycrush-omni", + 33: "sparkleshower", + + bmus: "bmus", + inf: "inf", +}; + +const ASCII_SLUG_RE = /^[a-z0-9-]+$/u; + +export function assertAsciiSlug(slug: string, ctx: string): void { + if (!ASCII_SLUG_RE.test(slug)) { + throw new Error(`${ctx}: slug must match [a-z0-9-], got ${JSON.stringify(slug)}`); + } +} + +/** Collapses separators to single `-`; strips non-alphanumeric. */ +export function asciiSlugSegment(input: string): string { + const stripped = input + .normalize("NFKD") + .replace(/[\u0300-\u036f]/gu, "") + .replace(/[^a-zA-Z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .replace(/-+/gu, "-") + .toLowerCase(); + + return stripped.length > 0 ? stripped : "x"; +} + +export function slugLevelPart(level: string): string { + return level.replaceAll("+", "p").replaceAll(".", "p"); +} + +function expectSingleVersionFilter(folder: SeedFolderRow, label: string): string { + const vf = folder.versionFilter; + + if (!vf || vf.length !== 1) { + throw new Error( + `${label} folder "${folder.title}" (${folder.id}): expected exactly one versionFilter entry, got ${JSON.stringify(vf)}`, + ); + } + + return vf[0]; +} + +function slugLevelFromWhere(where: string, label: string, id: string, title: string): string { + const eq = CHART_LEVEL_EQ_RE.exec(where); + + if (eq !== null) { + return slugLevelPart(eq[1]); + } + + const lt = LEVEL_NUM_GE_LT_RE.exec(where); + + if (lt !== null) { + return `ge-${slugLevelPart(lt[1])}-lt-${slugLevelPart(lt[2])}`; + } + + const le = LEVEL_NUM_GE_LE_RE.exec(where); + + if (le !== null) { + return `ge-${slugLevelPart(le[1])}-le-${slugLevelPart(le[2])}`; + } + + const mIn = CHART_LEVEL_IN_RE.exec(where); + + if (mIn !== null) { + const vals = mIn[1].split(",").map((x) => x.trim().replace(/^'/u, "").replace(/'$/u, "")); + + return vals.map((v) => slugLevelPart(v)).join("-"); + } + + throw new Error( + `${label} folder "${title}" (${id}): unhandled level predicate ${JSON.stringify(where)}`, + ); +} + +/** `{level}-{version}` with slugified version token. */ +function slugLevelPlusVersion(folder: SeedFolderRow, label: string): string { + const rawVf = expectSingleVersionFilter(folder, label); + const levelSeg = slugLevelFromWhere(folder.where, label, folder.id, folder.title); + + return `${levelSeg}-${asciiSlugSegment(rawVf)}`; +} + +function slugIidx(folder: SeedFolderRow): string { + const rawKey = expectSingleVersionFilter(folder, "IIDX"); + const versionSlug = IIDX_VERSION_SLUG[rawKey]; + + if (versionSlug === undefined) { + throw new Error( + `IIDX folder "${folder.title}" (${folder.id}): unmapped versionFilter ${JSON.stringify(rawKey)}`, + ); + } + + const match = CHART_LEVEL_EQ_RE.exec(folder.where); + + if (match === null) { + throw new Error( + `IIDX folder "${folder.title}" (${folder.id}): expected chart.level = '…', got ${JSON.stringify(folder.where)}`, + ); + } + + return `${slugLevelPart(match[1])}-${versionSlug}`; +} + +function slugChunithm(folder: SeedFolderRow): string { + const rawVf = expectSingleVersionFilter(folder, "chunithm"); + const vfPart = asciiSlugSegment(rawVf); + const w = folder.where; + + const mLv = CHART_LEVEL_EQ_RE.exec(w); + + if (mLv !== null) { + return `${slugLevelPart(mLv[1])}-${vfPart}`; + } + + const mDiff = /chart\.difficulty = '([^']+)'/u.exec(w); + + if (mDiff !== null) { + return `${asciiSlugSegment(mDiff[1])}-${vfPart}`; + } + + const mIn = /chart\.difficulty IN \(([^)]+)\)/u.exec(w); + + if (mIn !== null) { + const parts = mIn[1].split(",").map((x) => x.trim().replace(/^'/u, "").replace(/'$/u, "")); + + return `${asciiSlugSegment(parts.join("-"))}-${vfPart}`; + } + + throw new Error( + `chunithm folder "${folder.title}" (${folder.id}): unhandled where ${JSON.stringify(w)}`, + ); +} + +function slugOngeki(folder: SeedFolderRow): string { + const w = folder.where; + + if (w.includes("song.data->>'genre'") && w.includes("'LUNATIC'")) { + return "lunatic-only-songs"; + } + + if ( + w === + "(chart.data->>'inGameID')::numeric >= 7000 AND (chart.data->>'inGameID')::numeric < 8000" + ) { + return "bonus-tracks"; + } + + const rawVf = expectSingleVersionFilter(folder, "ongeki"); + const vfPart = asciiSlugSegment(rawVf); + const isOld = folder.title.includes("(Old)"); + + const mLv = /chart\.level = '([^']+)'/u.exec(w); + + if (mLv !== null) { + let s = `${slugLevelPart(mLv[1])}-${vfPart}`; + + if (isOld) { + s = `${s}-old`; + } + + return s; + } + + let remaster = ""; + + if (/isReMaster' = 'true'/u.test(w)) { + remaster = "-remaster"; + } + + const mD = /chart\.difficulty = '([^']+)'/u.exec(w); + + if (mD !== null) { + return `${vfPart}-${asciiSlugSegment(mD[1])}${remaster}`; + } + + const mIn = /chart\.difficulty IN \(([^)]+)\)/u.exec(w); + + if (mIn !== null) { + const parts = mIn[1].split(",").map((x) => x.trim().replace(/^'/u, "").replace(/'$/u, "")); + + return `${vfPart}-${asciiSlugSegment(parts.join("-"))}${remaster}`; + } + + throw new Error( + `ongeki folder "${folder.title}" (${folder.id}): unhandled where ${JSON.stringify(w)}`, + ); +} + +function slugJubeat(folder: SeedFolderRow): string { + const rawVf = expectSingleVersionFilter(folder, "jubeat"); + const vfPart = asciiSlugSegment(rawVf); + const w = folder.where; + const hard = w.includes("HARD BSC"); + const mode = hard ? "hard" : "normal"; + + const mLv = /chart\.level = '([^']+)'/u.exec(w); + + if (mLv !== null) { + return `${slugLevelPart(mLv[1])}-${mode}-${vfPart}`; + } + + const mRg = /chart\.level_num >= ([0-9.]+) AND chart\.level_num < ([0-9.]+)/u.exec(w); + + if (mRg !== null) { + return `ge-${slugLevelPart(mRg[1])}-lt-${slugLevelPart(mRg[2])}-${mode}-${vfPart}`; + } + + throw new Error( + `jubeat folder "${folder.title}" (${folder.id}): unhandled where ${JSON.stringify(w)}`, + ); +} + +function slugGitadora(folder: SeedFolderRow): string { + const rawVf = expectSingleVersionFilter(folder, "gitadora"); + const vfPart = asciiSlugSegment(rawVf); + const m = /chart\.level_num >= ([0-9.]+) AND chart\.level_num < ([0-9.]+)/u.exec(folder.where); + + if (m === null) { + throw new Error( + `gitadora folder "${folder.title}" (${folder.id}): expected level_num range, got ${JSON.stringify(folder.where)}`, + ); + } + + const a = m[1].replaceAll(".", "p"); + const b = m[2].replaceAll(".", "p"); + + return `${vfPart}-ge-${a}-lt-${b}`; +} + +/** Non-official USC folders use the seed title as the slug (e.g. `os8`). */ +function slugUscUnofficialTitle(title: string): string { + // Match level-style `+` handling so `us17` vs `us17+` do not collide; `*` → `s` for titles like `us0*`. + return asciiSlugSegment(title.replaceAll("+", "p").replaceAll("*", "s")); +} + +function slugUsc(folder: SeedFolderRow): string { + const w = folder.where; + + const mOfficial = /chart\.data->>'isOfficial' = 'true' AND chart\.level = '([^']+)'/u.exec(w); + + if (mOfficial !== null) { + return `official-${slugLevelPart(mOfficial[1])}`; + } + + return slugUscUnofficialTitle(folder.title); +} + +function slugItgStamina(folder: SeedFolderRow): string { + return asciiSlugSegment(folder.title); +} + +/** + * Maps a `tableFolders` JSON key to a slug segment using {@link BMS_TABLES} `asciiPrefix` + * (ASCII-friendly identifier for the table `prefix`). + */ +function encodeBmsTableKey(game: string, key: string): string { + const row = BMS_TABLES.find((e) => e.game === game && e.prefix === key); + + if (row !== undefined) { + return asciiSlugSegment(row.asciiPrefix); + } + + // USC and other non-BMS seeds: `os`, `us`, etc. + if (/^[a-zA-Z0-9_]+$/u.test(key)) { + return key.toLowerCase(); + } + + throw new Error( + `No BMS_TABLES row for game=${JSON.stringify(game)} prefix=${JSON.stringify(key)} — add a BMSTableInfo entry or use an ascii-only prefix.`, + ); +} + +function encodeBmsTableValue(value: string): string { + const parts: Array = []; + + for (const ch of value) { + if (/[a-z0-9]/iu.test(ch)) { + parts.push(ch.toLowerCase()); + } else if (ch === "+") { + parts.push("p"); + } else if (ch === "?") { + parts.push("q"); + } else if (ch === "-") { + parts.push("dash"); + } else if (ch === "!") { + parts.push("excl"); + } else { + parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`); + } + } + + return parts.join("-"); +} + +function slugBmsLike(folder: SeedFolderRow, label: string): string { + const w = folder.where; + + const mKv = /tableFolders'->>'((?:[^']|'')+)'\) = '((?:[^']|'')*)'/u.exec(w); + + if (mKv !== null) { + const key = mKv[1].replaceAll("''", "'"); + const val = mKv[2].replaceAll("''", "'"); + + return `tf-${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`; + } + + const mPr = /tableFolders'\) \? '((?:[^']|'')+)'/u.exec(w); + + if (mPr !== null) { + const key = mPr[1].replaceAll("''", "'"); + + return `tf-${encodeBmsTableKey(folder.game, key)}-present`; + } + + const mRg = + /chart\.data->>'([a-zA-Z0-9_]+)'\)::numeric >= ([0-9.]+) AND \(chart\.data->>'\1'\)::numeric < ([0-9.]+)/u.exec( + w, + ); + + if (mRg !== null) { + return `${mRg[1].toLowerCase()}-ge-${mRg[2]}-lt-${mRg[3]}`; + } + + throw new Error( + `${label} folder "${folder.title}" (${folder.id}): unhandled where ${JSON.stringify(w)}`, + ); +} + +export function computeFolderSlug(folder: SeedFolderRow): string { + let slug: string; + + switch (folder.game) { + case "iidx-dp": + case "iidx-sp": + slug = slugIidx(folder); + break; + + case "arcaea": + case "ddr-dp": + case "ddr-sp": + case "maimai": + case "maimaidx": + case "museca": + case "popn": + case "sdvx": + case "wacca": + slug = slugLevelPlusVersion(folder, folder.game); + break; + + case "chunithm": + slug = slugChunithm(folder); + break; + + case "ongeki": + slug = slugOngeki(folder); + break; + + case "jubeat": + slug = slugJubeat(folder); + break; + + case "gitadora-dora": + case "gitadora-gita": + slug = slugGitadora(folder); + break; + + case "usc-controller": + case "usc-keyboard": + slug = slugUsc(folder); + break; + + case "itg-stamina": + slug = slugItgStamina(folder); + break; + + case "bms-14k": + case "bms-7k": + case "pms-controller": + case "pms-keyboard": + slug = slugBmsLike(folder, folder.game); + break; + + default: + throw new Error(`computeFolderSlug: unsupported game ${JSON.stringify(folder.game)}`); + } + + assertAsciiSlug(slug, `folder "${folder.title}" (${folder.id})`); + + return slug; +} diff --git a/typescript/common/src/lib/schemas.ts b/typescript/common/src/lib/schemas.ts index 5c27b7d12..1e4039420 100644 --- a/typescript/common/src/lib/schemas.ts +++ b/typescript/common/src/lib/schemas.ts @@ -8,21 +8,21 @@ import { type ValidationFunctionParentOptionsKeychain, type ValidSchemaValue, } from "prudence"; -import { z, type ZodType } from "zod"; -import type { GameGroup, Playtype, Playtypes, V3Game } from "../types/game-config"; -import type { INTERNAL_GAME_PT_CONFIG } from "../types/internals"; +import type { GameGroup, LEGACY_Playtypes, V3Game } from "../types/game-config"; import type { ConfScoreMetric } from "../types/metrics"; import type { NotificationBody } from "../types/notifications"; import { allGPTStrings, allSupportedGameGroups, + GameToGameGroup, + GetGameConfig, GetGameGroupConfig, - GetGamePTConfig, - GetGPTString, GetScoreMetrics, - v3AllGames, + LEGACY_GameGroupPTToGame, + LEGACY_GameToPlaytypeFn, + LEGACY_GetGamePTConfig, } from "../config/config"; import { allImportTypes } from "../constants/import-types"; import { ALL_PERMISSIONS } from "../constants/permissions"; @@ -50,17 +50,15 @@ function prSchemaFnWrap(schema: PrudenceSchema) { }; } -const PR_GAME_STATS = ( - game: GameGroup, - playtype: Playtypes[GameGroup], - gptConfig: INTERNAL_GAME_PT_CONFIG, -) => ({ +const PR_GAME_STATS = (game: V3Game) => ({ userID: p.isPositiveNonZeroInteger, - game: p.is(game), - playtype: p.is(playtype), - ratings: Object.fromEntries(Object.keys(gptConfig.scoreRatingAlgs).map((s) => [s, "*number"])), + game: p.is(GameToGameGroup(game)), + playtype: p.is(LEGACY_GameToPlaytypeFn(game)), + ratings: Object.fromEntries( + Object.keys(GetGameConfig(game).scoreRatingAlgs).map((s) => [s, "*number"]), + ), classes: Object.fromEntries( - Object.keys(gptConfig.classes).map((e) => [e, p.optional(p.isInteger)]), + Object.keys(GetGameConfig(game).classes).map((e) => [e, p.optional(p.isInteger)]), ), }); @@ -86,7 +84,7 @@ const extractGPTIDString = (self: unknown) => { // if there's no ":" in the string, this returns only one element. const [game, playtype] = s.gptString.split(":") as [string, string | undefined]; - if (!IsValidGame(game)) { + if (!IsValidGameGroup(game)) { throw new Error(`Expected valid game -- got ${game} from gptString ${s.gptString}.`); } @@ -100,7 +98,7 @@ const extractGPTIDString = (self: unknown) => { return { game, playtype }; }; -const extractGPT = (self: unknown) => { +const extractGame = (self: unknown) => { if (typeof self !== "object" || !self) { throw new Error("Expected an object."); } @@ -115,7 +113,7 @@ const extractGPT = (self: unknown) => { throw new Error(`Expected a string where self.playtype is. Got ${s.playtype}`); } - if (!IsValidGame(s.game)) { + if (!IsValidGameGroup(s.game)) { throw new Error(`Expected valid game -- got ${s.game}.`); } @@ -123,18 +121,22 @@ const extractGPT = (self: unknown) => { throw new Error(`Expected valid playtype -- got ${s.playtype}.`); } - return { game: s.game, playtype: s.playtype }; + return { + v3Game: LEGACY_GameGroupPTToGame(s.game, s.playtype), + gameGroup: s.game, + playtype: s.playtype, + }; }; -function IsValidPlaytype(game: GameGroup, str: string): str is Playtypes[GameGroup] { - return GetGameGroupConfig(game).playtypes.includes(str as Playtypes[GameGroup]); +function IsValidPlaytype(game: GameGroup, str: string): str is LEGACY_Playtypes[GameGroup] { + return GetGameGroupConfig(game).playtypes.includes(str as LEGACY_Playtypes[GameGroup]); } -function IsValidGame(str: string): str is GameGroup { +function IsValidGameGroup(str: string): str is GameGroup { return allSupportedGameGroups.includes(str as GameGroup); } -const getPlaytype = (game: GameGroup, self: unknown): Playtypes[GameGroup] => { +const getPlaytype = (game: GameGroup, self: unknown): LEGACY_Playtypes[GameGroup] => { if (self === null || typeof self !== "object") { throw new Error("Expected an object."); } @@ -155,7 +157,7 @@ const getPlaytype = (game: GameGroup, self: unknown): Playtypes[GameGroup] => { const games = allSupportedGameGroups; const isValidPlaytype = (self: unknown, parent: Record) => { - if (typeof parent.game !== "string" || !IsValidGame(parent.game)) { + if (typeof parent.game !== "string" || !IsValidGameGroup(parent.game)) { throw new Error(`Invalid Schema, need game to base IsValidPlaytype off of.`); } @@ -171,7 +173,7 @@ const isValidPlaytype = (self: unknown, parent: Record) => { }; export const PR_GOAL_SCHEMA = (self: unknown) => { - const { game, playtype } = extractGPT(self); + const { gameGroup: game, playtype } = extractGame(self); return prSchemaFnWrap({ game: p.isIn(games), @@ -182,8 +184,8 @@ export const PR_GOAL_SCHEMA = (self: unknown) => { { mode: p.is("single"), key: (self) => { - const gptConfig = GetGamePTConfig(game, playtype); - const metrics = GetScoreMetrics(gptConfig); + const gameConfig = LEGACY_GetGamePTConfig(game, playtype); + const metrics = GetScoreMetrics(gameConfig); return p.isIn(metrics)(self); }, @@ -193,8 +195,8 @@ export const PR_GOAL_SCHEMA = (self: unknown) => { mode: p.isIn("absolute", "proportion"), countNum: p.isPositive, key: (self) => { - const gptConfig = GetGamePTConfig(game, playtype); - const metrics = GetScoreMetrics(gptConfig); + const gameConfig = LEGACY_GetGamePTConfig(game, playtype); + const metrics = GetScoreMetrics(gameConfig); return p.isIn(metrics)(self); }, @@ -234,7 +236,7 @@ const PR_SONG_DOCUMENT = (game: GameGroup): PrudenceSchema => { const PR_CHART_DOCUMENT = (game: GameGroup) => (self: unknown) => { const playtype = getPlaytype(game, self); - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = LEGACY_GetGamePTConfig(game, playtype); return prSchemaFnWrap({ songID: p.isPositiveInteger, @@ -248,18 +250,18 @@ const PR_CHART_DOCUMENT = (game: GameGroup) => (self: unknown) => { playtype: p.is(playtype), difficulty: - gptConfig.difficulties.type === "DYNAMIC" + gameConfig.difficulties.type === "DYNAMIC" ? "string" - : p.isIn(gptConfig.difficulties.order), + : p.isIn(gameConfig.difficulties.order), - data: PrudenceZodShim(gptConfig.chartData), + data: PrudenceZodShim(gameConfig.chartData), versions: (self) => { if (!Array.isArray(self)) { return "Expected an array"; } - const versions = Object.keys(gptConfig.versions); + const versions = Object.keys(gameConfig.versions); if (self.find((k) => !versions.includes(k))) { return "Array contained invalid versions"; @@ -330,14 +332,14 @@ const PRE_SCHEMAS = { }, set: (self, pa) => { const parent = pa as any; - const config = GetGamePTConfig("bms", parent.playtype); + const config = GetGameConfig(parent.game); return p.isIn(Object.keys(config.classes))(self); }, - playtype: p.isIn("7K", "14K"), + game: p.isIn("bms-7k", "bms-14k"), value: (self, pa) => { const parent = pa as any; - const config = GetGamePTConfig("bms", parent.playtype); + const config = GetGameConfig(parent.game); return p.isIn(config.classes[parent.set]?.values.map((e) => e.id) ?? [])(self); }, @@ -429,9 +431,9 @@ const PRE_SCHEMAS = { }), goals: PR_GOAL_SCHEMA, scores: (self: unknown) => { - const { game, playtype } = extractGPT(self); + const { gameGroup: game, playtype } = extractGame(self); - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = LEGACY_GetGamePTConfig(game, playtype); return prSchemaFnWrap({ service: "string", @@ -448,27 +450,27 @@ const PRE_SCHEMAS = { importType: p.nullable(p.isIn(allImportTypes)), timeAchieved: p.nullable(p.isPositive), scoreData: { - ...PR_METRICS(gptConfig.providedMetrics), - ...PR_METRICS(gptConfig.derivedMetrics), + ...PR_METRICS(gameConfig.providedMetrics), + ...PR_METRICS(gameConfig.derivedMetrics), judgements: Object.fromEntries( - gptConfig.orderedJudgements.map((j) => [j, optNull(p.isInteger)]), + gameConfig.orderedJudgements.map((j) => [j, optNull(p.isInteger)]), ), - optional: PR_METRICS(gptConfig.optionalMetrics, true), + optional: PR_METRICS(gameConfig.optionalMetrics, true), }, - scoreMeta: PrudenceZodShim(gptConfig.scoreMeta), + scoreMeta: PrudenceZodShim(gameConfig.scoreMeta), calculatedData: Object.fromEntries( - Object.keys(gptConfig.scoreRatingAlgs).map((a) => [a, "*?number"]), + Object.keys(gameConfig.scoreRatingAlgs).map((a) => [a, "*?number"]), ), })(self); }, "personal-bests": (self: unknown) => { - const { game, playtype } = extractGPT(self); + const { gameGroup: game, playtype } = extractGame(self); - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = LEGACY_GetGamePTConfig(game, playtype); return prSchemaFnWrap({ composedFrom: [{ name: "string", scoreID: "string" }], @@ -485,18 +487,18 @@ const PRE_SCHEMAS = { isPrimary: "boolean", timeAchieved: p.nullable(p.isPositive), scoreData: { - ...PR_METRICS(gptConfig.providedMetrics), - ...PR_METRICS(gptConfig.derivedMetrics), + ...PR_METRICS(gameConfig.providedMetrics), + ...PR_METRICS(gameConfig.derivedMetrics), // TODO ENUMINDEXES judgements: Object.fromEntries( - gptConfig.orderedJudgements.map((j) => [j, optNull(p.isInteger)]), + gameConfig.orderedJudgements.map((j) => [j, optNull(p.isInteger)]), ), - optional: PR_METRICS(gptConfig.optionalMetrics, true), + optional: PR_METRICS(gameConfig.optionalMetrics, true), }, calculatedData: Object.fromEntries( - Object.keys(gptConfig.scoreRatingAlgs).map((a) => [a, "*?number"]), + Object.keys(gameConfig.scoreRatingAlgs).map((a) => [a, "*?number"]), ), })(self); }, @@ -512,9 +514,9 @@ const PRE_SCHEMAS = { }, }), sessions: (self: unknown) => { - const { game, playtype } = extractGPT(self); + const { gameGroup: game, playtype } = extractGame(self); - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = LEGACY_GetGamePTConfig(game, playtype); return prSchemaFnWrap({ userID: p.isPositiveNonZeroInteger, @@ -528,7 +530,7 @@ const PRE_SCHEMAS = { timeStarted: p.isPositiveInteger, highlight: "boolean", calculatedData: Object.fromEntries( - Object.keys(gptConfig.sessionRatingAlgs).map((k) => [k, "*?number"]), + Object.keys(gameConfig.sessionRatingAlgs).map((k) => [k, "*?number"]), ), scoreIDs: ["string"], })(self); @@ -592,21 +594,17 @@ const PRE_SCHEMAS = { score: p.any, }), "game-stats": (self: unknown) => { - const { game, playtype } = extractGPT(self); + const { v3Game } = extractGame(self); - const gptConfig = GetGamePTConfig(game, playtype); - - return prSchemaFnWrap(PR_GAME_STATS(game, playtype, gptConfig))(self); + return prSchemaFnWrap(PR_GAME_STATS(v3Game))(self); }, "game-stats-snapshots": (self: unknown) => { - const { game, playtype } = extractGPT(self); - - const gptConfig = GetGamePTConfig(game, playtype); + const { v3Game } = extractGame(self); return prSchemaFnWrap( - Object.assign(PR_GAME_STATS(game, playtype, gptConfig), { + Object.assign(PR_GAME_STATS(v3Game), { rankings: Object.fromEntries( - Object.keys(gptConfig.profileRatingAlgs).map((e) => [ + Object.keys(GetGameConfig(v3Game).profileRatingAlgs).map((e) => [ e, { outOf: p.isPositiveNonZeroInteger, ranking: p.isPositiveNonZeroInteger }, ]), @@ -617,19 +615,23 @@ const PRE_SCHEMAS = { )(self); }, "game-settings": (s: unknown) => { - const { game, playtype } = extractGPT(s); - - const gptConfig = GetGamePTConfig(game, playtype); + const { v3Game } = extractGame(s); return prSchemaFnWrap({ userID: p.isPositiveNonZeroInteger, - game: p.is(game), - playtype: p.is(playtype), + game: p.is(GameToGameGroup(v3Game)), + playtype: p.is(LEGACY_GameToPlaytypeFn(v3Game)), rivals: [p.isPositiveNonZeroInteger], preferences: { - preferredScoreAlg: p.nullable(p.isIn(Object.keys(gptConfig.scoreRatingAlgs))), - preferredSessionAlg: p.nullable(p.isIn(Object.keys(gptConfig.sessionRatingAlgs))), - preferredProfileAlg: p.nullable(p.isIn(Object.keys(gptConfig.profileRatingAlgs))), + preferredScoreAlg: p.nullable( + p.isIn(Object.keys(GetGameConfig(v3Game).scoreRatingAlgs)), + ), + preferredSessionAlg: p.nullable( + p.isIn(Object.keys(GetGameConfig(v3Game).sessionRatingAlgs)), + ), + preferredProfileAlg: p.nullable( + p.isIn(Object.keys(GetGameConfig(v3Game).profileRatingAlgs)), + ), // ouch stats: p.and( @@ -654,7 +656,7 @@ const PRE_SCHEMAS = { defaultTable: p.nullable("string"), preferredRanking: p.isIn("global", "rival", null), - gameSpecific: PrudenceZodShim(gptConfig.preferences), + gameSpecific: PrudenceZodShim(GetGameConfig(v3Game).preferences), }, })(s); }, @@ -666,12 +668,12 @@ const PRE_SCHEMAS = { game: p.isIn(games), playtype: isValidPlaytype, classSet: (self, parent) => { - const gptConfig = GetGamePTConfig( + const gameConfig = LEGACY_GetGamePTConfig( parent.game as GameGroup, - parent.playtype as Playtypes[GameGroup], + parent.playtype as LEGACY_Playtypes[GameGroup], ); - const keys = Object.keys(gptConfig.classes); + const keys = Object.keys(gameConfig.classes); if (typeof self !== "string") { return "Expected a string."; @@ -864,11 +866,11 @@ const PRE_SCHEMAS = { export const SCHEMAS: Record = PRE_SCHEMAS; -const PR_BATCH_MANUAL_SCORE = (game: GameGroup, playtype: Playtype): PrudenceSchema => { - const gptConfig = GetGamePTConfig(game, playtype); +const PR_BATCH_MANUAL_SCORE = (game: V3Game): PrudenceSchema => { + const gameConfig = GetGameConfig(game); return { - ...PR_METRICS(gptConfig.providedMetrics), + ...PR_METRICS(gameConfig.providedMetrics), matchType: p.isIn( "songTitle", @@ -902,8 +904,8 @@ const PR_BATCH_MANUAL_SCORE = (game: GameGroup, playtype: Playtype): PrudenceSch } for (const [key, v] of Object.entries(self)) { - if (!gptConfig.orderedJudgements.includes(key)) { - return `Invalid Key ${key}. Expected any of ${gptConfig.orderedJudgements.join( + if (!gameConfig.orderedJudgements.includes(key)) { + return `Invalid Key ${key}. Expected any of ${gameConfig.orderedJudgements.join( ", ", )}`; } @@ -915,9 +917,9 @@ const PR_BATCH_MANUAL_SCORE = (game: GameGroup, playtype: Playtype): PrudenceSch return true; }), - optional: optNull(PR_METRICS(gptConfig.optionalMetrics, true)), - hitMeta: optNull(PR_METRICS(gptConfig.optionalMetrics, true)), - scoreMeta: optNull(PrudenceZodShim(gptConfig.scoreMeta)), + optional: optNull(PR_METRICS(gameConfig.optionalMetrics, true)), + hitMeta: optNull(PR_METRICS(gameConfig.optionalMetrics, true)), + scoreMeta: optNull(PrudenceZodShim(gameConfig.scoreMeta)), }; }; @@ -982,8 +984,8 @@ function PR_METRICS(metrics: Record, shouldAllBeOptNull return schema; } -const PR_BATCH_MANUAL_CLASSES = (game: GameGroup, playtype: Playtype): PrudenceSchema => { - const config = GetGamePTConfig(game, playtype); +const PR_BATCH_MANUAL_CLASSES = (game: V3Game): PrudenceSchema => { + const config = GetGameConfig(game); const schema: PrudenceSchema = {}; @@ -998,15 +1000,15 @@ const PR_BATCH_MANUAL_CLASSES = (game: GameGroup, playtype: Playtype): PrudenceS return schema; }; -export const PR_BATCH_MANUAL = (game: GameGroup, playtype: Playtype): PrudenceSchema => ({ +export const PR_BATCH_MANUAL = (game: V3Game): PrudenceSchema => ({ meta: { service: p.isBoundedString(3, 60), game: p.isIn(allSupportedGameGroups), - playtype: p.is(playtype), + playtype: p.is(LEGACY_GameToPlaytypeFn(game)), version: "*?string", }, - scores: [PR_BATCH_MANUAL_SCORE(game, playtype)], - classes: optNull(PR_BATCH_MANUAL_CLASSES(game, playtype)), + scores: [PR_BATCH_MANUAL_SCORE(game)], + classes: optNull(PR_BATCH_MANUAL_CLASSES(game)), }); export const PR_RESOLVER: PrudenceSchema = { diff --git a/typescript/common/src/seeds/types.ts b/typescript/common/src/seeds/types.ts index 3bf0166d9..71aab44f3 100644 --- a/typescript/common/src/seeds/types.ts +++ b/typescript/common/src/seeds/types.ts @@ -1,14 +1,14 @@ import type { + BMSCourseDocument, + ChartDocument, + FolderDocument, GameGroup, - GPTStrings, - MONGO_BMSCourseDocument, - MONGO_ChartDocument, - MONGO_FolderDocument, - MONGO_GoalDocument, - MONGO_QuestDocument, - MONGO_QuestlineDocument, - MONGO_SongDocument, - MONGO_TableDocument, + GoalDocument, + QuestDocument, + QuestlineDocument, + SongDocument, + TableDocument, + V3Game, } from "../types"; import { allSupportedGameGroups } from "../config/config"; @@ -17,20 +17,20 @@ import { allSupportedGameGroups } from "../config/config"; // note that TS won't let you do this multiple times within an object // so, we have to join them ourselves. Ah well, not that bad. type ChartDBSeeds = { - [G in GameGroup as `charts-${G}.json`]: Array>; + [TGame in V3Game as `charts-${TGame}.json`]: Array>; }; type SongDBSeeds = { - [G in GameGroup as `songs-${G}.json`]: Array>; + [G in GameGroup as `songs-${G}.json`]: Array>; }; interface OtherDBSeeds { - "bms-course-lookup.json": Array; - "folders.json": Array; - "goals.json": Array; - "questlines.json": Array; - "quests.json": Array; - "tables.json": Array; + "bms-course-lookup.json": Array; + "folders.json": Array; + "goals.json": Array; + "questlines.json": Array; + "quests.json": Array; + "tables.json": Array; } export type AllDatabaseSeeds = ChartDBSeeds & OtherDBSeeds & SongDBSeeds; diff --git a/typescript/common/src/types.ts b/typescript/common/src/types.ts index be6a2868e..af57a17ad 100644 --- a/typescript/common/src/types.ts +++ b/typescript/common/src/types.ts @@ -18,7 +18,7 @@ export enum UserAuthLevels { * The config the server expects, and emits on /api/v1/config. */ export interface TachiServerCoreConfig { - GAMES: Array; + GAME_GROUPS: Array; IMPORT_TYPES: Array; NAME: string; TYPE: "boku" | "kamai" | "omni"; diff --git a/typescript/common/src/types/batch-manual.ts b/typescript/common/src/types/batch-manual.ts index 5c0b531a4..b8d17f32b 100644 --- a/typescript/common/src/types/batch-manual.ts +++ b/typescript/common/src/types/batch-manual.ts @@ -1,15 +1,11 @@ -import type { integer } from "../types"; +import type { GameGroupFromGame, integer, LEGACY_Playtypes, V3Game } from "../types"; import type { ConfOptionalMetrics, ConfProvidedMetrics, Difficulties, ExtractedClasses, GameGroup, - GPTString, - GPTStringToGame, - GPTStringToPlaytype, Judgements, - Playtype, ScoreMeta, Versions, } from "./game-config"; @@ -31,9 +27,8 @@ type MatchTypesWithDifficulty = export type MatchTypes = MatchTypesNoDifficulty | MatchTypesWithDifficulty; interface MatchTypeBase { - game: GameGroup; - playtype: Playtype; - version: Versions[GPTString] | null; + game: V3Game; + version: Versions[V3Game] | null; identifier: string; artist?: string | null; } @@ -49,22 +44,22 @@ export type MatchTypeResolverNoDifficulty = { export type MatchTypeResolver = MatchTypeResolverNoDifficulty | MatchTypeResolverWithDifficulty; -export type BatchManualScore = { +export type BatchManualScore = { artist?: string | null; comment?: string | null; /** * @deprecated Use `optional` instead. */ - hitMeta?: AllFieldsNullableOptional>; + hitMeta?: AllFieldsNullableOptional>; identifier: string; - judgements?: Record; - optional?: AllFieldsNullableOptional>; + judgements?: Record; + optional?: AllFieldsNullableOptional>; - scoreMeta?: Partial; + scoreMeta?: Partial; timeAchieved?: number | null; } & ( | { - difficulty: Difficulties[GPT]; + difficulty: Difficulties[TGame]; matchType: MatchTypesWithDifficulty; } | { @@ -72,15 +67,15 @@ export type BatchManualScore = { matchType: MatchTypesNoDifficulty; } ) & - MongoExtractMetrics; + MongoExtractMetrics; -export interface BatchManual { +export interface BatchManual { meta: { - game: GPTStringToGame[GPT]; - playtype: GPTStringToPlaytype[GPT]; + game: GameGroupFromGame[TGame]; + playtype: LEGACY_Playtypes[GameGroup]; service: string; - version?: Versions[GPT]; + version?: Versions[TGame]; }; - scores: Array>; - classes?: ExtractedClasses[GPT] | null; + scores: Array>; + classes?: ExtractedClasses[TGame] | null; } diff --git a/typescript/common/src/types/documents.ts b/typescript/common/src/types/documents.ts index 81b1af063..6aed229ef 100644 --- a/typescript/common/src/types/documents.ts +++ b/typescript/common/src/types/documents.ts @@ -2,26 +2,22 @@ import type { FilterQuery } from "mongodb"; import type { AnyClasses, + ChartDocumentData, Classes, Difficulties, ExtractedClasses, GameGroup, - GPTString, - GPTStrings, - GPTStringToGame, - GPTStringToPlaytype, + GameGroupFromGame, integer, Judgements, - MONGO_ChartDocumentData, - MongoDerivedMetrics as MongoDerivedMetrics, - MongoOptionalMetrics as MongoOptionalMetrics, - MongoProvidedMetrics as MongoProvidedMetrics, + LEGACY_GPTStringToGameGroup, + MongoDerivedMetrics, + MongoOptionalMetrics, + MongoProvidedMetrics, OptionalEnumIndexes, PgDerivedMetrics, PgOptionalMetrics, PgProvidedMetrics, - Playtype, - Playtypes, Preferences, ProfileRatingAlgorithms, ScoreEnumIndexes, @@ -43,25 +39,14 @@ export interface IObjectID { readonly toString: () => string; } -export interface MONGO_CounterDocument { - counterName: string; - value: integer; -} - -export interface MONGO_ChartFolderLookupDocument { - chartID: string; - folderID: string; -} - -export interface MONGO_BaseGoalDocument { - game: GameGroup; - playtype: Playtype; +export interface BaseGoalDocument { + game: TGame; name: string; goalID: string; - criteria: GoalCountCriteria | GoalSingleCriteria; + criteria: GoalCountCriteria | GoalSingleCriteria; } -interface GoalCriteria<_GPT extends GPTString> { +interface GoalCriteria<_TGame extends V3Game = V3Game> { // vvv this basically doesn't work as it starts thinking this might be a symbol // causing a myriad of annoying errors. // key: keyof DerivedMetrics[GPT] | keyof ProvidedMetrics[GPT]; @@ -72,7 +57,7 @@ interface GoalCriteria<_GPT extends GPTString> { value: number; } -export interface GoalSingleCriteria extends GoalCriteria { +export interface GoalSingleCriteria extends GoalCriteria { mode: "single"; } @@ -80,7 +65,7 @@ export interface GoalSingleCriteria extends GoalCriteria< * Criteria for a score to match this criteria - this is a "count" mode, which means that * atleast N scores have to match this criteria. This is for things like folders. */ -export interface GoalCountCriteria extends GoalCriteria { +export interface GoalCountCriteria extends GoalCriteria { mode: "absolute" | "proportion"; countNum: number; } @@ -88,8 +73,7 @@ export interface GoalCountCriteria extends GoalCriteria - extends MONGO_BaseGoalDocument { +export interface GoalDocumentSingle extends BaseGoalDocument { charts: { data: string; type: "single"; @@ -100,8 +84,7 @@ export interface MONGO_GoalDocumentSingle * Goal Document - Multi. A goal document whos set of charts is the array of * chartIDs inside "charts". */ -export interface MONGO_GoalDocumentMulti - extends MONGO_BaseGoalDocument { +export interface GoalDocumentMulti extends BaseGoalDocument { charts: { data: Array; type: "multi"; @@ -112,18 +95,20 @@ export interface MONGO_GoalDocumentMulti * Goal Document - Folder. A goal document whos set of charts is derived from * the folderID inside "charts". */ -export interface MONGO_GoalDocumentFolder - extends MONGO_BaseGoalDocument { +export interface GoalDocumentFolder extends BaseGoalDocument { charts: { + /** Internal Postgres `folder.id`; goal validation and storage use this. */ data: string; + /** Set on API responses so clients can link with {@link BaseFolderDocument.slug}. */ + folderSlug?: string; type: "folder"; }; } -export type MONGO_GoalDocument = - | MONGO_GoalDocumentFolder - | MONGO_GoalDocumentMulti - | MONGO_GoalDocumentSingle; +export type GoalDocument = + | GoalDocumentFolder + | GoalDocumentMulti + | GoalDocumentSingle; interface BaseInviteCodeDocument { createdBy: integer; @@ -131,7 +116,7 @@ interface BaseInviteCodeDocument { createdAt: number; } -export type MONGO_InviteCodeDocument = ( +export type InviteCodeDocument = ( | { consumed: false; consumedAt: null; consumedBy: null } | { consumed: true; consumedAt: number; consumedBy: integer } ) & @@ -157,19 +142,18 @@ interface SessionScoreNewInfo { export type SessionScoreInfo = SessionScoreNewInfo | SessionScorePBInfo; -export interface MONGO_SessionDocument { +export interface SessionDocument { userID: integer; sessionID: string; scoreIDs: Array; name: string; desc: string | null; - game: GameGroup; - playtype: Playtype; + game: V3Game; timeInserted: integer; timeEnded: integer; timeStarted: integer; - calculatedData: Partial>; + calculatedData: Partial>; highlight: boolean; } @@ -178,17 +162,20 @@ interface ImportErrContent { message: string; } -export interface MONGO_ImportDocument { +export interface ImportDocument { userID: integer; timeStarted: number; timeFinished: number; - // Contains an array of GPTString, which dictates what (game:playtype)s were involved in this import. - gptStrings: Array; + gameGroup: GameGroup; + // What games were involved in this import. + // this is just a subset of the games available in the gameGroup above + // the logic around whether imports can have more than one gameGroup is - uh - muddled + // at best. + games: Array; + importID: string; scoreIDs: Array; - game: GameGroup; - playtypes: Array; errors: Array; // TODO(zk): Incoherent and wrong comment @@ -207,7 +194,7 @@ export interface MONGO_ImportDocument { userIntent: boolean; } -export interface MONGO_ImportTimingsDocument { +export interface ImportTimingsDocument { importID: string; timestamp: number; total: number; @@ -236,7 +223,7 @@ interface ImportTimingSections { } export type GoalImportStat = Pick< - MONGO_GoalSubscriptionDocument, + GoalSubscriptionDocument, "achieved" | "outOf" | "outOfHuman" | "progress" | "progressHuman" >; @@ -246,7 +233,7 @@ export interface GoalImportInfo { new: GoalImportStat; } -export type QuestImportStat = Pick; +export type QuestImportStat = Pick; export interface QuestImportInfo { questID: string; @@ -254,20 +241,18 @@ export interface QuestImportInfo { new: QuestImportStat; } -export type MONGO_GoalSubscriptionDocument = { - game: GameGroup; +export type GoalSubscriptionDocument = { + game: V3Game; goalID: string; lastInteraction: integer | null; outOf: number; outOfHuman: string; - playtype: Playtype; progress: number | null; progressHuman: string; userID: integer; // Was this goal assigned "standalone"? I.e. a user explicitly subscribed to this. // instead of it being a result of a quest subscription. wasAssignedStandalone: boolean; - wasInstantlyAchieved: boolean; } & ( | { @@ -291,27 +276,25 @@ export interface QuestSection { goals: Array; } -export interface MONGO_QuestDocument { - game: GameGroup; - playtype: Playtype; +export interface QuestDocument { + game: V3Game; name: string; desc: string; questData: Array; questID: string; } -export interface MONGO_QuestlineDocument { +export interface QuestlineDocument { questlineID: string; name: string; desc: string; - game: GameGroup; - playtype: Playtype; + game: V3Game; quests: Array; } export type UserBadges = "alpha" | "beta" | "contributor" | "dev-team" | "significant-contributor"; -export interface MONGO_UserDocument { +export interface UserDocument { username: string; usernameLowercase: string; id: integer; @@ -334,23 +317,21 @@ export interface MONGO_UserDocument { isSupporter?: boolean; } -export interface MONGO_SpecificUserGameStats { +export interface SpecificUserGameStats { userID: integer; - game: GPTStringToGame[GPT]; - playtype: GPTStringToPlaytype[GPT]; - ratings: Partial>; - classes: Partial; + game: V3Game; + ratings: Partial>; + classes: Partial; } /** * GPT agnostic stats for a game. This type is easier to work with than the * specificUserGameStats one for general cases. */ -export interface MONGO_UserGameStats { +export interface UserGameStats { userID: integer; - game: GameGroup; - playtype: Playtype; - ratings: Partial>; + game: V3Game; + ratings: Partial>; classes: AnyClasses; } @@ -360,20 +341,20 @@ export interface ChartTierlistInfo { individualDifference?: boolean; } -export interface MONGO_ChartDocument { +export interface ChartDocument { + game: TGame; chartID: string; - songID: integer; level: string; levelNum: number; isPrimary: boolean; - difficulty: Difficulties[GPT]; - playtype: GPTStringToPlaytype[GPT]; - data: MONGO_ChartDocumentData[GPT]; - versions: Array; + difficulty: Difficulties[TGame]; + data: ChartDocumentData[TGame]; + versions: Array; + song: SongDocument; } -export interface MONGO_SongDocument { - id: integer; +export interface SongDocument { + id: string; title: string; artist: string; searchTerms: Array; @@ -381,10 +362,9 @@ export interface MONGO_SongDocument { data: SongDocumentData[G]; } -export interface MONGO_TableDocument { +export interface TableDocument { tableID: string; - game: GameGroup; - playtype: Playtype; + game: V3Game; title: string; description: string; folders: Array; @@ -392,11 +372,11 @@ export interface MONGO_TableDocument { default: boolean; } -export interface MONGO_BaseFolderDocument { - title: string; - game: GameGroup; - playtype: Playtype; +export interface BaseFolderDocument { folderID: string; + slug: string; + title: string; + game: V3Game; /** * This folder has been superceded by another folder, @@ -406,35 +386,31 @@ export interface MONGO_BaseFolderDocument { searchTerms: Array; } -export interface MONGO_FolderSongsDocument extends MONGO_BaseFolderDocument { +export interface FolderSongsDocument extends BaseFolderDocument { type: "songs"; - data: FilterQuery; + data: FilterQuery; } -export interface MONGO_FolderChartsDocument extends MONGO_BaseFolderDocument { +export interface FolderChartsDocument extends BaseFolderDocument { type: "charts"; - data: FilterQuery; + data: FilterQuery; } -export interface MONGO_FolderStaticDocument extends MONGO_BaseFolderDocument { +export interface FolderStaticDocument extends BaseFolderDocument { type: "static"; data: Array; } -export type MONGO_FolderDocument = - | MONGO_FolderChartsDocument - | MONGO_FolderSongsDocument - | MONGO_FolderStaticDocument; +export type FolderDocument = FolderChartsDocument | FolderSongsDocument | FolderStaticDocument; export interface FolderChartLookup { chartID: string; folderID: string; } -export type MONGO_QuestSubscriptionDocument = { - game: GameGroup; +export type QuestSubscriptionDocument = { + game: V3Game; lastInteraction: integer | null; - playtype: Playtype; progress: integer; questID: string; userID: integer; @@ -456,35 +432,33 @@ export type PgScoreData = { judgements: PgScoreJudgements; }; -export type PgScoreProvidedData = - PgOptionalMetrics[V3GameToGPTString[Game]] & PgProvidedMetrics[V3GameToGPTString[Game]]; +export type PgScoreProvidedData = PgOptionalMetrics[TGame] & + PgProvidedMetrics[TGame]; -export type PgScoreDerivedData = - PgDerivedMetrics[V3GameToGPTString[Game]]; +export type PgScoreDerivedData = PgDerivedMetrics[TGame]; -export type PgScoreJudgements = Partial< - Record +export type PgScoreJudgements = Partial< + Record >; -export type MONGO_ScoreData = { - enumIndexes: ScoreEnumIndexes; - judgements: Partial>; +export type ScoreData = { + enumIndexes: ScoreEnumIndexes; + judgements: Partial>; optional: { - enumIndexes: OptionalEnumIndexes; - } & MongoOptionalMetrics[GPT]; -} & MongoDerivedMetrics[GPT] & - MongoProvidedMetrics[GPT]; + enumIndexes: OptionalEnumIndexes; + } & MongoOptionalMetrics[TGame]; +} & MongoDerivedMetrics[TGame] & + MongoProvidedMetrics[TGame]; -export interface MONGO_ScoreDocument { +export interface ScoreDocument { service: string; - game: GPTStringToGame[GPT]; - playtype: GPTStringToPlaytype[GPT]; + game: TGame; userID: integer; - scoreData: MONGO_ScoreData; - scoreMeta: Partial; - calculatedData: Partial>; + scoreData: ScoreData; + scoreMeta: Partial; + calculatedData: Partial>; timeAchieved: integer | null; - songID: integer; + songID: string; chartID: string; isPrimary: boolean; highlight: boolean; @@ -499,7 +473,7 @@ export interface PBReference { scoreID: string; } -export interface MONGO_PBScoreDocument { +export interface PBScoreDocument { // guaranteed to atleast have one element. composedFrom: [PBReference, ...Array]; rankingData: { @@ -514,14 +488,13 @@ export interface MONGO_PBScoreDocument { }; userID: integer; chartID: string; - game: GameGroup; - playtype: Playtype; - songID: integer; + game: TGame; + songID: string; highlight: boolean; isPrimary: boolean; timeAchieved: number | null; - scoreData: MONGO_ScoreData; - calculatedData: Partial>; + scoreData: ScoreData; + calculatedData: Partial>; } export interface ImportProcessInfoOrphanExists { @@ -549,12 +522,12 @@ export interface ImportProcessInfoAmbiguousTitle { }; } -export interface ImportProcessInfoScoreImported { +export interface ImportProcessInfoScoreImported { success: true; type: "ScoreImported"; message: string; content: { - score: MONGO_ScoreDocument; + score: ScoreDocument; }; } @@ -577,12 +550,12 @@ export interface ImportProcessInfoSongOrChartNotFound { }; } -export type ImportProcessingInfo = +export type ImportProcessingInfo = | ImportProcessInfoAmbiguousTitle | ImportProcessInfoInternalError | ImportProcessInfoInvalidDatapoint | ImportProcessInfoOrphanExists - | ImportProcessInfoScoreImported + | ImportProcessInfoScoreImported | ImportProcessInfoSongOrChartNotFound; export interface ImportStatistics { @@ -594,14 +567,14 @@ export interface ImportStatistics { importID: string; } -export interface MONGO_KaiAuthDocument { +export interface KaiAuthDocument { userID: integer; token: string; refreshToken: string; service: "EAG" | "FLO" | "MIN"; } -export interface MONGO_CGCardInfo { +export interface CGCardInfo { userID: integer; service: "dev" | "gan" | "nag"; cardID: string; @@ -610,22 +583,25 @@ export interface MONGO_CGCardInfo { pin: string; } -export interface MONGO_MytCardInfo { +export interface MytCardInfo { userID: integer; cardAccessCode: string; // matches /^[0-9]{20}$/ } -interface BMSCourseInner { +interface BMSCourseInner { set: Set; - playtype: GPTStringToPlaytype[GPT]; - value: ExtractedClasses[GPT][Set]; + game: TGame; + value: ExtractedClasses[TGame][Set]; } /** * Used to resolve beatoraja IR courses. */ -export interface MONGO_BMSCourseDocument - extends BMSCourseInner { +export interface BMSCourseDocument + extends BMSCourseInner< + "bms-7k" | "bms-14k" | "pms-controller" | "pms-keyboard", + keyof ExtractedClasses["bms-7k" | "bms-14k" | "pms-controller" | "pms-keyboard"] + > { title: string; md5sums: string; } @@ -633,7 +609,7 @@ export interface MONGO_BMSCourseDocument /** * Information about the API Token used to make this request. */ -export interface MONGO_APITokenDocument { +export interface APITokenDocument { userID: integer | null; token: string | null; identifier: string; @@ -644,15 +620,13 @@ export interface MONGO_APITokenDocument { fromAPIClient: string | null; } -export interface MONGO_ImportLockDocument { - userID: integer; -} - export type ShowcaseStatDetails = ShowcaseStatChart | ShowcaseStatFolder; export interface ShowcaseStatFolder { mode: "folder"; - folderID: string; + // TODO(zk): This should be folderID, not slug + /** {@link BaseFolderDocument.slug} for this game. */ + slug: string; // should be a valid metric for the showcase this game is for // this is not checked by the typesystem though. sorry! @@ -663,37 +637,32 @@ export interface ShowcaseStatFolder { export interface ShowcaseStatChart { mode: "chart"; chartID: string; - - // should be a valid metric for the showcase this game is for - // this is not checked by the typesystem though. sorry! - metric: string; } -export interface MONGO_UGPTSettingsDocument { +export interface UGPTSettingsDocument { userID: integer; - game: GPTStringToGame[GPT]; - playtype: GPTStringToPlaytype[GPT]; + game: TGame; preferences: { defaultTable: string | null; - gameSpecific: Preferences[GPT]; + gameSpecific: Preferences[TGame]; preferredDefaultEnum: string | null; - preferredProfileAlg: ProfileRatingAlgorithms[GPT] | null; + preferredProfileAlg: ProfileRatingAlgorithms[TGame] | null; preferredRanking: "global" | "rival" | null; - preferredScoreAlg: ScoreRatingAlgorithms[GPT] | null; - preferredSessionAlg: SessionRatingAlgorithms[GPT] | null; + preferredScoreAlg: ScoreRatingAlgorithms[TGame] | null; + preferredSessionAlg: SessionRatingAlgorithms[TGame] | null; stats: Array; }; rivals: Array; } -export interface MONGO_UserGameStatsSnapshotDocument - extends MONGO_SpecificUserGameStats { - rankings: Record; +export interface UserGameStatsSnapshotDocument + extends SpecificUserGameStats { + rankings: Record; playcount: integer; timestamp: integer; } -export interface MONGO_UserSettingsDocument { +export interface UserSettingsDocument { userID: integer; following: Array; preferences: { @@ -705,7 +674,7 @@ export interface MONGO_UserSettingsDocument { }; } -export interface MONGO_TachiAPIClientDocument { +export interface TachiAPIClientDocument { clientID: string; clientSecret: string; name: string; @@ -717,62 +686,58 @@ export interface MONGO_TachiAPIClientDocument { apiKeyFilename: string | null; } -export interface MONGO_FervidexSettingsDocument { +export interface FervidexSettingsDocument { userID: integer; cards: Array | null; forceStaticImport: boolean; } -export interface MONGO_KsHookSettingsDocument { +export interface KsHookSettingsDocument { userID: integer; forceStaticImport: boolean; } -export interface MONGO_OrphanChartDocument { - gptString: GPT; - chartDoc: MONGO_ChartDocument; - songDoc: MONGO_SongDocument; +export interface OrphanChartDocument { + game: TGame; + chartDoc: ChartDocument; + songDoc: SongDocument; userIDs: Array; } export interface ClassDelta { - game: GameGroup; - set: Classes[GPTString]; - playtype: Playtype; + game: V3Game; + set: Classes[V3Game]; old: string | null; new: string; } -export interface MONGO_ClassAchievementDocument { - game: GPTStringToGame[GPT]; - playtype: GPTStringToPlaytype[GPT]; - classSet: Classes[GPT]; +export interface ClassAchievementDocument { + game: TGame; + classSet: Classes[TGame]; classOldValue: string | null; classValue: string; timeAchieved: number; userID: integer; } -export interface MONGO_RecentlyViewedFolderDocument { +export interface RecentlyViewedFolderDocument { userID: integer; - game: GameGroup; - playtype: Playtypes[GameGroup]; - folderID: string; + game: V3Game; + // TODO(zk): should be folderID + /** Folder {@link BaseFolderDocument.slug} for this game. */ + slug: string; lastViewed: number; } -export type MONGO_NotificationDocument = { +export type NotificationDocument = { body: NotificationBody; } & BaseNotification; -export interface MONGO_ChallengeSubscriptionDocument { +export interface ChallengeSubscriptionDocument { chartID: string; authorID: integer; type: "lamp" | "score"; - - game: GameGroup; - playtype: Playtype; - + game: V3Game; userID: integer; achieved: boolean; achievedAt: number | null; @@ -799,11 +764,11 @@ export interface ImportTrackerFailed extends BaseImportTracker { * Tracks the status of an import while it goes through the pipeline or if it fails. * * Successful imports are removed from the tracking database, and their existence - * is kept track of via { @see MONGO_ImportDocument }. + * is kept track of via { @see ImportDocument }. */ -export type MONGO_ImportTrackerDocument = ImportTrackerFailed | ImportTrackerOngoing; +export type ImportTrackerDocument = ImportTrackerFailed | ImportTrackerOngoing; -export interface MONGO_UserNameChangeDocument { +export interface UserNameChangeDocument { userID: integer; username: string; timestamp: integer; diff --git a/typescript/common/src/types/game-config-utils.ts b/typescript/common/src/types/game-config-utils.ts index a7eaa9a4e..d22a1a2aa 100644 --- a/typescript/common/src/types/game-config-utils.ts +++ b/typescript/common/src/types/game-config-utils.ts @@ -128,7 +128,7 @@ export interface FixedDifficulties { * * For example, in IIDX "ANOTHER" is generally shortened to "A". */ - shorthand: Partial>; + format: Partial>; default: Difficulty; } diff --git a/typescript/common/src/types/game-config.ts b/typescript/common/src/types/game-config.ts index 5671bff68..979914dc6 100644 --- a/typescript/common/src/types/game-config.ts +++ b/typescript/common/src/types/game-config.ts @@ -1,6 +1,6 @@ import type { z, ZodObject } from "zod"; -import type { GAME_GROUP_CONFIGS, GAME_PT_CONFIGS } from "../config/config"; +import type { GAME_CONFIGS, GAME_GROUP_CONFIGS } from "../config/config"; import type { integer } from "../types"; import type { MatchTypes } from "./batch-manual"; import type { @@ -9,7 +9,7 @@ import type { ProfileRatingAlgorithmConfig, RatingAlgorithmConfig, } from "./game-config-utils"; -import type { INTERNAL_GAME_PT_CONFIG } from "./internals"; +import type { INTERNAL_GAME_CONFIG } from "./internals"; import type { ExtractEnumMetricNames, MongoExtractMetrics as MongoExtractMetrics, @@ -18,11 +18,15 @@ import type { import type { AllFieldsNullableOptional, ExtractArrayElementType } from "./utils"; /** - * All the games Tachi supports. + * All the game groups Tachi supports. */ export type GameGroup = keyof typeof GAME_GROUP_CONFIGS; /** + * LEGACY. Game + Playtype was the v2 way of identifying games. + * + * --- + * * What game + playtypes does Tachi support? We typically shorten this concept * to a "GPT", or Game+Playtype. * @@ -36,15 +40,19 @@ export type GameGroup = keyof typeof GAME_GROUP_CONFIGS; * * For games that don't really have a meaningful concept of "playtypes", "Single" * is the go-to. + * + * @deprecated */ -export type Playtypes = { +export type LEGACY_Playtypes = { [G in GameGroup]: (typeof GAME_GROUP_CONFIGS)[G]["playtypes"][number]; }; /** - * Expresses any playtype (for any game). Alias for Playtypes[Game]. + * Expresses any playtype (for any game). Alias for LEGACY_Playtypes[Game]. + * + * @deprecated */ -export type Playtype = Playtypes[GameGroup]; +export type LEGACY_Playtype = LEGACY_Playtypes[GameGroup]; export type SongDocumentData = { [G in GameGroup]: z.infer<(typeof GAME_GROUP_CONFIGS)[G]["songData"]>; @@ -55,27 +63,43 @@ export type SongDocumentData = { * and what playtypes it supports. */ export interface GameGroupConfig { - readonly name: string; - readonly playtypes: ReadonlyArray; - /** - * Song documents get their own game-specific record that they use for whatever - * they want. IIDX song documents store genres, etc. + * A pretty name for this game group. + */ + readonly name: string; + /** + * In tachi 2, games were identified by a GameGroup + playtype. In Tachi 3, + * games are just identified by the Game. + * + * This is for backwards compatibility with the old system, and the v1 api. + * + * @deprecated Use `games` instead. + */ + readonly playtypes: ReadonlyArray; + /** + * What games are in this group? + */ + readonly games: ReadonlyArray; + /** + * Songs are the only thing that are meaningfully part of a "game group" but not a game + * itself - basically, for sharing song data between games. */ readonly songData: ZodObject; } /** - * GPTStrings are an internal (ish) identifier used to identify Game + Playtype combos. + * GPTStrings are an legacy identifier used to identify GameGroup + Playtype combos. * * These are used in places where we want to switch over all supported game + playtype * combos. * * The below type magic automatically creates all combinations like iidx:SP, iidx:DP... * using the `Playtypes` thing above. + * + * @deprecated Use V3Game instead. */ -export type GPTString = keyof { - [G in GameGroup as `${G}:${Playtypes[G]}`]: never; +export type LEGACY_GPTString = keyof { + [G in GameGroup as `${G}:${LEGACY_Playtypes[G]}`]: never; }; export type V3Game = @@ -103,6 +127,32 @@ export type V3Game = | "usc-keyboard" | "wacca"; +export interface GameGroupFromGame { + arcaea: "arcaea"; + "bms-7k": "bms"; + "bms-14k": "bms"; + chunithm: "chunithm"; + "ddr-dp": "ddr"; + "ddr-sp": "ddr"; + "gitadora-dora": "gitadora"; + "gitadora-gita": "gitadora"; + "iidx-dp": "iidx"; + "iidx-sp": "iidx"; + "itg-stamina": "itg"; + jubeat: "jubeat"; + maimai: "maimai"; + maimaidx: "maimaidx"; + museca: "museca"; + ongeki: "ongeki"; + "pms-controller": "pms"; + "pms-keyboard": "pms"; + popn: "popn"; + sdvx: "sdvx"; + "usc-controller": "usc"; + "usc-keyboard": "usc"; + wacca: "wacca"; +} + export type V3GameToGPTString = { arcaea: "arcaea:Touch"; "bms-7k": "bms:7K"; @@ -155,138 +205,150 @@ export type GPTStringToV3Game = { "wacca:Single": "wacca"; }; -export type GPTStrings = { - [G in GameGroup]: `${G}:${Playtypes[G]}`; +/// Get all the games that exist for this game group. +export type GamesForGroup = { + [G in GameGroup]: (typeof GAME_GROUP_CONFIGS)[G]["games"][number]; }; -export type GetGameFromGPTString = GPT extends `${infer G}:${infer _}` - ? G - : never; -export type GetPlaytypeFromGPTString = GPT extends `${infer _}:${infer PT}` - ? PT - : never; +/** + * @deprecated Use V3Game instead. + */ +export type LEGACY_GPTStrings = { + [G in GameGroup]: `${G}:${LEGACY_Playtypes[G]}`; +}; + +export type GetGameGroupFromGPTString = + GPT extends `${infer G}:${infer _}` ? G : never; +export type GetPlaytypeFromGPTString = + GPT extends `${infer _}:${infer PT}` ? PT : never; // Now that we've got GPTString defined, we can define "lookup types" for things about this GPT. // For example, if we have a function that works with IIDX's difficulties, we want a type like // Difficulties["iidx:SP"] which expresses those difficulties. -export type GPTStringToGame = { - [GPT in GPTString]: GetGameFromGPTString; +export type LEGACY_GPTStringToGameGroup = { + [GPT in LEGACY_GPTString]: GetGameGroupFromGPTString; }; -export type GPTStringToPlaytype = { - [GPT in GPTString]: GetPlaytypeFromGPTString; +export type LEGACY_GPTStringToPlaytype = { + [GPT in LEGACY_GPTString]: GetPlaytypeFromGPTString; +}; + +export type LEGACY_GameToPlaytype = { + [TGame in V3Game]: LEGACY_Playtypes[GetGameGroupFromGPTString]; }; export type Difficulties = { // If this game has fixed difficulties, infer what they are // otherwise, difficulties are an arbitrary string - [G in GPTString]: (typeof GAME_PT_CONFIGS)[G]["difficulties"] extends FixedDifficulties + [TGame in V3Game]: (typeof GAME_CONFIGS)[TGame]["difficulties"] extends FixedDifficulties< + infer D + > ? D : string; }; export type DifficultyConfigs = { - [G in GPTString]: (typeof GAME_PT_CONFIGS)[G]["difficulties"]; + [TGame in V3Game]: (typeof GAME_CONFIGS)[TGame]["difficulties"]; }; export type Judgements = { - [G in GPTString]: ExtractArrayElementType<(typeof GAME_PT_CONFIGS)[G]["orderedJudgements"]>; + [TGame in V3Game]: ExtractArrayElementType<(typeof GAME_CONFIGS)[TGame]["orderedJudgements"]>; }; export type Versions = { // https://stackoverflow.com/questions/51808160/keyof-inferring-string-number-when-key-is-only-a-string - [G in GPTString]: keyof (typeof GAME_PT_CONFIGS)[G]["versions"] & string; + [TGame in V3Game]: keyof (typeof GAME_CONFIGS)[TGame]["versions"] & string; }; export type ScoreRatingAlgorithms = { - [G in GPTString]: keyof (typeof GAME_PT_CONFIGS)[G]["scoreRatingAlgs"] & string; + [TGame in V3Game]: keyof (typeof GAME_CONFIGS)[TGame]["scoreRatingAlgs"] & string; }; export type SessionRatingAlgorithms = { - [G in GPTString]: keyof (typeof GAME_PT_CONFIGS)[G]["sessionRatingAlgs"] & string; + [TGame in V3Game]: keyof (typeof GAME_CONFIGS)[TGame]["sessionRatingAlgs"] & string; }; export type ProfileRatingAlgorithms = { - [G in GPTString]: keyof (typeof GAME_PT_CONFIGS)[G]["profileRatingAlgs"] & string; + [TGame in V3Game]: keyof (typeof GAME_CONFIGS)[TGame]["profileRatingAlgs"] & string; }; -export type AnyScoreRatingAlg = ScoreRatingAlgorithms[GPTString]; -export type AnySessionRatingAlg = SessionRatingAlgorithms[GPTString]; -export type AnyProfileRatingAlg = ProfileRatingAlgorithms[GPTString]; +export type AnyScoreRatingAlg = ScoreRatingAlgorithms[V3Game]; +export type AnySessionRatingAlg = SessionRatingAlgorithms[V3Game]; +export type AnyProfileRatingAlg = ProfileRatingAlgorithms[V3Game]; export type ConfProvidedMetrics = { - [G in GPTString]: (typeof GAME_PT_CONFIGS)[G]["providedMetrics"]; + [TGame in V3Game]: (typeof GAME_CONFIGS)[TGame]["providedMetrics"]; }; export type ConfDerivedMetrics = { - [G in GPTString]: (typeof GAME_PT_CONFIGS)[G]["derivedMetrics"]; + [TGame in V3Game]: (typeof GAME_CONFIGS)[TGame]["derivedMetrics"]; }; export type ConfOptionalMetrics = { - [G in GPTString]: (typeof GAME_PT_CONFIGS)[G]["optionalMetrics"]; + [TGame in V3Game]: (typeof GAME_CONFIGS)[TGame]["optionalMetrics"]; }; export type ConfScoreMetrics = { - [G in GPTString]: ConfDerivedMetrics[G] & ConfProvidedMetrics[G]; + [TGame in V3Game]: ConfDerivedMetrics[TGame] & ConfProvidedMetrics[TGame]; }; export type ExtractedClasses = { - [G in GPTString]: ExtractClassValues<(typeof GAME_PT_CONFIGS)[G]["classes"]>; + [TGame in V3Game]: ExtractClassValues<(typeof GAME_CONFIGS)[TGame]["classes"]>; }; export type AnyClasses = { - [C in Classes[GPTString]]?: string | null; + [C in Classes[V3Game]]?: string | null; }; export type Classes = { - [G in GPTString]: keyof (typeof GAME_PT_CONFIGS)[G]["classes"] & string; + [TGame in V3Game]: keyof (typeof GAME_CONFIGS)[TGame]["classes"] & string; }; export type ClassConfigs = { - [G in GPTString]: (typeof GAME_PT_CONFIGS)[G]["classes"]; + [TGame in V3Game]: (typeof GAME_CONFIGS)[TGame]["classes"]; }; -export type MONGO_ChartDocumentData = { - [G in GPTString]: z.infer<(typeof GAME_PT_CONFIGS)[G]["chartData"]>; +export type ChartDocumentData = { + [TGame in V3Game]: z.infer<(typeof GAME_CONFIGS)[TGame]["chartData"]>; }; export type Preferences = { - [G in GPTString]: z.infer<(typeof GAME_PT_CONFIGS)[G]["preferences"]>; + [TGame in V3Game]: z.infer<(typeof GAME_CONFIGS)[TGame]["preferences"]>; }; export type ScoreMeta = { - [G in GPTString]: z.infer<(typeof GAME_PT_CONFIGS)[G]["scoreMeta"]>; + [TGame in V3Game]: z.infer<(typeof GAME_CONFIGS)[TGame]["scoreMeta"]>; }; export type MongoProvidedMetrics = { - [G in GPTString]: MongoExtractMetrics<(typeof GAME_PT_CONFIGS)[G]["providedMetrics"]>; + [TGame in V3Game]: MongoExtractMetrics<(typeof GAME_CONFIGS)[TGame]["providedMetrics"]>; }; export type MongoDerivedMetrics = { - [G in GPTString]: MongoExtractMetrics<(typeof GAME_PT_CONFIGS)[G]["derivedMetrics"]>; + [TGame in V3Game]: MongoExtractMetrics<(typeof GAME_CONFIGS)[TGame]["derivedMetrics"]>; }; export type PgProvidedMetrics = { - [G in GPTString]: PgExtractMetrics<(typeof GAME_PT_CONFIGS)[G]["providedMetrics"]>; + [TGame in V3Game]: PgExtractMetrics<(typeof GAME_CONFIGS)[TGame]["providedMetrics"]>; }; export type PgOptionalMetrics = { - [G in GPTString]: PgExtractMetrics<(typeof GAME_PT_CONFIGS)[G]["optionalMetrics"]>; + [TGame in V3Game]: PgExtractMetrics<(typeof GAME_CONFIGS)[TGame]["optionalMetrics"]>; }; export type PgDerivedMetrics = { - [G in GPTString]: PgExtractMetrics<(typeof GAME_PT_CONFIGS)[G]["derivedMetrics"]>; + [TGame in V3Game]: PgExtractMetrics<(typeof GAME_CONFIGS)[TGame]["derivedMetrics"]>; }; /** * Top level metrics on a score. */ export type MongoScoreMetrics = { - [G in GPTString]: MongoDerivedMetrics[G] & MongoProvidedMetrics[G]; + [TGame in V3Game]: MongoDerivedMetrics[TGame] & MongoProvidedMetrics[TGame]; }; export type MongoOptionalMetrics = { - [G in GPTString]: AllFieldsNullableOptional< - MongoExtractMetrics<(typeof GAME_PT_CONFIGS)[G]["optionalMetrics"]> + [TGame in V3Game]: AllFieldsNullableOptional< + MongoExtractMetrics<(typeof GAME_CONFIGS)[TGame]["optionalMetrics"]> >; }; @@ -294,37 +356,37 @@ export type MongoOptionalMetrics = { * Alongside strings, we want to store integers to represent the integer value * of enums. */ -export type ScoreEnumIndexes = Record< - ExtractEnumMetricNames, +export type ScoreEnumIndexes = Record< + ExtractEnumMetricNames, integer >; /** * Same as ScoreEnumIndexes but for the optional properties on a score. */ -export type OptionalEnumIndexes = Partial< - Record, integer> +export type OptionalEnumIndexes = Partial< + Record, integer> >; /** - * A generic GamePTConfig. This type is significantly less specific than the - * "SpecificGamePTConfig", which only really works as a type if you know *what* - * GamePTConfig you're working with. + * A generic GameConfig. This type is significantly less specific than the + * "SpecificGameConfig", which only really works as a type if you know *what* + * GameConfig you're working with. */ -export type GamePTConfig = { - defaultProfileRatingAlg: ProfileRatingAlgorithms[GPTString]; - defaultScoreRatingAlg: ScoreRatingAlgorithms[GPTString]; - defaultSessionRatingAlg: SessionRatingAlgorithms[GPTString]; -} & INTERNAL_GAME_PT_CONFIG; +export type GameConfig = { + defaultProfileRatingAlg: ProfileRatingAlgorithms[V3Game]; + defaultScoreRatingAlg: ScoreRatingAlgorithms[V3Game]; + defaultSessionRatingAlg: SessionRatingAlgorithms[V3Game]; +} & INTERNAL_GAME_CONFIG; /** * Configuration for a GPT. This declares *almost everything* about how this game is * implemented in Tachi, such as what metrics it supports, how it handles chart * difficulties, etc. * - * To get a GamePTConfig for a given Game + Playtype, @see {GetGamePTConfig} + * To get a GameConfig for a given Game + Playtype, @see {GetGameConfig} */ -export interface SpecificGamePTConfig { +export interface SpecificGameConfig { /** * What metrics **must** be provided in order for this score to be usable by * Tachi? @@ -332,7 +394,7 @@ export interface SpecificGamePTConfig { * This is intended for things like Score, Lamp, etc. Things that quite fundamentally * *are* the metrics of the score. */ - providedMetrics: ConfProvidedMetrics[GPT]; + providedMetrics: ConfProvidedMetrics[TGame]; /** * What metrics do we want to exist on score documents, but don't need to be @@ -354,7 +416,7 @@ export interface SpecificGamePTConfig { * Another good example would be "Grade" for most games, as a grade is often just * cutoffs applied on score values. */ - derivedMetrics: ConfDerivedMetrics[GPT]; + derivedMetrics: ConfDerivedMetrics[TGame]; /** * What's the default metric for this GPT? @@ -363,7 +425,7 @@ export interface SpecificGamePTConfig { * * @note This **MUST** be one of the mandatory or derived keys. */ - defaultMetric: keyof ConfDerivedMetrics[GPT] | keyof ConfProvidedMetrics[GPT]; + defaultMetric: keyof ConfDerivedMetrics[TGame] | keyof ConfProvidedMetrics[TGame]; /** * What's the preferred default enum for this GPT? @@ -374,7 +436,7 @@ export interface SpecificGamePTConfig { * @note This **MUST** be one of the mandatory or derived keys. */ preferredDefaultEnum: ExtractEnumMetricNames< - ConfDerivedMetrics[GPT] & ConfProvidedMetrics[GPT] + ConfDerivedMetrics[TGame] & ConfProvidedMetrics[TGame] >; /** @@ -387,7 +449,7 @@ export interface SpecificGamePTConfig { * The idea of additionalMetrics allow us to store useful metrics about scores * without necessitating that they exist on arrival. Incredibly convenient. */ - additionalMetrics: ConfOptionalMetrics[GPT]; + additionalMetrics: ConfOptionalMetrics[TGame]; /** * What rating algorithms may a score have attached onto it for this GPT? @@ -396,7 +458,7 @@ export interface SpecificGamePTConfig { * server config. By defining them here, the typesystem will enforce that you * implement them elsewhere. */ - scoreRatingAlgs: Record; + scoreRatingAlgs: Record; /** * What rating algorithms may a session have attached onto it for this GPT? @@ -405,7 +467,7 @@ export interface SpecificGamePTConfig { * server config. By defining them here, the typesystem will enforce that you * implement them elsewhere. */ - sessionRatingAlgs: Record; + sessionRatingAlgs: Record; /** * What rating algorithms may a profile have attached onto it for this GPT? @@ -425,7 +487,7 @@ export interface SpecificGamePTConfig { * server config. By defining them here, the typesystem will enforce that you * implement them elsewhere. */ - profileRatingAlgs: Record; + profileRatingAlgs: Record; /** * What classes may a profile have attached onto it for this GPT? @@ -436,28 +498,28 @@ export interface SpecificGamePTConfig { * or they may be provided by score imports, such as "dans", which cannot be * derived from a player's scores or profile ratings. */ - classes: ClassConfigs[GPT]; + classes: ClassConfigs[TGame]; /** * What's the default score rating algorithm for this GPT? * * @note This should be one of the keys in scoreRatingAlgs. */ - defaultScoreRatingAlg: ScoreRatingAlgorithms[GPT]; + defaultScoreRatingAlg: ScoreRatingAlgorithms[TGame]; /** * What's the default session rating algorithm for this GPT? * * @note This should be one of the keys in sessionRatingAlgs. */ - defaultSessionRatingAlg: SessionRatingAlgorithms[GPT]; + defaultSessionRatingAlg: SessionRatingAlgorithms[TGame]; /** * What's the default profile rating algorithm for this GPT1? * * @note This should be one of the keys in sessionRatingAlgs. */ - defaultProfileRatingAlg: ProfileRatingAlgorithms[GPT]; + defaultProfileRatingAlg: ProfileRatingAlgorithms[TGame]; /** * How does this GPT handle difficulties? @@ -470,14 +532,14 @@ export interface SpecificGamePTConfig { * allowing any string (as long as its unique.) * */ - difficulties: DifficultyConfigs[GPT]; + difficulties: DifficultyConfigs[TGame]; /** * What judgements does this GPT have? These are typically timing-window names. * * These should be ordered from **best to worst**. */ - orderedJudgements: ReadonlyArray; + orderedJudgements: ReadonlyArray; /** * What versions do we support for this GPT? @@ -500,7 +562,7 @@ export interface SpecificGamePTConfig { * version this score was on. That way, we can make sure they resolve to the right * chart. */ - versions: Record; + versions: Record; /** * Chart documents get their own GPT-specific record that they use for whatever @@ -532,3 +594,6 @@ export interface SpecificGamePTConfig { */ supportedMatchTypes: ReadonlyArray; } + +// Games that are BMS-like. +export type BMSGames = GamesForGroup["bms" | "pms"]; diff --git a/typescript/common/src/types/internals.ts b/typescript/common/src/types/internals.ts index 1698573b4..bb8219bcb 100644 --- a/typescript/common/src/types/internals.ts +++ b/typescript/common/src/types/internals.ts @@ -9,17 +9,18 @@ import type { } from "./game-config-utils"; import type { ConfScoreMetric } from "./metrics"; +import { type V3Game } from "../types"; + /** - * What's the "mold" for a GPT config? All GPT configs *must* satisfy this interface, - * but aren't necessarily this type. + * What's the "mold" for a game config? All game configs *must* satisfy this interface. * - * @see {GamePTConfig} for the intended-to-use type. This is an *outline* for a GPT + * @see {GameConfig} for the intended-to-use type. This is an *outline* for a GPT * config, and has significantly less typesafety. * * Documentation for this type can be found in `game-support.ts`, which has the - * GamePTConfig type, which actually has documentation. + * GameConfig type, which actually has documentation. */ -export type INTERNAL_GAME_PT_CONFIG = Readonly<{ +export type INTERNAL_GAME_CONFIG = Readonly<{ chartData: ZodObject; classes: Record; @@ -49,6 +50,7 @@ export type INTERNAL_GAME_PT_CONFIG = Readonly<{ } & ConfScoreMetric >; orderedJudgements: ReadonlyArray; + preferences: ZodObject; preferredDefaultEnum: string; @@ -60,20 +62,20 @@ export type INTERNAL_GAME_PT_CONFIG = Readonly<{ scoreMeta: ZodObject; scoreRatingAlgs: Record; - sessionRatingAlgs: Record; supportedMatchTypes: ReadonlyArray; versions: Record; }>; /** - * A game config *must* satisfy this, but we don't export this kind of game config. + * A game group config *must* satisfy this, but we don't export this kind of game config. * * Think of this like a "mold" for a game config, it's gotta be shaped like this, * but interacting with the mold is a little too malleable for the rest of the - * codebase. @see {GameConfig} for the exported version. + * codebase. @see {GameGroupConfig} for the exported version. */ -export type INTERNAL_GAME_CONFIG = Readonly<{ +export type INTERNAL_GAME_GROUP_CONFIG = Readonly<{ + games: ReadonlyArray; name: string; playtypes: ReadonlyArray; songData: ZodObject; diff --git a/typescript/common/src/types/metrics.ts b/typescript/common/src/types/metrics.ts index 3fb318f76..b28865844 100644 --- a/typescript/common/src/types/metrics.ts +++ b/typescript/common/src/types/metrics.ts @@ -1,11 +1,6 @@ -import type { integer } from "../types"; -import type { MONGO_ChartDocument } from "./documents"; -import type { - ConfDerivedMetrics, - ConfOptionalMetrics, - ConfProvidedMetrics, - GPTString, -} from "./game-config"; +import type { integer, V3Game } from "../types"; +import type { ChartDocument } from "./documents"; +import type { ConfDerivedMetrics, ConfOptionalMetrics, ConfProvidedMetrics } from "./game-config"; // WARNING! THIS FILE IS VERY COMPLEX. THERE IS SOME HIGH-TIER TYPESCRIPT MAGIC GOING // ON HERE. @@ -175,9 +170,9 @@ export type ExtractEnumMetricNames> = * What are all the metrics available for this GPT? */ export type AllConfMetrics = { - [GPT in GPTString]: ConfDerivedMetrics[GPT] & - ConfOptionalMetrics[GPT] & - ConfProvidedMetrics[GPT]; + [TGame in V3Game]: ConfDerivedMetrics[TGame] & + ConfOptionalMetrics[TGame] & + ConfProvidedMetrics[TGame]; }; /** @@ -186,10 +181,10 @@ export type AllConfMetrics = { * @usage GetEnumValue<"iidx:SP", "lamp"> = "FAILED" | "ASSIST CLEAR" | "EASY CLEAR" ... */ export type GetEnumValue< - GPT extends GPTString, - MetricName extends ExtractEnumMetricNames, + TGame extends V3Game, + MetricName extends ExtractEnumMetricNames, > = - AllConfMetrics[GPT][MetricName] extends ConfEnumScoreMetric + AllConfMetrics[TGame][MetricName] extends ConfEnumScoreMetric ? EnumValues : never; @@ -220,30 +215,11 @@ export type DerivedMetricValue = number | string | Array | Array; export type MetricDeriver< - GPT extends GPTString, + TGame extends V3Game, // possible return values // from a derived fn V extends DerivedMetricValue = DerivedMetricValue, > = ( - mandatoryMetrics: MongoExtractMetrics, - chart: MONGO_ChartDocument, + mandatoryMetrics: MongoExtractMetrics, + chart: ChartDocument, ) => V; - -/** - * A function that will derive this metric, given a function of other metrics and - * a chart for this GPT. - */ -export type __OLD_KILL_ScoreMetricDeriver = - // graph score metrics correspond to Array - M extends ConfGraphScoreMetric - ? MetricDeriver> - : // nullable graphs correspond to Array - M extends ConfNullableGraphScoreMetric - ? MetricDeriver> - : // enums correspond to their string unions ("FAILED"|"CLEAR") - M extends ConfEnumScoreMetric - ? MetricDeriver - : // the other two are obvious - M extends ConfIntegerScoreMetric - ? MetricDeriver - : MetricDeriver; diff --git a/typescript/common/src/types/notifications.ts b/typescript/common/src/types/notifications.ts index b69fcbdb8..a5ecca013 100644 --- a/typescript/common/src/types/notifications.ts +++ b/typescript/common/src/types/notifications.ts @@ -1,4 +1,4 @@ -import type { GameGroup, integer, Playtype } from "../types"; +import type { integer, V3Game } from "../types"; export interface BaseNotification { title: string; @@ -13,16 +13,14 @@ export interface BaseNotification { export type NotificationBody = | { content: { - game: GameGroup; - playtype: Playtype; + game: V3Game; questID: string; }; type: "QUEST_CHANGED"; // Emitted when a quest the user is subscribed to changed. } | { content: { - game: GameGroup; - playtype: Playtype; + game: V3Game; userID: integer; }; type: "RIVALED_BY"; // Emitted when the user is rivalled by someone. diff --git a/typescript/common/src/utils/util.ts b/typescript/common/src/utils/util.ts index 90f4a949a..d875a6f59 100644 --- a/typescript/common/src/utils/util.ts +++ b/typescript/common/src/utils/util.ts @@ -3,16 +3,15 @@ import type { ZodObject } from "zod"; import type { GradeBoundary, IIDXLikes } from "../constants/grade-boundaries"; import type { + BMSCourseDocument, + BMSGames, + ChartDocument, GameGroup, - GPTString, - GPTStrings, + GamesForGroup, integer, - MONGO_BMSCourseDocument, - MONGO_ChartDocument, - MONGO_SongDocument, - Playtypes, + LEGACY_Playtypes, + SongDocument, V3Game, - V3GameToGPTString, } from "../types"; import type { AllConfMetrics, @@ -22,22 +21,42 @@ import type { } from "../types/metrics"; import { + ALL_GAMES, + GameToGameGroup, + GetGameConfig, GetGameGroupConfig, - GetGamePTConfig, - GetGPTString, - GetSpecificGPTConfig, - SplitGPT, - V3GetGameConfig, - V3ToGPTString, + LEGACY_GameToGameGroupPT, } from "../config/config"; +/** + * Stick this in the "default" branch of switch exprs to statically typecheck that your + * switch is exhaustive. + * + * This works because the argument to this function should be "never" because all of its + * variants should be exhausted. + */ +export function staticAssertUnreachable(_: never): never { + throw new Error(`unreachable (Got ${JSON.stringify(_)})`); +} + export function FormatInt(v: number): string { return Math.floor(v).toFixed(0); } -export function FormatDifficulty(chart: MONGO_ChartDocument, game: GameGroup): string { - if (game === "bms" || game === "pms") { - const bmsChart = chart as MONGO_ChartDocument<"bms:7K" | "bms:14K">; +export function FormatDifficulty(chart: ChartDocument): string { + return FormatDifficultyShort(chart); +} + +/** + * Formats a chart's difficulty into a shorter variant. This handles a lot of + * game-specific strange edge cases. + */ +export function FormatDifficultyShort(chart: ChartDocument): string { + const gameGroup = GameToGameGroup(chart.game); + const gameConfig = GetGameConfig(chart.game); + + if (gameGroup === "bms" || gameGroup === "pms") { + const bmsChart = chart as ChartDocument; return ( Object.entries(bmsChart.data.tableFolders) @@ -45,9 +64,8 @@ export function FormatDifficulty(chart: MONGO_ChartDocument, game: GameGroup): s .join(", ") || "Unrated" ); } - - if (game === "itg") { - const itgChart = chart as MONGO_ChartDocument<"itg:Stamina">; + if (gameGroup === "itg") { + const itgChart = chart as ChartDocument<"itg-stamina">; const level = itgChart.data.rankedLevel ?? itgChart.data.chartLevel; const unranked = itgChart.data.rankedLevel === null ? "UNRANKED " : ""; @@ -55,111 +73,33 @@ export function FormatDifficulty(chart: MONGO_ChartDocument, game: GameGroup): s return `${unranked}${itgChart.data.difficultyTag} ${level} (${itgChart.data.charter})`; } - if (game === "gitadora") { - const ch = chart as MONGO_ChartDocument; + // use the difficulty format there + if (gameConfig.difficulties.type === "FIXED") { + const diff = gameConfig.difficulties.format[chart.difficulty] ?? chart.difficulty; - const gptConfig = GetSpecificGPTConfig( - GetGPTString(game, chart.playtype) as GPTStrings["gitadora"], - ); - - // it's complaining that since the dora config doesn't have shorthand for - // "BASS BASIC", this assignment may fail. - // it's technically correct, but in the worst way, since this isn't - // actually possible. - // todo: come up with something better. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore this fails in my IDE but is fine in the compiler, can't use ts-expect-err here. - const shortDiff = gptConfig.difficulties.shorthand[ch.difficulty]; - - // gitadora should always use short diffs. they just look better. - return `${shortDiff} ${chart.level}`; - } - - const gameConfig = GetGameGroupConfig(game); - - if (gameConfig.playtypes.length > 1) { - return `${chart.playtype} ${chart.difficulty} ${chart.level}`; + return `${diff} ${chart.level}`; } + // TODO cap string length return `${chart.difficulty} ${chart.level}`; } -/** - * Formats a chart's difficulty into a shorter variant. This handles a lot of - * game-specific strange edge cases. - */ -export function FormatDifficultyShort(chart: MONGO_ChartDocument, game: GameGroup): string { - const gameConfig = GetGameGroupConfig(game); - const gptConfig = GetGamePTConfig(game, chart.playtype); - - if (game === "itg") { - const itgChart = chart as MONGO_ChartDocument<"itg:Stamina">; - - return `S${itgChart.data.difficultyTag} ${chart.level}`; - } - - if (gptConfig.difficulties.type === "DYNAMIC") { - // TODO cap string length - return `${chart.difficulty} ${chart.level}`; - } - - const shortDiff = gptConfig.difficulties.shorthand[chart.difficulty] ?? chart.difficulty; - - if (gameConfig.playtypes.length === 1 || game === "gitadora") { - return `${shortDiff} ${chart.level}`; - } - - if (game === "usc") { - return `${chart.playtype === "Controller" ? "CON" : "KB"} ${shortDiff} ${chart.level}`; - } - - return `${chart.playtype}${shortDiff} ${chart.level}`; -} - /** * Formats a chart's difficulty for searching, such as forwarding this query to youtube. */ -export function FormatDifficultySearch(chart: MONGO_ChartDocument, game: GameGroup): string | null { - const gptConfig = GetGamePTConfig(game, chart.playtype); - - if (game === "itg") { - const itgChart = chart as MONGO_ChartDocument<"itg:Stamina">; - - return `S${itgChart.data.difficultyTag} ${chart.level}`; - } - - if (gptConfig.difficulties.type === "DYNAMIC") { - // TODO cap string length - return chart.difficulty; - } - - const shortDiff = gptConfig.difficulties.shorthand[chart.difficulty] ?? chart.difficulty; - - switch (game) { - case "jubeat": - case "maimai": - case "museca": - case "maimaidx": - case "popn": - case "sdvx": - case "wacca": - case "arcaea": - case "ongeki": - case "chunithm": - case "ddr": - case "usc": - return chart.difficulty; - case "iidx": - return `${chart.playtype}${shortDiff}`; - case "gitadora": - return shortDiff; - case "bms": - case "pms": - return null; - } +export function FormatDifficultySearch(chart: ChartDocument): string | null { + return FormatDifficulty(chart); } -export function FormatGameGroup(game: GameGroup, playtype: Playtypes[GameGroup]): string { +export function FormatGame(game: V3Game): string { + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(game); + return LEGACY_FormatGameGroupPT(gameGroup, playtype); +} + +export function LEGACY_FormatGameGroupPT( + game: GameGroup, + playtype: LEGACY_Playtypes[GameGroup], +): string { const gameConfig = GetGameGroupConfig(game); if (gameConfig.playtypes.length === 1) { @@ -173,22 +113,12 @@ export function FormatGameGroup(game: GameGroup, playtype: Playtypes[GameGroup]) return `${gameConfig.name} (${playtype})`; } -export function V3FormatGame(game: V3Game): string { - const [g, p] = SplitGPT(V3ToGPTString(game)); +export function FormatChart(chart: ChartDocument): string { + const gameGroup = GameToGameGroup(chart.game); + if (gameGroup === "bms") { + const tables = (chart as ChartDocument).data.tableFolders; - return FormatGameGroup(g, p); -} - -export function FormatChart( - game: GameGroup, - song: MONGO_SongDocument, - chart: MONGO_ChartDocument, - short = false, -): string { - if (game === "bms") { - const tables = (chart as MONGO_ChartDocument).data.tableFolders; - - const bmsSong = song as MONGO_SongDocument<"bms">; + const bmsSong = chart.song as SongDocument<"bms">; let realTitle = bmsSong.title; @@ -207,13 +137,14 @@ export function FormatChart( return `${realTitle} (${Object.entries(tables) .map(([table, level]) => `${table}${level}`) .join(", ")})`; - } else if (game === "usc") { - const uscChart = chart as MONGO_ChartDocument; + } else if (gameGroup === "usc") { + const uscChart = chart as ChartDocument; + const inputType = chart.game === "usc-controller" ? "Controller" : "Keyboard"; // If this chart isn't an official, render it differently if (!uscChart.data.isOfficial) { // Same as BMS. turn this into SongTitle (Keyboard MXM normal1, insane2) - return `${song.title} (${chart.playtype} ${chart.difficulty} ${Object.entries( + return `${chart.song.title} (${inputType} ${chart.difficulty} ${Object.entries( uscChart.data.tableFolders, ) .map(([table, level]) => `${table}${level}`) @@ -223,7 +154,7 @@ export function FormatChart( // it as so: // SongTitle (Keyboard MXM 17, normal1, insane2) - return `${song.title} (${chart.playtype} ${chart.difficulty} ${ + return `${chart.song.title} (${inputType} ${chart.difficulty} ${ chart.level }, ${Object.entries(uscChart.data.tableFolders) .map(([table, level]) => `${table}${level}`) @@ -231,9 +162,9 @@ export function FormatChart( } // otherwise, it's just an official and should be rendered like any other game. - } else if (game === "itg") { - const itgChart = chart as MONGO_ChartDocument<"itg:Stamina">; - const itgSong = song as MONGO_SongDocument<"itg">; + } else if (gameGroup === "itg") { + const itgChart = chart as ChartDocument; + const itgSong = chart.song as SongDocument<"itg">; const level = itgChart.data.rankedLevel ?? `${itgChart.data.chartLevel}?`; @@ -242,43 +173,23 @@ export function FormatChart( } ${level}`; } - const gameConfig = GetGameGroupConfig(game); - - let playtypeStr = `${chart.playtype}`; - - if (gameConfig.playtypes.length === 1) { - playtypeStr = ""; - } - - const gptConfig = GetGamePTConfig(game, chart.playtype); + const gameConfig = GetGameConfig(chart.game); let diff: string; - if (gptConfig.difficulties.type === "DYNAMIC") { + if (gameConfig.difficulties.type === "DYNAMIC") { diff = chart.difficulty; - } else if (short) { - diff = gptConfig.difficulties.shorthand[chart.difficulty] ?? chart.difficulty; } else { - diff = chart.difficulty; - } - - // iidx formats things like SPA instead of SP A. - // this is a hack, this should be part of the gptConfig, tbh. - let space = ""; - - if ((game === "iidx" && short) || !playtypeStr) { - space = ""; - } else { - space = " "; + diff = gameConfig.difficulties.format[chart.difficulty] ?? chart.difficulty; } // return the most recent version this chart appeared in if it // is not primary. if (!chart.isPrimary) { - return `${song.title} (${playtypeStr}${space}${diff} ${chart.level} ${chart.versions[0]})`; + return `${chart.song.title} (${diff} ${chart.level} ${chart.versions[0]})`; } - return `${song.title} (${playtypeStr}${space}${diff} ${chart.level})`; + return `${chart.song.title} (${diff} ${chart.level})`; } /** @@ -446,10 +357,8 @@ export function GetCloserGradeDelta( return lower; } -export function CreateSongMap( - songs: Array>, -) { - const songMap = new Map>(); +export function CreateSongMap(songs: Array>) { + const songMap = new Map>(); for (const song of songs) { songMap.set(song.id, song); @@ -458,10 +367,8 @@ export function CreateSongMap( return songMap; } -export function CreateChartMap( - charts: Array>, -) { - const chartMap = new Map>(); +export function CreateChartMap(charts: Array>) { + const chartMap = new Map>(); for (const chart of charts) { chartMap.set(chart.chartID, chart); @@ -470,11 +377,6 @@ export function CreateChartMap( return chartMap; } -/** TODO(zk) remove this function */ -export function MongoChartLegacyId(chart: MONGO_ChartDocument): string { - return chart.chartID; -} - /** * Formats a PrudenceError into something a little more readable. * @param err - The prudence error to format. @@ -489,10 +391,10 @@ export function FormatPrError(err: PrudenceError, foreword = "Error"): string { return `${foreword}: ${err.keychain} | ${err.message}${receivedText}.`; } -export function GetBMSCourseIndex(course: MONGO_BMSCourseDocument) { - const gptConf = GetGamePTConfig("bms", course.playtype); +export function GetBMSCourseIndex(course: BMSCourseDocument) { + const gameConfig = GetGameConfig(course.game); - const cls = gptConf.classes[course.set]; + const cls = gameConfig.classes[course.set as keyof typeof gameConfig.classes]; if (!cls) { throw new Error( @@ -549,10 +451,10 @@ export function IIDXLikeGetGrade( } export function EnumIndexToValue< - G extends V3Game, - EV extends ExtractEnumMetricNames, ->(game: G, enumMetric: EV, index: integer): GetEnumValue { - const config = V3GetGameConfig(game); + TGame extends V3Game, + EV extends ExtractEnumMetricNames, +>(game: TGame, enumMetric: EV, index: integer): GetEnumValue { + const config = GetGameConfig(game); let metric = config.providedMetrics[enumMetric]; if (!metric) { @@ -566,8 +468,9 @@ export function EnumIndexToValue< throw new Error(`Invalid enum metric ${enumMetric} for game ${game}.`); } - return (metric as ConfEnumScoreMetric).values[index] as GetEnumValue< - V3GameToGPTString[G], - EV - >; + return (metric as ConfEnumScoreMetric).values[index] as GetEnumValue; +} + +export function IsValidGame(game: string): game is V3Game { + return ALL_GAMES.includes(game as unknown as V3Game); } diff --git a/typescript/common/src/webhooks.ts b/typescript/common/src/webhooks.ts index b747e7a62..af51d772b 100644 --- a/typescript/common/src/webhooks.ts +++ b/typescript/common/src/webhooks.ts @@ -1,5 +1,5 @@ -import type { GoalImportStat, integer, QuestImportStat } from "./types"; -import type { Classes, GameGroup, GPTString, Playtypes } from "./types/game-config"; +import type { GoalImportStat, integer, QuestImportStat, V3Game } from "./types"; +import type { Classes } from "./types/game-config"; /** * An event fired when a users class improves. @@ -7,11 +7,10 @@ import type { Classes, GameGroup, GPTString, Playtypes } from "./types/game-conf export interface WebhookEventClassUpdateV1 { type: "class-update/v1"; content: { - game: GameGroup; + game: V3Game; new: string; old: string | null; - playtype: Playtypes[GameGroup]; - set: Classes[GPTString]; + set: Classes[V3Game]; userID: integer; }; } @@ -22,12 +21,12 @@ export interface WebhookEventClassUpdateV1 { export interface WebhookEventGoalAchievedV1 { type: "goals-achieved/v1"; content: { - game: GameGroup; + game: V3Game; goals: Array<{ + game: V3Game; goalID: string; new: GoalImportStat; old: GoalImportStat; - playtype: Playtypes[GameGroup]; }>; userID: integer; }; @@ -39,10 +38,9 @@ export interface WebhookEventGoalAchievedV1 { export interface WebhookEventQuestAchievedV1 { type: "quest-achieved/v1"; content: { - game: GameGroup; + game: V3Game; new: QuestImportStat; old: QuestImportStat; - playtype: Playtypes[GameGroup]; questID: string; userID: integer; }; diff --git a/typescript/common/vitest.config.ts b/typescript/common/vitest.config.ts index 4e3d90768..8b533ee50 100644 --- a/typescript/common/vitest.config.ts +++ b/typescript/common/vitest.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + passWithNoTests: true, + env: { NODE_ENV: "test", }, @@ -10,7 +12,9 @@ export default defineConfig({ "build/**", ], coverage: { + enabled: true, provider: "v8", + reporter: ["text", "html", "clover", "json", "lcov"], include: ["src/**/*.ts"], exclude: ["src/**/*.test.ts", "build/**"], }, diff --git a/typescript/coverage-tools/src/manifest.ts b/typescript/coverage-tools/src/manifest.ts index 34dabbfee..37d9eb4cd 100644 --- a/typescript/coverage-tools/src/manifest.ts +++ b/typescript/coverage-tools/src/manifest.ts @@ -1,6 +1,6 @@ /** * Registry of Vitest packages that emit `coverage/coverage-final.json` when tests - * run with `--coverage` (see Justfile-test `test-typescript`). + * run (coverage is enabled in each package’s `vitest.config`; see Justfile-test `test-typescript`). * * When you add a new workspace with Vitest + v8 coverage, append an entry here * and (if needed) point `coverageFinal` at that package’s reportsDirectory. @@ -35,4 +35,14 @@ export const COVERAGE_SOURCES: readonly CoverageSource[] = [ packageRoot: "typescript/common", coverageFinal: "typescript/common/coverage/coverage-final.json", }, + { + id: "db", + packageRoot: "typescript/db", + coverageFinal: "typescript/db/coverage/coverage-final.json", + }, + { + id: "sieglinde", + packageRoot: "typescript/sieglinde", + coverageFinal: "typescript/sieglinde/coverage/coverage-final.json", + }, ] as const; diff --git a/typescript/db/package.json b/typescript/db/package.json index 768cf2c1e..e62343326 100644 --- a/typescript/db/package.json +++ b/typescript/db/package.json @@ -9,7 +9,8 @@ "build": "tsgo -b", "start": "tsgo -b && node js/main.js", "typecheck": "tsgo --noEmit", - "bench": "vitest bench --passWithNoTests" + "test": "vitest", + "bench": "vitest bench" }, "dependencies": { "kysely": "catalog:" diff --git a/typescript/db/src/generated/public/BmsCourseLookup.ts b/typescript/db/src/generated/public/BmsCourseLookup.ts index 47fb35712..46d79fafb 100644 --- a/typescript/db/src/generated/public/BmsCourseLookup.ts +++ b/typescript/db/src/generated/public/BmsCourseLookup.ts @@ -1,6 +1,7 @@ // @generated // This file is automatically generated by Kanel. Do not modify manually. +import type { default as Game } from './Game'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Identifier type for public.bms_course_lookup */ @@ -14,7 +15,7 @@ export default interface BmsCourseLookupTable { set: ColumnType; - playtype: ColumnType; + game: ColumnType; value: ColumnType; } diff --git a/typescript/db/src/generated/public/Folder.ts b/typescript/db/src/generated/public/Folder.ts index 42c6d8a9d..2de0e183c 100644 --- a/typescript/db/src/generated/public/Folder.ts +++ b/typescript/db/src/generated/public/Folder.ts @@ -19,7 +19,7 @@ export default interface FolderTable { title: ColumnType; - slug: ColumnType; + slug: ColumnType; where: ColumnType; diff --git a/typescript/db/src/generated/public/TableFolder.ts b/typescript/db/src/generated/public/TableFolder.ts index 50400eb3a..3f00fe96c 100644 --- a/typescript/db/src/generated/public/TableFolder.ts +++ b/typescript/db/src/generated/public/TableFolder.ts @@ -10,6 +10,8 @@ export default interface TableFolderTable { table_id: ColumnType; folder_id: ColumnType; + + ordering: ColumnType; } export type TableFolder = Selectable; diff --git a/typescript/db/src/placeholder.test.ts b/typescript/db/src/placeholder.test.ts deleted file mode 100644 index 42c66ea5e..000000000 --- a/typescript/db/src/placeholder.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// vitest requires that there is at least one test file in a project. -import { assert, describe, it } from "vitest"; - -describe("placeholder", () => { - it("should placehold", () => { - assert(true); - }); -}); diff --git a/typescript/db/vitest.config.ts b/typescript/db/vitest.config.ts new file mode 100644 index 000000000..6f29010e6 --- /dev/null +++ b/typescript/db/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + passWithNoTests: true, + + coverage: { + enabled: true, + provider: "v8", + reporter: ["text", "html", "clover", "json", "lcov"], + // Hand-written surface only; generated Kysely types are huge and not meaningfully coverable. + include: ["src/index.ts"], + }, + }, +}); diff --git a/typescript/docs-autogen-scripts/src/main.ts b/typescript/docs-autogen-scripts/src/main.ts index 134ab5c73..ddd6438bc 100644 --- a/typescript/docs-autogen-scripts/src/main.ts +++ b/typescript/docs-autogen-scripts/src/main.ts @@ -5,13 +5,11 @@ import type { ConfScoreMetric } from "tachi-common/types/metrics"; import { writeFileSync } from "fs"; import path from "path"; import { - allSupportedGameGroups, - FormatGameGroup, - type GameGroup, - GetGameGroupConfig, - GetGPTConfig, - GetGPTString, - type Playtypes, + ALL_GAMES, + FormatGame, + GetGameConfig, + LEGACY_GameToGPTString, + type V3Game, } from "tachi-common"; import { type ClassConfig, @@ -116,11 +114,11 @@ function formatVersions(versions: Record): string { return tbl; } -function createConfigDocumentation(game: GameGroup, playtype: Playtypes[GameGroup]) { - const gptString = GetGPTString(game, playtype); - const gptConfig = GetGPTConfig(gptString); +function createConfigDocumentation(game: V3Game) { + const gptString = LEGACY_GameToGPTString(game); + const gameConfig = GetGameConfig(game); - const output = `# ${FormatGameGroup(game, playtype)} Support + const output = `# ${FormatGame(game)} Support This game has the internal GPTString of \`${gptString}\`. @@ -133,51 +131,51 @@ For more information on what metrics are and how they work, see [TODO]! ### Provided Metrics -${metricsToTbl(gptConfig.providedMetrics)} +${metricsToTbl(gameConfig.providedMetrics)} ### Derived Metrics -${metricsToTbl(gptConfig.derivedMetrics)} +${metricsToTbl(gameConfig.derivedMetrics)} ### Optional Metrics -${metricsToTbl(gptConfig.optionalMetrics)} +${metricsToTbl(gameConfig.optionalMetrics)} ## Judgements The following judgements are defined: -${stringArrToList(gptConfig.orderedJudgements)} +${stringArrToList(gameConfig.orderedJudgements)} ## Rating Algorithms ### Score Rating Algorithms -${formatRatings(gptConfig.scoreRatingAlgs, gptConfig.defaultScoreRatingAlg)} +${formatRatings(gameConfig.scoreRatingAlgs, gameConfig.defaultScoreRatingAlg)} ### Session Rating Algorithms -${formatRatings(gptConfig.sessionRatingAlgs, gptConfig.defaultSessionRatingAlg)} +${formatRatings(gameConfig.sessionRatingAlgs, gameConfig.defaultSessionRatingAlg)} ### Profile Rating Algorithms -${formatRatings(gptConfig.profileRatingAlgs, gptConfig.defaultProfileRatingAlg)} +${formatRatings(gameConfig.profileRatingAlgs, gameConfig.defaultProfileRatingAlg)} ## Difficulties -${formatDifficulties(gptConfig.difficulties)} +${formatDifficulties(gameConfig.difficulties)} ## Classes -${formatClasses(gptConfig.classes)} +${formatClasses(gameConfig.classes)} ## Versions -${formatVersions(gptConfig.versions)} +${formatVersions(gameConfig.versions)} ## Supported Match Types -${stringArrToList(gptConfig.supportedMatchTypes)}`; +${stringArrToList(gameConfig.supportedMatchTypes)}`; return output; } @@ -186,17 +184,10 @@ const baseDir = path.join(__filename, "../../../docs/game-support/games"); let mkdocsConf = "- Game Information:"; -for (const game of allSupportedGameGroups) { - const gameConfig = GetGameGroupConfig(game); +for (const game of ALL_GAMES) { + writeFileSync(path.join(baseDir, `${game}.md`), createConfigDocumentation(game)); - for (const playtype of gameConfig.playtypes) { - writeFileSync( - path.join(baseDir, `${game}-${playtype}.md`), - createConfigDocumentation(game, playtype), - ); - - mkdocsConf += `\n - "game-support/games/${game}-${playtype}.md"`; - } + mkdocsConf += `\n - "game-support/games/${game}.md"`; } console.error("Done! Paste this config into your mkdocs.yml."); diff --git a/typescript/eslint-config/index.js b/typescript/eslint-config/index.js index b0c2c9a36..c882b261d 100644 --- a/typescript/eslint-config/index.js +++ b/typescript/eslint-config/index.js @@ -1,26 +1,37 @@ import eslint from "@eslint/js"; import configPrettier from "eslint-config-prettier"; import pluginImport from "eslint-plugin-import"; +import pluginTachiImports from "./rules/tachi-imports-plugin.js"; import pluginJsxA11y from "eslint-plugin-jsx-a11y"; import pluginPerfectionist from "eslint-plugin-perfectionist"; import pluginReact from "eslint-plugin-react"; import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginReactRefresh from "eslint-plugin-react-refresh"; +import pluginUnusedImports from "eslint-plugin-unused-imports"; import globals from "globals"; import tseslint from "typescript-eslint"; +const unusedVarsOptions = { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + ignoreRestSiblings: true, + varsIgnorePattern: "^_", +}; + const base = tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, pluginPerfectionist.configs["recommended-natural"], - { - ignores: ["**/*.oldtest.ts"], - }, { name: "zenith/base", files: ["**/*.{ts,tsx}"], plugins: { import: pluginImport, + tachi: pluginTachiImports, + "unused-imports": pluginUnusedImports, }, languageOptions: { ecmaVersion: 2022, @@ -44,18 +55,9 @@ const base = tseslint.config( "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - args: "all", - argsIgnorePattern: "^_", - caughtErrors: "all", - caughtErrorsIgnorePattern: "^_", - destructuredArrayIgnorePattern: "^_", - ignoreRestSiblings: true, - varsIgnorePattern: "^_", - }, - ], + "@typescript-eslint/no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": ["warn", unusedVarsOptions], "perfectionist/sort-objects": "off", "perfectionist/sort-union-types": [ "error", @@ -122,6 +124,10 @@ const base = tseslint.config( "require-unicode-regexp": "error", "space-before-blocks": "error", yoda: "error", + // import/extensions resolves the on-disk file and compares that extension (.ts vs a + // written .js), so it often misses redundant `.js` in TypeScript source. + "tachi/no-redundant-js-extension": "error", + "tachi/prefer-hash-import": "error", // Seems to be broken "import/no-unresolved": "off", "@typescript-eslint/no-explicit-any": "warn", diff --git a/typescript/eslint-config/package.json b/typescript/eslint-config/package.json index a70e684a3..36e57310c 100644 --- a/typescript/eslint-config/package.json +++ b/typescript/eslint-config/package.json @@ -21,6 +21,7 @@ "eslint-config-prettier": "catalog:", "eslint-import-resolver-typescript": "catalog:", "eslint-plugin-import": "catalog:", + "eslint-plugin-unused-imports": "catalog:", "eslint-plugin-jsx-a11y": "catalog:", "eslint-plugin-perfectionist": "catalog:", "eslint-plugin-react": "catalog:", diff --git a/typescript/eslint-config/rules/tachi-imports-plugin.js b/typescript/eslint-config/rules/tachi-imports-plugin.js new file mode 100644 index 000000000..1910e0c4e --- /dev/null +++ b/typescript/eslint-config/rules/tachi-imports-plugin.js @@ -0,0 +1,303 @@ +/** @import { Rule } from "eslint" */ + +import fs from "node:fs"; +import path from "node:path"; + +const MESSAGE_NO_REDUNDANT_JS = "noRedundantJsExtension"; +const MESSAGE_PREFER_HASH = "preferHashImport"; + +/** @type {Map} */ +const packageHashSrcCache = new Map(); + +/** + * Strips a trailing `.js` from the path portion (before `?`), if present. + * @param {string} specifier + * @returns {string | null} The rewritten specifier, or null if unchanged. + */ +function stripRedundantJs(specifier) { + const q = specifier.indexOf("?"); + const pathPart = q === -1 ? specifier : specifier.slice(0, q); + if (!pathPart.endsWith(".js")) { + return null; + } + const base = pathPart.slice(0, -3); + return q === -1 ? base : base + specifier.slice(q); +} + +/** + * @param {string} specifier + */ +function shouldLintSpecifier(specifier) { + return specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("#"); +} + +/** + * @param {import("eslint").Rule.RuleContext} context + * @param {import("estree").Literal | null | undefined} sourceNode + */ +function checkRedundantJs(context, sourceNode) { + if (!sourceNode || sourceNode.type !== "Literal") { + return; + } + const raw = sourceNode.value; + if (typeof raw !== "string" || !shouldLintSpecifier(raw)) { + return; + } + const fixed = stripRedundantJs(raw); + if (fixed === null) { + return; + } + context.report({ + node: sourceNode, + messageId: MESSAGE_NO_REDUNDANT_JS, + fix(fixer) { + const quote = sourceNode.raw[0]; + return fixer.replaceText(sourceNode, `${quote}${fixed}${quote}`); + }, + }); +} + +/** + * @param {string} startDir + * @returns {string | null} + */ +function findPackageRoot(startDir) { + let dir = startDir; + for (;;) { + const pkg = path.join(dir, "package.json"); + if (fs.existsSync(pkg)) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +/** + * @param {string} packageRoot + */ +function packageHasHashSrcMapping(packageRoot) { + const cached = packageHashSrcCache.get(packageRoot); + if (cached !== undefined) { + return cached; + } + const tsconfigPath = path.join(packageRoot, "tsconfig.json"); + if (!fs.existsSync(tsconfigPath)) { + packageHashSrcCache.set(packageRoot, false); + return false; + } + let data; + try { + data = JSON.parse(fs.readFileSync(tsconfigPath, "utf8")); + } catch { + packageHashSrcCache.set(packageRoot, false); + return false; + } + const star = data?.compilerOptions?.paths?.["#*"]; + const ok = Array.isArray(star) && star.length === 1 && star[0] === "./src/*"; + packageHashSrcCache.set(packageRoot, ok); + return ok; +} + +/** + * @param {string} specifier + */ +function countLeadingDotDotSegments(specifier) { + const q = specifier.indexOf("?"); + const pathPart = q === -1 ? specifier : specifier.slice(0, q); + const normalized = path.posix.normalize(pathPart.replace(/\\/g, "/")); + const parts = normalized.split("/").filter((p) => p.length > 0); + let i = 0; + while (i < parts.length && parts[i] === "..") { + i++; + } + return i; +} + +/** + * @param {string} fromDir + * @param {string} specifier + * @returns {string | null} + */ +function resolveImportToFile(fromDir, specifier) { + const q = specifier.indexOf("?"); + const pathPart = q === -1 ? specifier : specifier.slice(0, q); + let resolved = path.resolve(fromDir, pathPart); + + if (fs.existsSync(resolved)) { + const st = fs.statSync(resolved); + if (st.isFile()) { + return resolved; + } + if (st.isDirectory()) { + const indexExts = [".ts", ".tsx", ".js", ".mjs", ".cjs"]; + for (const ext of indexExts) { + const indexFile = path.join(resolved, `index${ext}`); + if (fs.existsSync(indexFile) && fs.statSync(indexFile).isFile()) { + return indexFile; + } + } + } + } + + const baseExts = [".ts", ".tsx", ".js", ".mjs", ".cjs"]; + for (const e of baseExts) { + const p = resolved + e; + if (fs.existsSync(p) && fs.statSync(p).isFile()) { + return p; + } + } + + return null; +} + +/** + * @param {string} packageRoot + * @param {string} absoluteFile + * @returns {string | null} + */ +function absoluteFileToHashSpecifier(packageRoot, absoluteFile) { + const srcRoot = path.join(packageRoot, "src"); + const relToSrc = path.relative(srcRoot, absoluteFile); + if (relToSrc.startsWith("..") || path.isAbsolute(relToSrc)) { + return null; + } + let posix = relToSrc.split(path.sep).join("/"); + posix = posix.replace(/\.(tsx?|jsx?|mjs|cjs)$/, ""); + return `#${posix}`; +} + +/** + * @param {import("eslint").Rule.RuleContext} context + * @param {import("estree").Literal | null | undefined} sourceNode + */ +function checkPreferHash(context, sourceNode) { + if (!sourceNode || sourceNode.type !== "Literal") { + return; + } + const raw = sourceNode.value; + if (typeof raw !== "string" || raw.startsWith("#")) { + return; + } + if (!raw.startsWith(".")) { + return; + } + if (countLeadingDotDotSegments(raw) < 2) { + return; + } + + const currentFile = context.getFilename(); + if (currentFile === "" || !path.isAbsolute(currentFile)) { + return; + } + + const packageRoot = findPackageRoot(path.dirname(currentFile)); + if (!packageRoot || !packageHasHashSrcMapping(packageRoot)) { + return; + } + + const fromDir = path.dirname(currentFile); + const resolved = resolveImportToFile(fromDir, raw); + if (!resolved) { + return; + } + + const newSpec = absoluteFileToHashSpecifier(packageRoot, resolved); + if (!newSpec) { + return; + } + + const q = raw.indexOf("?"); + const suffix = q === -1 ? "" : raw.slice(q); + const fixed = newSpec + suffix; + + context.report({ + node: sourceNode, + messageId: MESSAGE_PREFER_HASH, + fix(fixer) { + const quote = sourceNode.raw[0]; + return fixer.replaceText(sourceNode, `${quote}${fixed}${quote}`); + }, + }); +} + +/** @type {Rule.RuleModule} */ +export const noRedundantJsExtension = { + meta: { + type: "suggestion", + docs: { + description: + "Disallow redundant `.js` extensions in relative and `#` path import specifiers.", + }, + fixable: "code", + schema: [], + messages: { + [MESSAGE_NO_REDUNDANT_JS]: + "Remove the redundant .js extension from this import path - just import it without an extension", + }, + }, + create(context) { + return { + ImportDeclaration(node) { + checkRedundantJs(context, node.source); + }, + ExportNamedDeclaration(node) { + if (node.source) { + checkRedundantJs(context, node.source); + } + }, + ExportAllDeclaration(node) { + checkRedundantJs(context, node.source); + }, + ImportExpression(node) { + checkRedundantJs(context, node.source); + }, + }; + }, +}; + +/** @type {Rule.RuleModule} */ +export const preferHashImport = { + meta: { + type: "suggestion", + docs: { + description: + "Prefer `#src/...` path aliases over deep relative imports (at least two `../` segments).", + }, + fixable: "code", + schema: [], + messages: { + [MESSAGE_PREFER_HASH]: + "Use a `#...` import path instead of a deep relative path (../../...)", + }, + }, + create(context) { + return { + ImportDeclaration(node) { + checkPreferHash(context, node.source); + }, + ExportNamedDeclaration(node) { + if (node.source) { + checkPreferHash(context, node.source); + } + }, + ExportAllDeclaration(node) { + checkPreferHash(context, node.source); + }, + ImportExpression(node) { + checkPreferHash(context, node.source); + }, + }; + }, +}; + +export default { + meta: { name: "eslint-plugin-tachi-imports", version: "1.0.0" }, + rules: { + "no-redundant-js-extension": noRedundantJsExtension, + "prefer-hash-import": preferHashImport, + }, +}; diff --git a/typescript/rg-stats/package.json b/typescript/rg-stats/package.json index 7518af7e6..c7273f5e2 100644 --- a/typescript/rg-stats/package.json +++ b/typescript/rg-stats/package.json @@ -13,7 +13,7 @@ } }, "scripts": { - "test": "vitest run --coverage", + "test": "vitest run", "snapshot": "vitest run -u", "snap": "vitest run -u", "build": "tsgo -p tsconfig.build.json", diff --git a/typescript/rg-stats/vitest.config.ts b/typescript/rg-stats/vitest.config.ts index 6f48a0175..fcf2f917a 100644 --- a/typescript/rg-stats/vitest.config.ts +++ b/typescript/rg-stats/vitest.config.ts @@ -6,11 +6,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ test: { + passWithNoTests: true, + env: { NODE_ENV: "test", }, coverage: { + enabled: true, provider: "v8", + reporter: ["text", "html", "clover", "json", "lcov"], include: ["src/**/*.ts"], exclude: [ "src/**/*.test.ts", diff --git a/typescript/seeds-scripts/finders.js b/typescript/seeds-scripts/finders.js index 595645592..c8684236b 100644 --- a/typescript/seeds-scripts/finders.js +++ b/typescript/seeds-scripts/finders.js @@ -1,7 +1,7 @@ function FindChartWithPTDFVersion(collection, songID, playtype, difficulty, version) { return collection.find( (chart) => - chart.songID === songID && + chart.song.id === songID && chart.playtype === playtype && chart.difficulty === difficulty && chart.versions.includes(version), diff --git a/typescript/seeds-scripts/rerunners/arcaea/merge-songlist.ts b/typescript/seeds-scripts/rerunners/arcaea/merge-songlist.ts index bd2446645..0bc097cd0 100644 --- a/typescript/seeds-scripts/rerunners/arcaea/merge-songlist.ts +++ b/typescript/seeds-scripts/rerunners/arcaea/merge-songlist.ts @@ -2,13 +2,18 @@ import { Command } from "commander"; import fjsh from "fast-json-stable-hash"; import fs from "fs"; import { + type ChartDocument, type Difficulties, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, + type SongDocument, } from "tachi-common"; -import { CreateChartID, ReadCollection, WriteCollection } from "../../util"; +import { + CreateChartID, + GetFreshSongIDGenerator, + ReadCollection, + WriteCollection, +} from "../../util"; type LocalizedText = { en: string } & Record; type LocalizedSearchTerms = Record>; @@ -85,7 +90,7 @@ class MultiMapUniqueValues { } } -function convertDifficulty(input: integer): Difficulties["arcaea:Touch"] { +function convertDifficulty(input: integer): Difficulties["arcaea"] { switch (input) { case 0: return "Past"; @@ -142,31 +147,30 @@ const packlistContent = fs.readFileSync(options.packlist, { encoding: "utf-8" }) const packlistData: { packs: Array } = JSON.parse(packlistContent); const packsByID = Object.fromEntries(packlistData.packs.map((p) => [p.id, p])); -const existingSongDocsById: Map> = new Map( - ReadCollection("songs-arcaea.json").map((e: MONGO_SongDocument<"arcaea">) => [e.id, e]), +const existingSongDocsById: Map> = new Map( + ReadCollection("songs-arcaea.json").map((e: SongDocument<"arcaea">) => [e.id, e]), ); -const existingChartDocs: Array> = - ReadCollection("charts-arcaea.json"); +const existingChartDocs: Array> = ReadCollection("charts-arcaea.json"); const inGameIDToSongsMap: MultiMapUniqueValues< string, - MONGO_SongDocument<"arcaea"> + SongDocument<"arcaea"> > = new MultiMapUniqueValues(); -const existingCharts: Map> = new Map(); +const existingCharts: Map> = new Map(); for (const chart of existingChartDocs) { - const song = existingSongDocsById.get(chart.songID); + const song = existingSongDocsById.get(chart.song.id); if (!song) { - console.warn(`Chart ${chart.songID} does not belong to any song?`); + console.warn(`Chart ${chart.song.id} does not belong to any song?`); continue; } - inGameIDToSongsMap.set(chart.data.inGameID, song); - existingCharts.set(`${chart.data.inGameID}-${chart.difficulty}`, chart); + inGameIDToSongsMap.set(chart.data.inGameStrID, song); + existingCharts.set(`${chart.data.inGameStrID}-${chart.difficulty}`, chart); } const getNewSongID = GetFreshSongIDGenerator("arcaea"); -const newSongs: Array> = []; -const newCharts: Array> = []; +const newSongs: Array> = []; +const newCharts: Array> = []; for (const entry of data.songs) { const inGameID = entry.id; @@ -186,7 +190,7 @@ for (const entry of data.songs) { ...new Set(Object.values(entry.title_localized).filter((t) => t !== title)), ]; - const songDoc: MONGO_SongDocument<"arcaea"> = { + const songDoc: SongDocument<"arcaea"> = { title, artist: entry.artist, altTitles, @@ -204,7 +208,7 @@ for (const entry of data.songs) { } for (const chart of entry.difficulties) { - let song: MONGO_SongDocument<"arcaea">; + let song: SongDocument<"arcaea">; if (chart.hidden_until_unlocked && chart.hidden_until === "always") { // Deactivated difficulty @@ -238,7 +242,7 @@ for (const entry of data.songs) { ...new Set(Object.values(chart.title_localized).filter((t) => t !== title)), ]; - const songDoc: MONGO_SongDocument<"arcaea"> = { + const songDoc: SongDocument<"arcaea"> = { title, artist: chart.artist ?? entry.artist, altTitles, @@ -265,7 +269,8 @@ for (const entry of data.songs) { song = possibleSong; } - const chartDoc: MONGO_ChartDocument<"arcaea:Touch"> = { + const chartDoc: ChartDocument<"arcaea"> = { + game: "arcaea", chartID: CreateChartID(), songID: song.id, difficulty, @@ -275,7 +280,7 @@ for (const entry of data.songs) { versions: ["mobile"], playtype: "Touch", data: { - inGameID, + inGameStrID: inGameID, // Filled in later, but not by this script notecount: 0, }, diff --git a/typescript/seeds-scripts/rerunners/bms-pms/add-beatoraja-courses.js b/typescript/seeds-scripts/rerunners/bms-pms/add-beatoraja-courses.js index cfc47785f..d5d2c9d2a 100644 --- a/typescript/seeds-scripts/rerunners/bms-pms/add-beatoraja-courses.js +++ b/typescript/seeds-scripts/rerunners/bms-pms/add-beatoraja-courses.js @@ -37,7 +37,7 @@ if ( courses.push({ md5sums, - playtype: options.playtype, + game: options.playtype === "7K" ? "bms-7k" : "bms-14k", set: options.set, title: d.name, value: Number(options.index) + i, diff --git a/typescript/seeds-scripts/rerunners/bms-pms/add-lr2crs-file.js b/typescript/seeds-scripts/rerunners/bms-pms/add-lr2crs-file.js index 602e39950..18c3a8755 100644 --- a/typescript/seeds-scripts/rerunners/bms-pms/add-lr2crs-file.js +++ b/typescript/seeds-scripts/rerunners/bms-pms/add-lr2crs-file.js @@ -47,7 +47,7 @@ MutateCollection("bms-course-lookup.json", (courses) => { courses.push({ md5sums, - playtype: options.playtype, + game: options.playtype === "7K" ? "bms-7k" : "bms-14k", set: options.set, title: d.title, value: dans[i].id, diff --git a/typescript/seeds-scripts/rerunners/bms-pms/sync-bms-tablefolders.ts b/typescript/seeds-scripts/rerunners/bms-pms/sync-bms-tablefolders.ts index d5af51971..fcc97d880 100644 --- a/typescript/seeds-scripts/rerunners/bms-pms/sync-bms-tablefolders.ts +++ b/typescript/seeds-scripts/rerunners/bms-pms/sync-bms-tablefolders.ts @@ -3,17 +3,17 @@ import { LoadBMSTable } from "bms-table-loader"; import { BMS_TABLES, type BMSTableInfo, - type MONGO_FolderDocument, - type MONGO_TableDocument, + type FolderDocument, + type TableDocument, } from "tachi-common"; -import { MutateCollection, ReadCollection } from "../../util"; +import { CreateLegacyFolderIDFromFolder, MutateCollection, ReadCollection } from "../../util"; const existsTables = ReadCollection("tables.json").map((e) => e.tableID); const existsFolders = ReadCollection("folders.json").map((e) => e.folderID); async function UpdateTable(tableInfo: BMSTableInfo) { - const tableID = `bms-${tableInfo.playtype}-${tableInfo.asciiPrefix}`; + const tableID = `bms-${tableInfo.game}-${tableInfo.asciiPrefix}`; if (existsTables.includes(tableID)) { return; @@ -25,12 +25,12 @@ async function UpdateTable(tableInfo: BMSTableInfo) { const levels = table.getLevelOrder(); - const folders: Array = []; + const folders: Array = []; for (const level of levels) { - const f: Omit = { + const f: Omit = { title: `${tableInfo.prefix}${level}`, - playtype: tableInfo.playtype, + playtype: tableInfo.game === "bms-7k" ? "7K" : "14K", game: "bms", searchTerms: [], type: "charts", @@ -45,12 +45,12 @@ async function UpdateTable(tableInfo: BMSTableInfo) { inactive: false, }; - const folderID = CreateFolderIDFromFolder(f); + const folderID = CreateLegacyFolderIDFromFolder(f); const realFolder = { ...f, folderID, - } as MONGO_FolderDocument; + } as FolderDocument; if (existsFolders.includes(folderID)) { continue; @@ -66,12 +66,12 @@ async function UpdateTable(tableInfo: BMSTableInfo) { return f; }); - MutateCollection("tables.json", (t: Array) => { + MutateCollection("tables.json", (t: Array) => { t.push({ folders: folders.map((e) => e.folderID), game: "bms", default: false, - playtype: tableInfo.playtype, + playtype: tableInfo.game === "bms-7k" ? "7K" : "14K", inactive: false, description: tableInfo.description, title: tableInfo.name, @@ -86,7 +86,7 @@ async function UpdateTable(tableInfo: BMSTableInfo) { const f = { title: tableInfo.name, - playtype: tableInfo.playtype, + playtype: tableInfo.game === "bms-7k" ? "7K" : "14K", game: "bms", searchTerms: [tableInfo.asciiPrefix], type: "charts", @@ -96,18 +96,18 @@ async function UpdateTable(tableInfo: BMSTableInfo) { inactive: false, }; - const folderID = CreateFolderIDFromFolder(f); + const folderID = CreateLegacyFolderIDFromFolder(f); const realFolder = { ...f, folderID, - } as MONGO_FolderDocument; + } as FolderDocument; // add this to meta table. if (!existsFolders.includes(folderID)) { MutateCollection("tables.json", (tables) => { for (const table of tables) { - if (table.tableID === `bms-${tableInfo.playtype}-meta`) { + if (table.tableID === `bms-${tableInfo.game}-meta`) { table.folders.push(folderID); } } diff --git a/typescript/seeds-scripts/rerunners/chunithm/merge-options.ts b/typescript/seeds-scripts/rerunners/chunithm/merge-options.ts index 93e8d6ce3..1018b7ee0 100644 --- a/typescript/seeds-scripts/rerunners/chunithm/merge-options.ts +++ b/typescript/seeds-scripts/rerunners/chunithm/merge-options.ts @@ -3,11 +3,11 @@ import { XMLParser } from "fast-xml-parser"; import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import path from "path"; import { + type ChartDocument, type Difficulties, - GetGamePTConfig, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, + LEGACY_GetGamePTConfig, + type SongDocument, } from "tachi-common"; import { log } from "../../log"; @@ -99,7 +99,7 @@ const program = new Command() const options = program.opts(); const baseVersion = options.version.replace(/(-intl|-omni)$/u, ""); -const tachiVersions = Object.keys(GetGamePTConfig("chunithm", "Single").versions); +const tachiVersions = Object.keys(LEGACY_GetGamePTConfig("chunithm", "Single").versions); if (!VERSIONS.includes(baseVersion)) { throw new Error( @@ -120,18 +120,16 @@ if (!tachiVersions.includes(options.version)) { const isOmnimixVersion = /-omni$/u.test(options.version); const isLatestVersion = VERSIONS.indexOf(baseVersion) === VERSIONS.length - 1; -const existingSongDocs: Array> = - ReadCollection("songs-chunithm.json"); -const existingChartDocs: Array> = - ReadCollection("charts-chunithm.json"); +const existingSongDocs: Array> = ReadCollection("songs-chunithm.json"); +const existingChartDocs: Array> = ReadCollection("charts-chunithm.json"); const songMap = new Map(existingSongDocs.map((s) => [s.id, s])); const songTitleMap = new Map(existingSongDocs.map((s) => [s.title, s])); const inGameIDToSongIDMap = new Map(); -const existingCharts = new Map>(); +const existingCharts = new Map>(); for (const chart of existingChartDocs) { - inGameIDToSongIDMap.set(chart.data.inGameID, chart.songID); + inGameIDToSongIDMap.set(chart.data.inGameID, chart.song.id); existingCharts.set(`${chart.data.inGameID}-${chart.difficulty}`, chart); } @@ -145,8 +143,8 @@ const parser = new XMLParser({ }, }); -const newSongs: Array> = []; -const newCharts: Array> = []; +const newSongs: Array> = []; +const newCharts: Array> = []; for (const optionsDir of options.input) { for (const option of readdirSync(optionsDir)) { @@ -253,7 +251,7 @@ for (const optionsDir of options.input) { ); } - const songDoc: MONGO_SongDocument<"chunithm"> = { + const songDoc: SongDocument<"chunithm"> = { title: musicData.name.str, altTitles: [], searchTerms: [], @@ -349,10 +347,11 @@ for (const optionsDir of options.input) { continue; } - const chartDoc: MONGO_ChartDocument<"chunithm:Single"> = { + const chartDoc: ChartDocument<"chunithm"> = { + game: "chunithm", chartID: CreateChartID(), songID: tachiSongID, - difficulty: difficultyName as Difficulties["chunithm:Single"], + difficulty: difficultyName as Difficulties["chunithm"], isPrimary: true, level, levelNum, diff --git a/typescript/seeds-scripts/rerunners/chunithm/parse-chunithm-dataset.ts b/typescript/seeds-scripts/rerunners/chunithm/parse-chunithm-dataset.ts index 54ea98a71..44cba6663 100644 --- a/typescript/seeds-scripts/rerunners/chunithm/parse-chunithm-dataset.ts +++ b/typescript/seeds-scripts/rerunners/chunithm/parse-chunithm-dataset.ts @@ -1,5 +1,5 @@ import fetch from "node-fetch"; -import { type Difficulties, type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, type Difficulties, type SongDocument } from "tachi-common"; import { CreateChartID, MutateCollection, ReadCollection, WriteCollection } from "../../util"; @@ -14,7 +14,7 @@ if (!CHUNIREC_TOKEN) { type ShortDifficultyNames = "adv" | "bas" | "exp" | "mas" | "ult"; -const DIFFICULTY_MAP = new Map([ +const DIFFICULTY_MAP = new Map([ ["adv", "ADVANCED"], ["bas", "BASIC"], ["exp", "EXPERT"], @@ -138,24 +138,24 @@ function releaseDateToVersion(date: Date): string { } (async () => { - const chunithmSongs: ChunithmSong[] = await fetch(DATA_URL, {}).then((r: Response) => r.json()); + const chunithmSongs: ChunithmSong[] = await fetch(DATA_URL, {}).then((r) => r.json()); const chunirecSongs: ChunirecSong[] = await fetch( `https://api.chunirec.net/2.0/music/showall.json?token=${CHUNIREC_TOKEN}®ion=jp2`, {}, - ).then((r: Response) => r.json()); + ).then((r) => r.json()); const existingChartDocs = ReadCollection("charts-chunithm.json"); const inGameIDToSongIDMap = new Map(); - const existingCharts = new Map>(); + const existingCharts = new Map>(); for (const chart of existingChartDocs) { - inGameIDToSongIDMap.set(chart.data.inGameID, chart.songID); + inGameIDToSongIDMap.set(chart.data.inGameID, chart.song.id); existingCharts.set(`${chart.data.inGameID}-${chart.difficulty}`, chart); } - const newSongs: Array> = []; - const newCharts: Array> = []; + const newSongs: Array> = []; + const newCharts: Array> = []; for (const chunithmSong of chunithmSongs) { const inGameID = Number(chunithmSong.id); @@ -224,6 +224,7 @@ function releaseDateToVersion(date: Date): string { } newCharts.push({ + game: "chunithm" as const, chartID: CreateChartID(), data: { inGameID, @@ -239,7 +240,7 @@ function releaseDateToVersion(date: Date): string { } } - MutateCollection("songs-chunithm.json", (songs: Array>) => [ + MutateCollection("songs-chunithm.json", (songs: Array>) => [ ...songs, ...newSongs, ]); diff --git a/typescript/seeds-scripts/rerunners/ddr/parse-charts.ts b/typescript/seeds-scripts/rerunners/ddr/parse-charts.ts index 77c59c580..f0f4e4124 100644 --- a/typescript/seeds-scripts/rerunners/ddr/parse-charts.ts +++ b/typescript/seeds-scripts/rerunners/ddr/parse-charts.ts @@ -1,6 +1,6 @@ import { Parser } from "binary-parser"; import fs from "fs"; -import { type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, type SongDocument } from "tachi-common"; import { ReadCollection, WriteCollection } from "../../util"; @@ -169,8 +169,7 @@ class SSQParser { } class DataUpdater { - existingChartDocs: MONGO_ChartDocument<"ddr:DP" | "ddr:SP">[] = - ReadCollection("charts-ddr.json"); + existingChartDocs: ChartDocument<"ddr-dp" | "ddr-sp">[] = ReadCollection("charts-ddr.json"); songs = ReadCollection("songs-ddr.json"); saveCharts() { @@ -182,15 +181,15 @@ class DataUpdater { updateStepCounts(basename: string, stepDataArray: StepData[]) { for (const stepData of stepDataArray) { - const song: MONGO_SongDocument<"ddr"> = this.songs.find( - (s: MONGO_SongDocument<"ddr">) => s.data.basename === basename, + const song: SongDocument<"ddr"> = this.songs.find( + (s: SongDocument<"ddr">) => s.data.basename === basename, ); if (!song) { break; } for (const chart of this.existingChartDocs) { if ( - song.id === chart.songID && + song.id === chart.song.id && chart.playtype === stepData.style && chart.difficulty === stepData.difficulty ) { diff --git a/typescript/seeds-scripts/rerunners/ddr/parse-gameData.ts b/typescript/seeds-scripts/rerunners/ddr/parse-gameData.ts index 913cc2a4e..0331097f4 100644 --- a/typescript/seeds-scripts/rerunners/ddr/parse-gameData.ts +++ b/typescript/seeds-scripts/rerunners/ddr/parse-gameData.ts @@ -1,17 +1,17 @@ import { + type ChartDocument, type Difficulties, - type GameConfig, + type GameGroupConfig, GetGameGroupConfig, - type MONGO_ChartDocument, - type MONGO_SongDocument, - type Playtypes, + type LEGACY_Playtypes, + type SongDocument, type Versions, } from "tachi-common"; import { DDR_FLARE_CATEGORIES } from "tachi-common/config/game-support/ddr"; -import { CreateChartID, MutateCollection, ReadCollection, WriteCollection } from "../../util"; +import { CreateChartID, ReadCollection, WriteCollection } from "../../util"; -const gameConfig = GetGameGroupConfig("ddr") as GameConfig<"ddr">; +const gameGroupConfig = GetGameGroupConfig("ddr") as GameGroupConfig<"ddr">; export interface Music { mcode: number; @@ -26,7 +26,7 @@ export interface Music { diffLv: string; } -const DIFFICULTIES: Array = [ +const DIFFICULTIES: Array = [ "BEGINNER", "BASIC", "DIFFICULT", @@ -53,7 +53,7 @@ function seriesToFlareCategory(series: number) { return DDR_FLARE_CATEGORIES.enum.NONE; } -function buildSong(music: Music): MONGO_SongDocument<"ddr"> { +function buildSong(music: Music): SongDocument<"ddr"> { return { artist: `${music.artist}`, title: `${music.title}`, @@ -70,13 +70,15 @@ function buildSong(music: Music): MONGO_SongDocument<"ddr"> { function buildChart( music: Music, - playtype: Playtypes["ddr"], - difficulty: Difficulties["ddr:DP" | "ddr:SP"], - version: Versions["ddr:DP" | "ddr:SP"], -): MONGO_ChartDocument<"ddr:DP" | "ddr:SP"> { + playtype: LEGACY_Playtypes["ddr"], + difficulty: Difficulties["ddr-dp" | "ddr-sp"], + version: Versions["ddr-dp" | "ddr-sp"], +): ChartDocument<"ddr-dp" | "ddr-sp"> { const splitDiff = music.diffLv.split(" "); const diffIndex = (playtype === "SP" ? 0 : 5) + DIFFICULTIES.indexOf(difficulty); + const game: "ddr-dp" | "ddr-sp" = playtype === "SP" ? "ddr-sp" : "ddr-dp"; return { + game, chartID: CreateChartID(), songID: music.mcode, difficulty: difficulty, @@ -91,18 +93,18 @@ function buildChart( }; } -export function parseGameData(version: Versions["ddr:DP" | "ddr:SP"], gameData: Music[]) { +export function parseGameData(version: Versions["ddr-dp" | "ddr-sp"], gameData: Music[]) { const songs = ReadCollection("songs-ddr.json"); const existingChartDocs = ReadCollection("charts-ddr.json"); - const existingCharts = new Map>(); + const existingCharts = new Map>(); for (const chart of existingChartDocs) { existingCharts.set(`${chart.data.inGameID}-${chart.difficulty}-${chart.playtype}`, chart); } - const newSongs: MONGO_SongDocument<"ddr">[] = []; - const newCharts: MONGO_ChartDocument<"ddr:DP" | "ddr:SP">[] = []; + const newSongs: SongDocument<"ddr">[] = []; + const newCharts: ChartDocument<"ddr-dp" | "ddr-sp">[] = []; for (const music of gameData) { - const song = songs.find((s: MONGO_SongDocument<"ddr">) => s.data.inGameID === music.mcode); + const song = songs.find((s: SongDocument<"ddr">) => s.data.inGameID === music.mcode); if (!song) { const newSong = buildSong(music); console.log( @@ -116,7 +118,7 @@ export function parseGameData(version: Versions["ddr:DP" | "ddr:SP"], gameData: for (let i = 0; i < 10; i++) { const diff = parseInt(splitDiff[i]!, 10); if (diff > 0 && diff <= 19) { - const playtype = gameConfig.playtypes.at(Math.floor(i / 5))!; + const playtype = gameGroupConfig.playtypes.at(Math.floor(i / 5))!; const difficulty = DIFFICULTIES.at(i % 5)!; const exists = existingCharts.get(`${music.mcode}-${difficulty}-${playtype}`); if (exists) { diff --git a/typescript/seeds-scripts/rerunners/folder-slugs/add-folder-slugs.ts b/typescript/seeds-scripts/rerunners/folder-slugs/add-folder-slugs.ts new file mode 100644 index 000000000..e7c2ef6a5 --- /dev/null +++ b/typescript/seeds-scripts/rerunners/folder-slugs/add-folder-slugs.ts @@ -0,0 +1,71 @@ +/** + * Assigns `slug` on folder seed rows in `db/seeds/folders.json`. + * Rules live in `./folder-slug.ts` (`computeFolderSlug`). + * + * Default is dry-run. Pass `--write` to persist. + * + * @example + * bun rerunners/folder-slugs/add-folder-slugs.ts + * bun rerunners/folder-slugs/add-folder-slugs.ts --write + * bun rerunners/folder-slugs/add-folder-slugs.ts --only iidx-sp,iidx-dp + */ +import { Command } from "commander"; + +import { MutateCollection, ReadCollection } from "../../util"; +import { applyFolderSlugs } from "./apply-folder-slugs"; +import { type SeedFolderRow } from "./folder-slug"; + +const program = new Command(); + +program + .name("add-folder-slugs") + .description("Set folder.slug for all games in folders.json") + .option("--write", "Persist changes to db/seeds/folders.json", false) + .option( + "--only ", + "Comma-separated `game` values (e.g. iidx-sp,chunithm). Default: all games.", + ) + .parse(process.argv); + +const opts = program.opts() as { only?: string; write: boolean }; + +const onlyGames = + opts.only === undefined || opts.only === "" + ? null + : new Set( + opts.only + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ); + +if (opts.write) { + MutateCollection("folders.json", (rows: Array) => { + const out = applyFolderSlugs(rows, onlyGames); + + console.log( + `[add-folder-slugs] wrote: updated ${out.updated}, already had slug ${out.skippedAlreadySet}`, + ); + console.log(`[add-folder-slugs] updated by game: ${JSON.stringify(out.updatedByGame)}`); + + return rows; + }); +} else { + const folders = ReadCollection("folders.json") as Array; + const clone = structuredClone(folders) as Array; + const out = applyFolderSlugs(clone, onlyGames); + + const n = + onlyGames === null ? folders.length : folders.filter((f) => onlyGames.has(f.game)).length; + + console.log(`[add-folder-slugs] dry-run (pass --write to save)`); + if (onlyGames !== null) { + console.log(`[add-folder-slugs] --only ${[...onlyGames].join(",")}`); + } + + console.log(`[add-folder-slugs] folder rows in scope: ${n}`); + console.log( + `[add-folder-slugs] would set slug on ${out.updated} rows; ${out.skippedAlreadySet} already correct`, + ); + console.log(`[add-folder-slugs] would update by game: ${JSON.stringify(out.updatedByGame)}`); +} diff --git a/typescript/seeds-scripts/rerunners/folder-slugs/add-iidx-folder-slugs.ts b/typescript/seeds-scripts/rerunners/folder-slugs/add-iidx-folder-slugs.ts new file mode 100644 index 000000000..3ee2d0188 --- /dev/null +++ b/typescript/seeds-scripts/rerunners/folder-slugs/add-iidx-folder-slugs.ts @@ -0,0 +1,50 @@ +/** + * IIDX-only convenience wrapper. Prefer `add-folder-slugs.ts --only iidx-sp,iidx-dp`. + */ +import { Command } from "commander"; + +import { MutateCollection, ReadCollection } from "../../util"; +import { applyFolderSlugs } from "./apply-folder-slugs"; +import { type SeedFolderRow } from "./folder-slug"; + +const IIDX_ONLY = new Set(["iidx-dp", "iidx-sp"]); + +const program = new Command(); + +program + .name("add-iidx-folder-slugs") + .description("Set folder.slug for iidx-sp / iidx-dp rows in folders.json") + .option("--write", "Persist changes to db/seeds/folders.json", false) + .parse(process.argv); + +const opts = program.opts() as { write: boolean }; + +if (opts.write) { + MutateCollection("folders.json", (rows: Array) => { + const out = applyFolderSlugs(rows, IIDX_ONLY); + + console.log( + `[add-iidx-folder-slugs] wrote: updated ${out.updated}, already had slug ${out.skippedAlreadySet}`, + ); + console.log( + `[add-iidx-folder-slugs] updated by game: ${JSON.stringify(out.updatedByGame)}`, + ); + + return rows; + }); +} else { + const folders = ReadCollection("folders.json") as Array; + const clone = structuredClone(folders) as Array; + const out = applyFolderSlugs(clone, IIDX_ONLY); + + const iidxCount = folders.filter((f) => IIDX_ONLY.has(f.game)).length; + + console.log(`[add-iidx-folder-slugs] dry-run (pass --write to save)`); + console.log(`[add-iidx-folder-slugs] IIDX folder rows: ${iidxCount}`); + console.log( + `[add-iidx-folder-slugs] would set slug on ${out.updated} rows; ${out.skippedAlreadySet} already correct`, + ); + console.log( + `[add-iidx-folder-slugs] would update by game: ${JSON.stringify(out.updatedByGame)}`, + ); +} diff --git a/typescript/seeds-scripts/rerunners/folder-slugs/apply-folder-slugs.ts b/typescript/seeds-scripts/rerunners/folder-slugs/apply-folder-slugs.ts new file mode 100644 index 000000000..ac4db9c1d --- /dev/null +++ b/typescript/seeds-scripts/rerunners/folder-slugs/apply-folder-slugs.ts @@ -0,0 +1,44 @@ +import { computeFolderSlug, type SeedFolderRow } from "./folder-slug"; + +export function applyFolderSlugs( + folders: Array, + onlyGames: ReadonlySet | null, +): { + skippedAlreadySet: number; + updated: number; + updatedByGame: Record; +} { + let updated = 0; + let skippedAlreadySet = 0; + const updatedByGame: Record = {}; + + const slugKeys = new Map(); + + for (const folder of folders) { + if (onlyGames !== null && !onlyGames.has(folder.game)) { + continue; + } + + const slug = computeFolderSlug(folder); + const key = `${folder.game}\0${slug}`; + + if (slugKeys.has(key)) { + throw new Error( + `Duplicate slug "${slug}" for game ${folder.game}: "${slugKeys.get(key)}" vs "${folder.title}"`, + ); + } + + slugKeys.set(key, folder.title); + + if (folder.slug === slug) { + skippedAlreadySet++; + continue; + } + + folder.slug = slug; + updated++; + updatedByGame[folder.game] = (updatedByGame[folder.game] ?? 0) + 1; + } + + return { updated, skippedAlreadySet, updatedByGame }; +} diff --git a/typescript/seeds-scripts/rerunners/folder-slugs/folder-slug.ts b/typescript/seeds-scripts/rerunners/folder-slugs/folder-slug.ts new file mode 100644 index 000000000..ca71a89dc --- /dev/null +++ b/typescript/seeds-scripts/rerunners/folder-slugs/folder-slug.ts @@ -0,0 +1,6 @@ +/** + * Re-exported from `tachi-common` so rerunner paths stay stable. + * @see {@link computeFolderSlug} in `tachi-common` + */ + +export * from "tachi-common/lib/folder-slug"; diff --git a/typescript/seeds-scripts/rerunners/gitadora/parse-mdb.ts b/typescript/seeds-scripts/rerunners/gitadora/parse-mdb.ts index dce903be4..ff4bd9226 100644 --- a/typescript/seeds-scripts/rerunners/gitadora/parse-mdb.ts +++ b/typescript/seeds-scripts/rerunners/gitadora/parse-mdb.ts @@ -2,17 +2,17 @@ import { Command } from "commander"; import { XMLParser } from "fast-xml-parser"; import { readFileSync } from "fs"; import { + type ChartDocument, type Difficulties, - type MONGO_ChartDocument, - type MONGO_SongDocument, - type Playtypes, + type LEGACY_Playtypes, + type SongDocument, type Versions, } from "tachi-common"; -import { GITADORA_GITA_CONF } from "tachi-common/config/game-support/gitadora"; +import { GAME_GITADORA_GITA_CONF } from "tachi-common/config/game-support/gitadora"; import { CreateChartID, MutateCollection, ReadCollection, WriteCollection } from "../../util"; -const supportedVersions = Object.keys(GITADORA_GITA_CONF.versions); +const supportedVersions = Object.keys(GAME_GITADORA_GITA_CONF.versions); const program = new Command(); program.requiredOption("-i, --input "); @@ -30,7 +30,7 @@ if (!supportedVersions.includes(options.version)) { ); throw new Error(`Invalid version ${options.version}.`); } -const version: Versions["gitadora:Dora" | "gitadora:Gita"] = options.version; +const version: Versions["gitadora-dora" | "gitadora-gita"] = options.version; interface XMLText { "#text": T; @@ -86,7 +86,7 @@ interface Entry { } // Used for destructuring the difficulty byte array - see implementation for mapping -const DIFFICULTIES: (Difficulties["gitadora:Dora" | "gitadora:Gita"] | null)[] = [ +const DIFFICULTIES: (Difficulties["gitadora-dora" | "gitadora-gita"] | null)[] = [ null, "BASIC", "ADVANCED", @@ -103,7 +103,7 @@ const DIFFICULTIES: (Difficulties["gitadora:Dora" | "gitadora:Gita"] | null)[] = "BASS EXTREME", "BASS MASTER", ]; -const PLAYTYPE_DIFFICULTY_MAP: (Playtypes["gitadora"] | null)[] = [ +const PLAYTYPE_DIFFICULTY_MAP: (LEGACY_Playtypes["gitadora"] | null)[] = [ null, "Gita", "Gita", @@ -121,7 +121,7 @@ const PLAYTYPE_DIFFICULTY_MAP: (Playtypes["gitadora"] | null)[] = [ "Gita", ]; -function buildSong(entry: Entry): MONGO_SongDocument<"gitadora"> { +function buildSong(entry: Entry): SongDocument<"gitadora"> { return { artist: entry.artist_title_ascii["#text"] || "", // Sometimes artist is just not there - this is valid! title: entry.title_name["#text"], @@ -135,14 +135,17 @@ function buildSong(entry: Entry): MONGO_SongDocument<"gitadora"> { const buildChart = ( entry: Entry, difficulty: number, - difficultyString: Difficulties["gitadora:Dora" | "gitadora:Gita"], - playType: Playtypes["gitadora"], -): MONGO_ChartDocument<"gitadora:Dora" | "gitadora:Gita"> => { + difficultyString: Difficulties["gitadora-dora" | "gitadora-gita"], + playType: LEGACY_Playtypes["gitadora"], +): ChartDocument<"gitadora-dora" | "gitadora-gita"> => { // Comes in a 435, needs to become "4.35" const convertedDifficultyAsFloat = difficulty / 100; const convertedDifficultyAsString = convertedDifficultyAsFloat.toFixed(2); + const game: "gitadora-dora" | "gitadora-gita" = + playType === "Gita" ? "gitadora-gita" : "gitadora-dora"; return { + game, chartID: CreateChartID(), data: { inGameID: entry.music_id["#text"], @@ -159,20 +162,20 @@ const buildChart = ( const songs = ReadCollection("songs-gitadora.json"); const existingChartDocs = ReadCollection("charts-gitadora.json"); -const existingCharts = new Map>(); +const existingCharts = new Map>(); for (const chart of existingChartDocs) { existingCharts.set(`${chart.data.inGameID}-${chart.difficulty}-${chart.playtype}`, chart); } -const newSongs: MONGO_SongDocument<"gitadora">[] = []; -const newCharts: MONGO_ChartDocument<"gitadora:Dora" | "gitadora:Gita">[] = []; +const newSongs: SongDocument<"gitadora">[] = []; +const newCharts: ChartDocument<"gitadora-dora" | "gitadora-gita">[] = []; for (const entry of data.mdb.mdb_data as Entry[]) { // For logging purposes - song may not exist, not safe to access for logging const mid = entry.music_id["#text"]; const title = entry.title_name["#text"]; const artist = entry.artist_title_ascii["#text"] || ""; - const song = songs.find((s: MONGO_SongDocument<"gitadora">) => s.id === mid); + const song = songs.find((s: SongDocument<"gitadora">) => s.id === mid); if (!song) { const newSong = buildSong(entry); console.log(`New song: ${newSong.artist} - ${newSong.title} (id ${newSong.id})`); @@ -216,7 +219,7 @@ for (const entry of data.mdb.mdb_data as Entry[]) { } } -MutateCollection("songs-gitadora.json", (songs: Array>) => [ +MutateCollection("songs-gitadora.json", (songs: Array>) => [ ...songs, ...newSongs, ]); diff --git a/typescript/seeds-scripts/rerunners/iidx/aix-output/parse-aix-json.ts b/typescript/seeds-scripts/rerunners/iidx/aix-output/parse-aix-json.ts index f1c2d6f9d..5f18e23d2 100644 --- a/typescript/seeds-scripts/rerunners/iidx/aix-output/parse-aix-json.ts +++ b/typescript/seeds-scripts/rerunners/iidx/aix-output/parse-aix-json.ts @@ -1,15 +1,15 @@ import { readdirSync, readFileSync } from "fs"; import { join } from "path"; import { + type ChartDocument, type Difficulties, - type GPTStrings, + type GamesForGroup, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, - type Playtypes, + type LEGACY_Playtypes, + type SongDocument, } from "tachi-common"; -import { CreateChartID, GetFreshScoreIDGenerator, MutateCollection } from "../../util"; +import { CreateChartID, GetFreshSongIDGenerator, MutateCollection } from "../../../util"; interface AixChart { bpm_max: integer; @@ -36,7 +36,7 @@ function ConvertAixStuff(d: AixData, songID: integer) { searchTerms.push(d.title_ascii); } - const song: MONGO_SongDocument<"iidx"> = { + const song: SongDocument<"iidx"> = { title: d.title, artist: d.artist, altTitles: [], @@ -48,7 +48,7 @@ function ConvertAixStuff(d: AixData, songID: integer) { id: songID, }; - const charts: Array> = []; + const charts: Array> = []; for (const [diff, c] of Object.entries(d.charts)) { // wonderful ts oddity charts.push(ParseAixChart(d, c, diff as ChartStrings, songID)); @@ -66,8 +66,8 @@ const DIFF_MAP = { } as const; function SplitAixDiff(diff: ChartStrings): { - difficulty: Difficulties["iidx:DP" | "iidx:SP"]; - playtype: Playtypes["iidx"]; + difficulty: Difficulties[GamesForGroup["iidx"]]; + playtype: LEGACY_Playtypes["iidx"]; } { const difficulty = DIFF_MAP[diff[2]]; @@ -84,10 +84,11 @@ function SplitAixDiff(diff: ChartStrings): { function ParseAixChart(d: AixData, c: AixChart, diff: ChartStrings, songID: integer) { const { difficulty, playtype } = SplitAixDiff(diff); - let chart: MONGO_ChartDocument; + let chart: ChartDocument; if (playtype === "SP") { - const temp: MONGO_ChartDocument<"iidx:SP"> = { + const temp: ChartDocument<"iidx-sp"> = { + game: "iidx-sp", chartID: CreateChartID(), data: { notecount: c.note_count, @@ -107,12 +108,13 @@ function ParseAixChart(d: AixData, c: AixChart, diff: ChartStrings, songID: inte level: c.rating.toString(), levelNum: c.rating, songID, - versions: ["INFINITAS"], + versions: ["inf"], }; chart = temp; } else { - const temp: MONGO_ChartDocument<"iidx:DP"> = { + const temp: ChartDocument<"iidx-dp"> = { + game: "iidx-dp", chartID: CreateChartID(), data: { notecount: c.note_count, @@ -130,7 +132,7 @@ function ParseAixChart(d: AixData, c: AixChart, diff: ChartStrings, songID: inte level: c.rating.toString(), levelNum: c.rating, songID, - versions: ["INFINITAS"], + versions: ["inf"], }; chart = temp; @@ -142,10 +144,10 @@ function ParseAixChart(d: AixData, c: AixChart, diff: ChartStrings, songID: inte if (require.main === module) { const files = readdirSync(__dirname); - const getSongID = GetFreshScoreIDGenerator("iidx"); + const getSongID = GetFreshSongIDGenerator("iidx"); - const newSongs: Array> = []; - const newCharts: Array> = []; + const newSongs: Array> = []; + const newCharts: Array> = []; for (const file of files) { if (file.endsWith(".json")) { const songID = getSongID(); diff --git a/typescript/seeds-scripts/rerunners/iidx/iidx-mdb-parse/merge-mdb.ts b/typescript/seeds-scripts/rerunners/iidx/iidx-mdb-parse/merge-mdb.ts index 189c4ac81..a8ed8b290 100644 --- a/typescript/seeds-scripts/rerunners/iidx/iidx-mdb-parse/merge-mdb.ts +++ b/typescript/seeds-scripts/rerunners/iidx/iidx-mdb-parse/merge-mdb.ts @@ -3,11 +3,12 @@ import { Command } from "commander"; import fs from "fs"; import path from "path"; import { + type ChartDocument, type Difficulties, - GetGamePTConfig, + type GamesForGroup, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, + LEGACY_GetGamePTConfig, + type SongDocument, type Versions, } from "tachi-common"; @@ -44,10 +45,10 @@ const options = program.opts() as { force: boolean; index: "0" | "1"; omni: boolean; - version: Versions["iidx:SP"]; + version: Versions["iidx-sp"]; }; -const iidxConfig = GetGamePTConfig("iidx", "SP"); +const iidxConfig = LEGACY_GetGamePTConfig("iidx", "SP"); if (!Object.keys(iidxConfig.versions).includes(options.version)) { throw new Error( @@ -63,10 +64,9 @@ if (options.index !== "0" && options.index !== "1") { throw new Error(`Expected an --index of 0 or 1. Got ${options.index}.`); } -const existingCharts: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">[] = - ReadCollection("charts-iidx.json"); +const existingCharts: ChartDocument[] = ReadCollection("charts-iidx.json"); -const existingSongs: MONGO_SongDocument<"iidx">[] = ReadCollection("songs-iidx.json"); +const existingSongs: SongDocument<"iidx">[] = ReadCollection("songs-iidx.json"); const blacklist = fs .readFileSync(path.join(__dirname, "blacklist.txt"), "utf-8") @@ -101,10 +101,10 @@ async function ParseIIDXMDB() { options.alwaysExtract, ); - const chartMap = new Map>(); - const songMap = new Map>(); - const songTitleMap = new Map>(); - const chartDiffMap = new Map>(); + const chartMap = new Map>(); + const songMap = new Map>(); + const songTitleMap = new Map>(); + const chartDiffMap = new Map>(); for (const song of existingSongs) { songMap.set(song.id, song); @@ -143,7 +143,7 @@ async function ParseIIDXMDB() { } const anySongIDMatch = chartMap.get(inp.songID); - let song: MONGO_SongDocument<"iidx">; + let song: SongDocument<"iidx">; if (!anySongIDMatch) { // new song? @@ -170,7 +170,7 @@ async function ParseIIDXMDB() { searchTerms.push(inp.marquee); } - const tachiSong: MONGO_SongDocument<"iidx"> = { + const tachiSong: SongDocument<"iidx"> = { id: getFreeSongID(), artist: inp.artist, title: inp.title, @@ -242,13 +242,16 @@ async function ParseIIDXMDB() { } // otherwise, make new chart? - const tachiChart: MONGO_ChartDocument<"iidx:DP" | "iidx:SP"> = { + const playtypeStr = diffName.split("-")[0] as "DP" | "SP"; + const game: GamesForGroup["iidx"] = playtypeStr === "SP" ? "iidx-sp" : "iidx-dp"; + const tachiChart: ChartDocument = { + game, chartID: CreateChartID(), - difficulty: diffName.split("-")[1] as Difficulties["iidx:DP" | "iidx:SP"], + difficulty: diffName.split("-")[1] as Difficulties[GamesForGroup["iidx"]], level: level.toString(), levelNum: level, isPrimary: true, - playtype: diffName.split("-")[0] as "DP" | "SP", + playtype: playtypeStr, songID: song.id, versions: [options.version], diff --git a/typescript/seeds-scripts/rerunners/iidx/update-bpi-poyashi.js b/typescript/seeds-scripts/rerunners/iidx/update-bpi-poyashi.js index b2f918f0f..5693ca0a1 100644 --- a/typescript/seeds-scripts/rerunners/iidx/update-bpi-poyashi.js +++ b/typescript/seeds-scripts/rerunners/iidx/update-bpi-poyashi.js @@ -105,7 +105,7 @@ function FindSongOnTitle(title) { function FindChartWithPTDFVersion(songID, playtype, diff, version) { for (const chart of chartData) { if ( - chart.songID === songID && + chart.song.id === songID && chart.playtype === playtype && chart.difficulty === diff && chart.versions.includes(version) diff --git a/typescript/seeds-scripts/rerunners/import-raw-quests.ts b/typescript/seeds-scripts/rerunners/import-raw-quests.ts index 7a2fc9763..8944e0def 100644 --- a/typescript/seeds-scripts/rerunners/import-raw-quests.ts +++ b/typescript/seeds-scripts/rerunners/import-raw-quests.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import fs from "fs"; -import { type MONGO_GoalDocument, type MONGO_QuestDocument } from "tachi-common"; +import { type GoalDocument, type QuestDocument } from "tachi-common"; import { CreateGoalID, CreateQuestID, MutateCollection } from "../util"; @@ -13,7 +13,7 @@ const options = program.opts(); // stolen from client/src/types/tachi.ts type RawQuestDocument = { rawQuestData: Array; -} & Omit; +} & Omit; type RawQuestSection = { desc: string; @@ -22,16 +22,16 @@ type RawQuestSection = { }; type RawQuestGoal = { - goal: Pick; + goal: Pick; note?: string; }; const data = JSON.parse(fs.readFileSync(options.file, "utf-8")) as Array; -const newGoals: Array = []; +const newGoals: Array = []; -function HydrateQuest(raw: RawQuestDocument): MONGO_QuestDocument { - const questData: MONGO_QuestDocument["questData"] = []; +function HydrateQuest(raw: RawQuestDocument): QuestDocument { + const questData: QuestDocument["questData"] = []; const { game, playtype } = raw; @@ -39,16 +39,16 @@ function HydrateQuest(raw: RawQuestDocument): MONGO_QuestDocument { const goals: Array<{ goalID: string; note?: string }> = []; for (const rawGoal of rawQuest.rawGoals) { - const goalID = CreateGoalID(rawGoal.goal.charts, rawGoal.goal.criteria, game, playtype); + const goalID = CreateGoalID(rawGoal.goal.charts, rawGoal.goal.criteria, game); - const newGoal: MONGO_GoalDocument = { + const newGoal: GoalDocument = { charts: rawGoal.goal.charts, criteria: rawGoal.goal.criteria, game, playtype, goalID, name: rawGoal.goal.name, - } as MONGO_GoalDocument; + } as GoalDocument; newGoals.push(newGoal); diff --git a/typescript/seeds-scripts/rerunners/jubeat/add-jubeat-folders.ts b/typescript/seeds-scripts/rerunners/jubeat/add-jubeat-folders.ts index 7f235bc03..7098dd946 100644 --- a/typescript/seeds-scripts/rerunners/jubeat/add-jubeat-folders.ts +++ b/typescript/seeds-scripts/rerunners/jubeat/add-jubeat-folders.ts @@ -1,5 +1,5 @@ -import { type MONGO_FolderDocument } from "tachi-common"; -import { JUBEAT_SINGLE_CONF } from "tachi-common/config/game-support/jubeat"; +import { type FolderDocument } from "tachi-common"; +import { GAME_JUBEAT_SINGLE_CONF } from "tachi-common/config/game-support/jubeat"; import { CreateFolderID, MutateCollection } from "../../util"; @@ -26,9 +26,9 @@ const versions = ["ave"]; for (const version of versions) { const shouldAddDecimals = true; - const versionPretty = JUBEAT_SINGLE_CONF.versions[version]; - const levelFolders: Array = []; - const levelHardFolders: Array = []; + const versionPretty = GAME_JUBEAT_SINGLE_CONF.versions[version]; + const levelFolders: Array = []; + const levelHardFolders: Array = []; for (const level of ["1", "2", "3", "4", "5", "6", "7", "8"]) { const f = CreateFolder( @@ -82,8 +82,8 @@ for (const version of versions) { ), ); - const detailFolders: Array = []; - const detailHardFolders: Array = []; + const detailFolders: Array = []; + const detailHardFolders: Array = []; if (shouldAddDecimals) { for (const level of [ diff --git a/typescript/seeds-scripts/rerunners/maimaidx/add-maimaidx-internal-levels.ts b/typescript/seeds-scripts/rerunners/maimaidx/add-maimaidx-internal-levels.ts index 849b5b4ea..8cec17521 100644 --- a/typescript/seeds-scripts/rerunners/maimaidx/add-maimaidx-internal-levels.ts +++ b/typescript/seeds-scripts/rerunners/maimaidx/add-maimaidx-internal-levels.ts @@ -11,7 +11,7 @@ import { XMLParser } from "fast-xml-parser"; */ import fs from "fs"; import path from "path"; -import { type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, type SongDocument } from "tachi-common"; import { MutateCollection, ReadCollection } from "../../util"; @@ -156,10 +156,10 @@ function normalizeTitle(title: string): string { } function findSong( - songs: MONGO_SongDocument<"maimaidx">[], + songs: SongDocument<"maimaidx">[], title: string, category: string, -): MONGO_SongDocument<"maimaidx"> | undefined { +): SongDocument<"maimaidx"> | undefined { // There are two songs with the exact same title and that only differs // by category: // - Link (maimai) is 68 @@ -196,7 +196,7 @@ function calculateDifficulty(style: string, sheetDifficulty: string): string { function addTmaiSheet(csvData: string[][]) { const songs = ReadCollection("songs-maimaidx.json"); - MutateCollection("charts-maimaidx.json", (charts: MONGO_ChartDocument<"maimaidx:Single">[]) => { + MutateCollection("charts-maimaidx.json", (charts: ChartDocument<"maimaidx">[]) => { for (let rowNumber = 1; rowNumber < csvData.length; rowNumber++) { const row = csvData[rowNumber]!; const title = row[1]; @@ -257,7 +257,7 @@ function addOtherSheet(csvData: string[][], headerRow: number, options: AddOther const categoryColumnOffset = hasCategoryColumn ? 1 : 0; let currentCategory = ""; - MutateCollection("charts-maimaidx.json", (charts: MONGO_ChartDocument<"maimaidx:Single">[]) => { + MutateCollection("charts-maimaidx.json", (charts: ChartDocument<"maimaidx">[]) => { for ( let colNumber = 0; colNumber + 4 + categoryColumnOffset < csvData[0]!.length; @@ -335,7 +335,7 @@ if (options.directory) { const songs = ReadCollection("songs-maimaidx.json"); const parser = new XMLParser({ ignoreAttributes: false }); - MutateCollection("charts-maimaidx.json", (charts: MONGO_ChartDocument<"maimaidx:Single">[]) => { + MutateCollection("charts-maimaidx.json", (charts: ChartDocument<"maimaidx">[]) => { const items = fs.readdirSync(options.directory); for (const item of items) { diff --git a/typescript/seeds-scripts/rerunners/maimaidx/merge-options.ts b/typescript/seeds-scripts/rerunners/maimaidx/merge-options.ts index 8a2571e86..03ccff994 100644 --- a/typescript/seeds-scripts/rerunners/maimaidx/merge-options.ts +++ b/typescript/seeds-scripts/rerunners/maimaidx/merge-options.ts @@ -5,13 +5,18 @@ import { XMLParser } from "fast-xml-parser"; import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import path from "path"; import { + type ChartDocument, type Difficulties, - GetGamePTConfig, - type MONGO_ChartDocument, - type MONGO_SongDocument, + LEGACY_GetGamePTConfig, + type SongDocument, } from "tachi-common"; -import { CreateChartID, ReadCollection, WriteCollection } from "../../util"; +import { + CreateChartID, + GetFreshSongIDGenerator, + ReadCollection, + WriteCollection, +} from "../../util"; const VERSION_DISPLAY_NAMES = [ "maimai", @@ -121,7 +126,7 @@ if (!options.vgmsBinary) { } } -const versions = Object.keys(GetGamePTConfig("maimaidx", "Single").versions); +const versions = Object.keys(LEGACY_GetGamePTConfig("maimaidx", "Single").versions); if (versions.indexOf(options.version) === -1) { throw new Error( @@ -133,20 +138,19 @@ if (versions.indexOf(options.version) === -1) { const isLatestVersion = versions.indexOf(options.version.replace(/(-intl|-omni)$/u, "")) === versions.length - 1; -const existingSongs: Array> = ReadCollection("songs-maimaidx.json"); -const existingCharts: Array> = - ReadCollection("charts-maimaidx.json"); +const existingSongs: Array> = ReadCollection("songs-maimaidx.json"); +const existingCharts: Array> = ReadCollection("charts-maimaidx.json"); const songMap = new Map(existingSongs.map((s) => [s.id, s])); -const chartMap = new Map>(); +const chartMap = new Map>(); const songTitleArtistMap = new Map(); const durationMap = new Map(); for (const chart of existingCharts) { - const song = songMap.get(chart.songID); + const song = songMap.get(chart.song.id); if (song === undefined) { log.error( - `CONSISTENCY ERROR: Chart ID ${chart.chartID} does not belong to any songs! (songID was ${chart.songID})`, + `CONSISTENCY ERROR: Chart ID ${chart.chartID} does not belong to any songs! (songID was ${chart.song.id})`, ); process.exit(1); } @@ -227,8 +231,8 @@ const parser = new XMLParser({ }, }); -const newSongs: Array> = []; -const newCharts: Array> = []; +const newSongs: Array> = []; +const newCharts: Array> = []; const songIDGenerator = GetFreshSongIDGenerator("maimaidx"); @@ -333,14 +337,16 @@ for (const optionsDir of options.input) { // New song? if (tachiSongID === undefined) { - tachiSongID = songIDGenerator(); + const newSongID = songIDGenerator(); - const songDoc: MONGO_SongDocument<"maimaidx"> = { + tachiSongID = newSongID; + + const songDoc: SongDocument<"maimaidx"> = { title: musicData.name.str, altTitles: [], searchTerms: [], artist: musicData.artistName.str, - id: tachiSongID, + id: newSongID, data: { genre, duration, @@ -348,8 +354,8 @@ for (const optionsDir of options.input) { }; newSongs.push(songDoc); - songTitleArtistMap.set(`${songDoc.title}-${songDoc.artist}`, tachiSongID); - songMap.set(tachiSongID, songDoc); + songTitleArtistMap.set(`${songDoc.title}-${songDoc.artist}`, newSongID); + songMap.set(newSongID, songDoc); log.info( `Added new song ${songDoc.artist} - ${songDoc.title} (inGameID ${inGameID}, tachiSongID ${tachiSongID}).`, @@ -389,7 +395,7 @@ for (const optionsDir of options.input) { difficultyName = `DX ${difficultyName}`; } - let exists: MONGO_ChartDocument<"maimaidx:Single"> | undefined; + let exists: ChartDocument<"maimaidx"> | undefined; if (inGameID === 11422) { exists = chartMap.get(`-x0o0x_-${difficultyName}`); @@ -450,10 +456,11 @@ for (const optionsDir of options.input) { continue; } - const chartDoc: MONGO_ChartDocument<"maimaidx:Single"> = { + const chartDoc: ChartDocument<"maimaidx"> = { chartID: CreateChartID(), - songID: tachiSongID, - difficulty: difficultyName as Difficulties["maimaidx:Single"], + songID: tachiSongID!, + game: "maimaidx", + difficulty: difficultyName as Difficulties["maimaidx"], isPrimary: true, level, levelNum, diff --git a/typescript/seeds-scripts/rerunners/maimaidx/parse-maimaidx-dataset.ts b/typescript/seeds-scripts/rerunners/maimaidx/parse-maimaidx-dataset.ts index 1f541ed66..6cdc84e2d 100644 --- a/typescript/seeds-scripts/rerunners/maimaidx/parse-maimaidx-dataset.ts +++ b/typescript/seeds-scripts/rerunners/maimaidx/parse-maimaidx-dataset.ts @@ -1,5 +1,5 @@ import { log } from "#log"; -import { type Difficulties, type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, type Difficulties, type SongDocument } from "tachi-common"; import { CreateChartID, @@ -71,19 +71,18 @@ const VERSION_MAP = new Map([ ]); const CURRENT_VERSION_NUM = 250; const CURRENT_VERSION = "prism"; -const DIFFICULTY_TO_TACHI_DIFFICULTY: Record = - { - lev_bas: "Basic", - lev_adv: "Advanced", - lev_exp: "Expert", - lev_mas: "Master", - lev_remas: "Re:Master", - dx_lev_bas: "DX Basic", - dx_lev_adv: "DX Advanced", - dx_lev_exp: "DX Expert", - dx_lev_mas: "DX Master", - dx_lev_remas: "DX Re:Master", - } as const; +const DIFFICULTY_TO_TACHI_DIFFICULTY: Record = { + lev_bas: "Basic", + lev_adv: "Advanced", + lev_exp: "Expert", + lev_mas: "Master", + lev_remas: "Re:Master", + dx_lev_bas: "DX Basic", + dx_lev_adv: "DX Advanced", + dx_lev_exp: "DX Expert", + dx_lev_mas: "DX Master", + dx_lev_remas: "DX Re:Master", +} as const; const DX_REGEX = /dx\s*:\s*([01])/u; const LV_REGEX = /lv\s*:\s*(\[(?:-?[\d.]+\s*,?\s*)+\])/u; @@ -151,9 +150,8 @@ async function ParseInternalLevelData( } async function ParseMaimaiDXDataset() { - const songs: Array> = ReadCollection("songs-maimaidx.json"); - const charts: Array> = - ReadCollection("charts-maimaidx.json"); + const songs: Array> = ReadCollection("songs-maimaidx.json"); + const charts: Array> = ReadCollection("charts-maimaidx.json"); const existingSongMap = new Map(songs.map((s) => [`${s.title}-${s.artist}`, s])); const existingChartMap = new Map(charts.map((c) => [`${c.songID}-${c.difficulty}`, c])); @@ -206,14 +204,13 @@ async function ParseMaimaiDXDataset() { if (!tachiSongID) { tachiSongID = getFreeSongID(); - const songDoc: MONGO_SongDocument<"maimaidx"> = { + const songDoc: SongDocument<"maimaidx"> = { id: tachiSongID, title: data.title.trim(), artist: data.artist.trim(), searchTerms: [], altTitles: [], data: { - displayVersion, genre: data.catcode.trim(), }, }; @@ -225,7 +222,7 @@ async function ParseMaimaiDXDataset() { for (const [key, difficulty] of Object.entries(DIFFICULTY_TO_TACHI_DIFFICULTY) as [ MaimaiDXDifficulty, - Difficulties["maimaidx:Single"], + Difficulties["maimaidx"], ][]) { const level = data[key]; @@ -276,15 +273,17 @@ async function ParseMaimaiDXDataset() { continue; } - const chartDoc: MONGO_ChartDocument<"maimaidx:Single"> = { - songID: tachiSongID, + const chartDoc: ChartDocument<"maimaidx"> = { + songID: tachiSongID!, chartID: CreateChartID(), + game: "maimaidx", level, levelNum, isPrimary: true, difficulty, playtype: "Single", data: { + displayVersion, inGameID: null, }, versions: [CURRENT_VERSION], diff --git a/typescript/seeds-scripts/rerunners/make-questline-from-quests.ts b/typescript/seeds-scripts/rerunners/make-questline-from-quests.ts index c4d21cca9..308ed2e3f 100644 --- a/typescript/seeds-scripts/rerunners/make-questline-from-quests.ts +++ b/typescript/seeds-scripts/rerunners/make-questline-from-quests.ts @@ -1,7 +1,7 @@ import { spawnSync } from "child_process"; import { Command } from "commander"; import fs from "fs"; -import { type MONGO_QuestDocument, type MONGO_QuestlineDocument } from "tachi-common"; +import { type QuestDocument, type QuestlineDocument } from "tachi-common"; import { MutateCollection, ReadCollection } from "../util"; @@ -25,7 +25,7 @@ const { game, playtype } = options; const quests = ReadCollection("quests.json").filter( (e) => e.game === game && e.playtype === playtype, -) as Array; +) as Array; if (quests.length === 0) { console.log(`No quests found for game ${game} and playtype ${playtype}.`); @@ -109,7 +109,7 @@ if (questIDs.length === 0) { process.exit(1); } -const questline: MONGO_QuestlineDocument = { +const questline: QuestlineDocument = { game, playtype, desc, diff --git a/typescript/seeds-scripts/rerunners/ongeki/add-tables-and-folders.ts b/typescript/seeds-scripts/rerunners/ongeki/add-tables-and-folders.ts index 702b732d5..733622111 100644 --- a/typescript/seeds-scripts/rerunners/ongeki/add-tables-and-folders.ts +++ b/typescript/seeds-scripts/rerunners/ongeki/add-tables-and-folders.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; -import { GetGamePTConfig, type MONGO_FolderDocument } from "tachi-common"; +import { type FolderDocument, GetGameConfig } from "tachi-common"; -import { CreateFolderID, MutateCollection } from "../../util"; +import { CreateLegacyFolderID, MutateCollection } from "../../util"; const LEVELS = [ "0", @@ -36,7 +36,7 @@ const command = new Command().requiredOption("-v, --version ").parse(pr const options = command.opts(); const version = options.version; -const tachiVersions = GetGamePTConfig("ongeki", "Single").versions; +const tachiVersions = GetGameConfig("ongeki").versions; const versionName = tachiVersions[version]; if (!versionName) { @@ -45,7 +45,7 @@ if (!versionName) { ); } -const newFolders: MONGO_FolderDocument[] = []; +const newFolders: FolderDocument[] = []; const levelFolderIDs: string[] = []; const difficultyFolderIDs: string[] = []; @@ -62,7 +62,7 @@ for (const level of LEVELS) { }, }; } - const folderID = CreateFolderID(data, "ongeki", "Single"); + const folderID = CreateLegacyFolderID(data, "ongeki", "Single"); levelFolderIDs.push(folderID); @@ -92,7 +92,7 @@ for (const difficulty of DIFFICULTIES) { }; } - const folderID = CreateFolderID(data, "ongeki", "Single"); + const folderID = CreateLegacyFolderID(data, "ongeki", "Single"); difficultyFolderIDs.push(folderID); diff --git a/typescript/seeds-scripts/rerunners/ongeki/parse-music-data.ts b/typescript/seeds-scripts/rerunners/ongeki/parse-music-data.ts index fe103e4e6..40afec7dd 100644 --- a/typescript/seeds-scripts/rerunners/ongeki/parse-music-data.ts +++ b/typescript/seeds-scripts/rerunners/ongeki/parse-music-data.ts @@ -1,13 +1,13 @@ // usage: cd rerunners && npx ts-node ongeki/parse-music-data.ts import fs from "fs/promises"; -import { type Difficulties, type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, type Difficulties, type SongDocument } from "tachi-common"; import { CreateChartID, ReadCollection, WriteCollection } from "../../util"; -type OngekiChart = MONGO_ChartDocument<"ongeki:Single">; -type OngekiSong = MONGO_SongDocument<"ongeki">; -type Difficulty = Difficulties["ongeki:Single"]; +type OngekiChart = ChartDocument<"ongeki">; +type OngekiSong = SongDocument<"ongeki">; +type Difficulty = Difficulties["ongeki"]; const CURRENT_VERSION = "refresh"; const CURRENT_OMNIMIX = "refreshOmni"; @@ -149,9 +149,10 @@ const main = async () => { if (!ver.startsWith("オンゲキ")) { ver = `オンゲキ ${ver}`; } - chart = { + const newChart: OngekiChart = { chartID: CreateChartID(), songID: song.id, + game: "ongeki", data: { displayVersion: ver as any, inGameID: inputSong.id, @@ -164,9 +165,10 @@ const main = async () => { playtype: "Single", versions: [CURRENT_VERSION, CURRENT_OMNIMIX], }; - if (chart.difficulty === "LUNATIC") { - chart.data.isReMaster = inputSong.isReMaster; + if (newChart.difficulty === "LUNATIC") { + newChart.data.isReMaster = inputSong.isReMaster; } + chart = newChart; changes.charts.push(`${song.title} ${chart.difficulty} ${chart.level}`); charts.push(chart); } diff --git a/typescript/seeds-scripts/rerunners/ongeki/parse-song-duration.ts b/typescript/seeds-scripts/rerunners/ongeki/parse-song-duration.ts index 4ce5a4e26..c85d33a3f 100644 --- a/typescript/seeds-scripts/rerunners/ongeki/parse-song-duration.ts +++ b/typescript/seeds-scripts/rerunners/ongeki/parse-song-duration.ts @@ -6,7 +6,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, type SongDocument } from "tachi-common"; import { ReadCollection, WriteCollection } from "../../util"; @@ -25,8 +25,8 @@ const game = options.game; const readOpt = async ( musicPath: string, - charts: MONGO_ChartDocument<"chunithm:Single" | "ongeki:Single">[], - songs: MONGO_SongDocument<"chunithm" | "ongeki">[], + charts: ChartDocument<"chunithm" | "ongeki">[], + songs: SongDocument<"chunithm" | "ongeki">[], ) => { let musicDir: string[]; try { @@ -65,7 +65,7 @@ const readOpt = async ( } continue; } - const song = songs.find((s) => s.id === chart.songID); + const song = songs.find((s) => s.id === chart.song.id); if (song === undefined) { console.error(`Song #${id}: orphan`); continue; diff --git a/typescript/seeds-scripts/rerunners/ongeki/scrape-sdvx-in.ts b/typescript/seeds-scripts/rerunners/ongeki/scrape-sdvx-in.ts index 76fd817e7..8a19f8db4 100644 --- a/typescript/seeds-scripts/rerunners/ongeki/scrape-sdvx-in.ts +++ b/typescript/seeds-scripts/rerunners/ongeki/scrape-sdvx-in.ts @@ -1,12 +1,12 @@ /* eslint-disable no-await-in-loop */ import readline from "readline"; -import { type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, type SongDocument } from "tachi-common"; import { ReadCollection, WriteCollection } from "../../util"; -type OngekiChart = MONGO_ChartDocument<"ongeki:Single">; -type OngekiSong = MONGO_SongDocument<"ongeki">; +type OngekiChart = ChartDocument<"ongeki">; +type OngekiSong = SongDocument<"ongeki">; const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const prompt = (query: string) => new Promise((resolve) => rl.question(query, resolve)); @@ -136,7 +136,7 @@ const listMissing = async (charts: OngekiChart[], songs: OngekiSong[], minLevel: continue; } if (chart.levelNum >= minLevel && !chart.data.chartViewURL) { - const song = songs.find((s) => s.id === chart.songID); + const song = songs.find((s) => s.id === chart.song.id); // @ts-expect-error The linter disagrees with the compiler chart.data.chartViewURL = await prompt( `Missing: ${song?.artist} ${song?.title} ${chart.difficulty} ${chart.level}\n`, diff --git a/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-clear-tierlist.js b/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-clear-tierlist.js index 5d6b8c68a..7f8567103 100644 --- a/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-clear-tierlist.js +++ b/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-clear-tierlist.js @@ -497,14 +497,14 @@ function addTiers(levelNum, csvData, headerRow, leftOffset, simple) { let chart = charts.find( (chart) => - chart.songID === song.id && + chart.song.id === song.id && chart.levelNum === levelNum && chart.difficulty === difficulty, ); if (!chart) { // Sometimes the difficulty is missing, or straight up wrong (e.g. MXM instead of VVD) so just try without. chart = charts.find( - (chart) => chart.songID === song.id && chart.levelNum === levelNum, + (chart) => chart.song.id === song.id && chart.levelNum === levelNum, ); } @@ -564,7 +564,7 @@ function addTiers(levelNum, csvData, headerRow, leftOffset, simple) { if (missingTiers.length > 0) { console.log(`\nThe following lv${levelNum} charts are still missing a tier:`); for (const chart of missingTiers) { - const song = songs.find((song) => song.id === chart.songID); + const song = songs.find((song) => song.id === chart.song.id); console.log( `${song.id}: ${song.title} [${chart.difficulty}] (displayVersion: ${song.data.displayVersion})`, ); diff --git a/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-konaste.js b/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-konaste.js index 9a2675cd6..81af13fa0 100644 --- a/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-konaste.js +++ b/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-konaste.js @@ -90,7 +90,7 @@ for (const music of xmlData.mdb.music) { const difficulty = getDifficulty(diffKey, music.info.inf_ver["#text"]); const chartIndex = charts.findIndex( - (chart) => chart.songID === id && chart.difficulty === difficulty, + (chart) => chart.song.id === id && chart.difficulty === difficulty, ); if (chartIndex === -1) { charts.push({ diff --git a/typescript/seeds-scripts/rerunners/sdvx/merge-mdb.ts b/typescript/seeds-scripts/rerunners/sdvx/merge-mdb.ts index 0172c5e87..094317454 100644 --- a/typescript/seeds-scripts/rerunners/sdvx/merge-mdb.ts +++ b/typescript/seeds-scripts/rerunners/sdvx/merge-mdb.ts @@ -3,10 +3,10 @@ import { XMLParser } from "fast-xml-parser"; import fs from "fs"; import { decode } from "iconv-lite"; import { + type ChartDocument, type Difficulties, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, + type SongDocument, } from "tachi-common"; import { @@ -66,10 +66,10 @@ interface MDBEntry { const existingChartDocs = ReadCollection("charts-sdvx.json"); const inGameIDToSongIDMap = new Map(); -const existingCharts = new Map>(); +const existingCharts = new Map>(); for (const chart of existingChartDocs) { - inGameIDToSongIDMap.set(chart.data.inGameID, chart.songID); + inGameIDToSongIDMap.set(chart.data.inGameID, chart.song.id); existingCharts.set(`${chart.data.inGameID}-${chart.difficulty}`, chart); } @@ -99,7 +99,7 @@ function convertVersion(input: number) { function convertDiff( diffString: keyof MDBEntry["difficulty"], infVer: integer, -): Difficulties["sdvx:Single"] { +): Difficulties["sdvx"] { switch (diffString) { case "novice": return "NOV"; @@ -130,8 +130,8 @@ function convertDiff( } } -const newSongs: Array> = []; -const newCharts: Array> = []; +const newSongs: Array> = []; +const newCharts: Array> = []; // anything we don't want to include? const blacklist = [1259, 1491, 1438, 1490]; @@ -217,7 +217,7 @@ for (const entry of data.mdb.music as Array) { } // new song, add to seeds. - const songDoc: MONGO_SongDocument<"sdvx"> = { + const songDoc: SongDocument<"sdvx"> = { title: fixedTitle, artist: fixString(entry.info.artist_name), searchTerms: [entry.info.ascii], @@ -277,9 +277,10 @@ for (const entry of data.mdb.music as Array) { continue; } - const chartDoc: MONGO_ChartDocument<"sdvx:Single"> = { + const chartDoc: ChartDocument<"sdvx"> = { chartID: CreateChartID(), songID, + game: "sdvx", difficulty, isPrimary: true, level: maybeEntry.difnum["#text"].toString(), diff --git a/typescript/seeds-scripts/rerunners/sdvx/scrape-maya2s.ts b/typescript/seeds-scripts/rerunners/sdvx/scrape-maya2s.ts index b3648c50d..d4f2fcdfd 100644 --- a/typescript/seeds-scripts/rerunners/sdvx/scrape-maya2s.ts +++ b/typescript/seeds-scripts/rerunners/sdvx/scrape-maya2s.ts @@ -2,12 +2,12 @@ import * as cheerio from "cheerio"; import fetch from "node-fetch"; -import { type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common"; +import { type ChartDocument, type SongDocument } from "tachi-common"; import { ReadCollection, WriteCollection } from "../../util"; -type SDVXChart = MONGO_ChartDocument<"sdvx:Single">; -type SDVXSong = MONGO_SongDocument<"sdvx">; +type SDVXChart = ChartDocument<"sdvx">; +type SDVXSong = SongDocument<"sdvx">; // Internal data for PUC tierlists are inconsistent const MANUAL_PUC_TIERS: { [level: number]: { [tier: string]: string } } = { @@ -92,7 +92,7 @@ async function scrape( level: number, tierlistType: "PUC_LAMP" | "S_RANK", ) { - const html = await fetch(url).then((r: Response) => r.text()); + const html = await fetch(url).then((r) => r.text()); const $ = cheerio.load(html); for (const tierBox of $(".tier_box")) { diff --git a/typescript/seeds-scripts/rerunners/toggle-table-inactive.js b/typescript/seeds-scripts/rerunners/toggle-table-inactive.js index 74389956d..9e45e585d 100644 --- a/typescript/seeds-scripts/rerunners/toggle-table-inactive.js +++ b/typescript/seeds-scripts/rerunners/toggle-table-inactive.js @@ -26,7 +26,12 @@ if (!table) { MutateCollection("folders.json", (folders) => { for (const folder of folders) { - if (table.folders.includes(folder.folderID)) { + const id = folder.folderID ?? folder.id; + const matchesRef = + (folder.slug !== undefined && table.folders.includes(folder.slug)) || + (id !== undefined && table.folders.includes(id)); + + if (matchesRef) { folder.inactive = !folder.inactive; } } diff --git a/typescript/seeds-scripts/rerunners/v3/1-split-charts-by-playtype.ts b/typescript/seeds-scripts/rerunners/v3/1-split-charts-by-playtype.ts index 37f9ac223..f534f9c44 100644 --- a/typescript/seeds-scripts/rerunners/v3/1-split-charts-by-playtype.ts +++ b/typescript/seeds-scripts/rerunners/v3/1-split-charts-by-playtype.ts @@ -1,6 +1,6 @@ import fs from "fs"; import path from "path"; -import { GAME_GROUP_CONFIGS, GAME_PT_CONFIGS, GPTStringToV3 } from "tachi-common"; +import { ALL_GAMES, GAME_GROUP_CONFIGS } from "tachi-common"; import { ReadCollection, WriteCollection } from "../../util"; @@ -58,9 +58,7 @@ for (const game of Object.keys(GAME_GROUP_CONFIGS)) { } // now, for each collection remove the playtype field. -for (const gptString of Object.keys(GAME_PT_CONFIGS)) { - const nugame = GPTStringToV3(gptString as any); - +for (const nugame of ALL_GAMES) { const data = ReadCollection(`charts-${nugame}.json`); for (const entry of data) { delete entry.playtype; diff --git a/typescript/seeds-scripts/rerunners/v3/2-migrate-to-pg-style.ts b/typescript/seeds-scripts/rerunners/v3/2-migrate-to-pg-style.ts index 2545bc13f..ab216b43f 100644 --- a/typescript/seeds-scripts/rerunners/v3/2-migrate-to-pg-style.ts +++ b/typescript/seeds-scripts/rerunners/v3/2-migrate-to-pg-style.ts @@ -1,6 +1,6 @@ import fs from "fs"; import path from "path"; -import { GAME_GROUP_CONFIGS, GAME_PT_CONFIGS, GPTStringToV3, V3ToGameGroup } from "tachi-common"; +import { ALL_GAMES, GAME_GROUP_CONFIGS, GameToGameGroup } from "tachi-common"; import { CreateChartID, CreateSongID, ReadCollection, WriteCollection } from "../../util"; @@ -72,9 +72,8 @@ for (const gameGroup of Object.keys(GAME_GROUP_CONFIGS)) { // ── Pass 2: charts ──────────────────────────────────────────────────────────── -for (const oldGpt of Object.keys(GAME_PT_CONFIGS)) { - const game = GPTStringToV3(oldGpt as any); - const gameGroup = V3ToGameGroup(game); +for (const game of ALL_GAMES) { + const gameGroup = GameToGameGroup(game); const collection = `charts-${game}.json`; const data = ReadCollection(collection); let modified = 0; diff --git a/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts b/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts index 008e3a6da..2159147e0 100644 --- a/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts +++ b/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts @@ -1,6 +1,17 @@ +/** + * Legacy Mongo → v3 hex ids for folders/tables. After this, run `4-tablefolders-to-object.ts`, + * `5-folders-to-sql-queries.ts`, then `6-tables-folder-refs-to-slugs.ts` so `tables.json` + * references folders by slug. + */ + import fs from "fs"; import path from "path"; -import { type GameGroup, GetGPTString, GPTStringToV3, type Playtype } from "tachi-common"; +import { + type GameGroup, + GPTStringToGame, + LEGACY_GetGPTString, + type LEGACY_Playtype, +} from "tachi-common"; import { CreateFolderID, CreateTableID, ReadCollection, WriteCollection } from "../../util"; @@ -47,7 +58,9 @@ for (const entry of folders) { delete entry.folderID; } - entry.game = GPTStringToV3(GetGPTString(entry.game as GameGroup, entry.playtype as Playtype)); + entry.game = GPTStringToGame( + LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype), + ); delete entry.playtype; } @@ -69,7 +82,9 @@ for (const entry of tables) { delete entry.tableID; } - entry.game = GPTStringToV3(GetGPTString(entry.game as GameGroup, entry.playtype as Playtype)); + entry.game = GPTStringToGame( + LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype), + ); delete entry.playtype; // Rewrite folder references to new hex ids. diff --git a/typescript/seeds-scripts/rerunners/v3/5-folders-to-sql-queries.ts b/typescript/seeds-scripts/rerunners/v3/5-folders-to-sql-queries.ts index 949774545..9d88bfef6 100644 --- a/typescript/seeds-scripts/rerunners/v3/5-folders-to-sql-queries.ts +++ b/typescript/seeds-scripts/rerunners/v3/5-folders-to-sql-queries.ts @@ -1,7 +1,16 @@ import fs from "fs"; import path from "path"; +import { computeFolderSlug, type SeedFolderRow } from "tachi-common"; import { fileURLToPath } from "url"; +/** + * Converts legacy Mongo-style folder `data` into SQL `where` strings, then assigns + * `slug` per {@link computeFolderSlug} (required for Postgres `folder.slug`). + * + * After this, run `6-tables-folder-refs-to-slugs.ts` so `tables.json` lists folder + * slugs instead of ids (see `importSeeds` in the server). + */ + // ── Helpers ─────────────────────────────────────────────────────────────────── function sqlStr(value: string): string { @@ -424,5 +433,22 @@ for (const entry of folders) { delete entry.type; } +const slugKeys = new Map(); + +for (const entry of folders) { + const slug = computeFolderSlug(entry as SeedFolderRow); + const game = String(entry.game); + const key = `${game}\0${slug}`; + + if (slugKeys.has(key)) { + throw new Error( + `Duplicate slug "${slug}" for game ${game}: "${slugKeys.get(key)}" vs "${String(entry.title)}"`, + ); + } + + slugKeys.set(key, String(entry.title)); + entry.slug = slug; +} + fs.writeFileSync(FOLDERS_JSON, `${JSON.stringify(folders, null, "\t")}\n`); -console.log(`Wrote ${FOLDERS_JSON} (${folders.length} folders).`); +console.log(`Wrote ${FOLDERS_JSON} (${folders.length} folders, with slugs).`); diff --git a/typescript/seeds-scripts/rerunners/v3/6-bms-course-lookup-playtype-to-game.ts b/typescript/seeds-scripts/rerunners/v3/6-bms-course-lookup-playtype-to-game.ts new file mode 100644 index 000000000..2e43debae --- /dev/null +++ b/typescript/seeds-scripts/rerunners/v3/6-bms-course-lookup-playtype-to-game.ts @@ -0,0 +1,21 @@ +import { ReadCollection, WriteCollection } from "../../util"; + +const courses = ReadCollection("bms-course-lookup.json"); +let modified = 0; + +for (const entry of courses) { + if (entry.game !== undefined && entry.playtype === undefined) { + continue; + } + + if (entry.playtype === undefined) { + throw new Error(`Entry missing both playtype and game: ${JSON.stringify(entry)}`); + } + + entry.game = entry.playtype === "7K" ? "bms-7k" : "bms-14k"; + delete entry.playtype; + modified++; +} + +WriteCollection("bms-course-lookup.json", courses); +console.log(`bms-course-lookup.json: migrated ${modified} entries`); diff --git a/typescript/seeds-scripts/rerunners/v3/6-tables-folder-refs-to-slugs.ts b/typescript/seeds-scripts/rerunners/v3/6-tables-folder-refs-to-slugs.ts new file mode 100644 index 000000000..2c2211dd2 --- /dev/null +++ b/typescript/seeds-scripts/rerunners/v3/6-tables-folder-refs-to-slugs.ts @@ -0,0 +1,96 @@ +/** + * Rewrites `tables.json` so each `folders[]` entry is a folder **slug** (per game), + * not a hex folder id. Run after `5-folders-to-sql-queries.ts` (folders must have + * `where` and `slug`). + * + * Idempotent: if an entry is already a slug for that game, it is left unchanged. + */ + +import fs from "fs"; +import path from "path"; +import { computeFolderSlug, type SeedFolderRow } from "tachi-common"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SEEDS = path.join(__dirname, "../../../../db/seeds"); + +function resolvedFolderSlug(f: { + game: string; + id: string; + slug?: string; + title: string; + versionFilter?: Array; + where: string; +}): string { + if (f.slug !== undefined && f.slug !== "") { + return f.slug; + } + + return computeFolderSlug({ + game: f.game, + id: f.id, + title: f.title, + where: f.where, + versionFilter: f.versionFilter, + } as SeedFolderRow); +} + +const foldersPath = path.join(SEEDS, "folders.json"); +const tablesPath = path.join(SEEDS, "tables.json"); + +const folders = JSON.parse(fs.readFileSync(foldersPath, "utf-8")) as Array<{ + game: string; + id: string; + slug?: string; + title: string; + versionFilter?: Array; + where: string; +}>; + +const idToSlug = new Map(); +const slugByGame = new Map>(); + +for (const f of folders) { + const slug = resolvedFolderSlug(f); + + idToSlug.set(f.id, slug); + + const set = slugByGame.get(f.game) ?? new Set(); + + set.add(slug); + slugByGame.set(f.game, set); +} + +const tables = JSON.parse(fs.readFileSync(tablesPath, "utf-8")) as Array<{ + folders: Array; + game: string; + id: string; + title: string; +}>; + +for (const t of tables) { + const validSlugs = slugByGame.get(t.game); + + if (validSlugs === undefined) { + throw new Error(`Table "${t.title}": no folders for game ${JSON.stringify(t.game)}`); + } + + t.folders = t.folders.map((ref, i) => { + const asSlug = idToSlug.get(ref); + + if (asSlug !== undefined) { + return asSlug; + } + + if (validSlugs.has(ref)) { + return ref; + } + + throw new Error( + `Table "${t.title}" (${t.id}): folders[${i}] = ${JSON.stringify(ref)} is neither a known folder id nor a slug for game ${JSON.stringify(t.game)}`, + ); + }); +} + +fs.writeFileSync(tablesPath, `${JSON.stringify(tables, null, "\t")}\n`); +console.log(`Wrote ${tablesPath} (${tables.length} tables; folder refs are slugs).`); diff --git a/typescript/seeds-scripts/rerunners/wacca/parse-wplus.js b/typescript/seeds-scripts/rerunners/wacca/parse-wplus.js index 845bb2e17..dcbe5cc4d 100644 --- a/typescript/seeds-scripts/rerunners/wacca/parse-wplus.js +++ b/typescript/seeds-scripts/rerunners/wacca/parse-wplus.js @@ -35,7 +35,7 @@ const existingChartDocs = ReadCollection("charts-wacca.json"); const inGameIDToSongIDMap = new Map(); for (const chart of existingChartDocs) { - inGameIDToSongIDMap.set(chart.data.inGameID, chart.songID); + inGameIDToSongIDMap.set(chart.data.inGameID, chart.song.id); } for (const song of songdata) { diff --git a/typescript/seeds-scripts/sort-seeds.js b/typescript/seeds-scripts/sort-seeds.js index dc8687d18..0180f50ed 100644 --- a/typescript/seeds-scripts/sort-seeds.js +++ b/typescript/seeds-scripts/sort-seeds.js @@ -53,8 +53,8 @@ function TableSort(a, b) { } function BMSCourseSort(a, b) { - if (a.playtype !== b.playtype) { - return a.playtype.localeCompare(b.playtype); + if (a.game !== b.game) { + return a.game.localeCompare(b.game); } if (a.set !== b.set) { diff --git a/typescript/seeds-scripts/test/collections.test.ts b/typescript/seeds-scripts/test/collections.test.ts index 97bf19b0e..811dfb3ae 100644 --- a/typescript/seeds-scripts/test/collections.test.ts +++ b/typescript/seeds-scripts/test/collections.test.ts @@ -1,7 +1,7 @@ import chalk from "chalk"; import fs from "fs"; import path from "path"; -import { type GameGroup, V3ToGameGroup } from "tachi-common"; +import { type GameGroup, GameToGameGroup } from "tachi-common"; import { z } from "zod"; import { GetChartCollectionGame, GetSongCollectionGame, ReadCollection } from "../util"; @@ -54,7 +54,7 @@ for (const collection of collections) { throw new Error(`Collection passed was literally ${collection}, why?`); } - gameGroup = V3ToGameGroup(maybeGame); + gameGroup = GameToGameGroup(maybeGame); } for (const d of data) { diff --git a/typescript/seeds-scripts/test/custom.test.ts b/typescript/seeds-scripts/test/custom.test.ts index 8c8b4ff28..14344bbcb 100644 --- a/typescript/seeds-scripts/test/custom.test.ts +++ b/typescript/seeds-scripts/test/custom.test.ts @@ -2,10 +2,10 @@ import chalk from "chalk"; import fs from "fs"; import path from "path"; import { + type ChartDocument, type GameGroup, - type GPTStrings, - type MONGO_ChartDocument, - type MONGO_SongDocument, + type GamesForGroup, + type SongDocument, } from "tachi-common"; import { ReadCollection } from "../util"; @@ -26,7 +26,7 @@ function test(desc: string, fn: TestFn, reason?: FmtFn): Test { // TODO(zk): This is stratified on GameGroup when it // should really be on Game, alas, poor yorick, I knew // him well. -const CHART_CHECKS: { [G in GameGroup]?: Array>> } = { +const CHART_CHECKS: { [G in GameGroup]?: Array>> } = { iidx: [ test("Level should not be 0", (c) => c.level !== "0"), test("LevelNum should be an integer greater than 0 if level is known", (c) => @@ -110,7 +110,7 @@ const CHART_CHECKS: { [G in GameGroup]?: Array>> } = {}; +const SONG_CHECKS: { [G in GameGroup]?: Array>> } = {}; let exitCode = 0; const suites: Array<{ good: boolean; name: string; report: unknown }> = []; diff --git a/typescript/seeds-scripts/test/goal-id.test.ts b/typescript/seeds-scripts/test/goal-id.test.ts index 2d95d8f59..dd927cf5c 100644 --- a/typescript/seeds-scripts/test/goal-id.test.ts +++ b/typescript/seeds-scripts/test/goal-id.test.ts @@ -1,42 +1,3 @@ -import chalk from "chalk"; -import { type MONGO_GoalDocument } from "tachi-common"; - -import { CreateGoalID, ReadCollection } from "../util"; -import { FormatFunctions } from "./test-utils"; - // disabled for now console.log("Disabled for now"); process.exit(0); - -let success = 0; -let fails = 0; - -const goals = ReadCollection("goals.json", true) as MONGO_GoalDocument[]; - -for (const goal of goals) { - const pretty = FormatFunctions["goals.json"]!(goal, null); - - const expectedGoalID = CreateGoalID(goal.charts, goal.criteria, goal.game); - - if (expectedGoalID !== goal.goalID) { - console.error( - chalk.red( - `[ERR] | ${pretty} | Expected goalID to be ${expectedGoalID}. Got ${goal.goalID}.`, - ), - ); - - fails++; - } else { - success++; - } -} - -console.log(chalk[fails === 0 ? "green" : "red"](`[GOAL_ID]: ${success} good, ${fails} bad.`)); - -if (fails !== 0) { - console.log( - chalk.yellow("Run scripts/rerunners/fix-goal-ids.js to automatically fix these issues."), - ); -} - -process.exit(fails !== 0 ? 1 : 0); diff --git a/typescript/seeds-scripts/test/id.test.ts b/typescript/seeds-scripts/test/id.test.ts index 0ef746005..d87739247 100644 --- a/typescript/seeds-scripts/test/id.test.ts +++ b/typescript/seeds-scripts/test/id.test.ts @@ -2,12 +2,12 @@ import chalk from "chalk"; import fjsh from "fast-json-stable-hash"; import get from "lodash.get"; import { + ALL_GAMES, allSupportedGameGroups, type GameGroup, + GameToGameGroup, type integer, - v3AllGames, type V3Game, - V3ToGameGroup, } from "tachi-common"; import { GetChartCollectionGame, GetSongCollectionGame, ReadCollection } from "../util"; @@ -32,12 +32,12 @@ for (const gameGroup of allSupportedGameGroups) { SongChartKeys[`songs-${gameGroup}`] = ["id", "legacySongID"]; } -for (const v3Game of v3AllGames) { +for (const v3Game of ALL_GAMES) { SongChartKeys[`charts-${v3Game}`] = chartDupKeyDecls(); } const UniqueKeys: Record = { - "bms-course-lookup": [["set", "playtype", "value"]], + "bms-course-lookup": [["set", "game", "value"]], folders: ["id", "legacyFolderID"], tables: ["id", "legacyTableID"], questlines: ["questlineID"], @@ -110,7 +110,7 @@ function gameGroupForFormat(collection: string): GameGroup { if (collection.startsWith("charts-")) { const v3Game = GetChartCollectionGame(`${collection}.json`) as V3Game; - return V3ToGameGroup(v3Game); + return GameToGameGroup(v3Game); } throw new Error(`Expected songs-* or charts-* collection, got ${collection}`); diff --git a/typescript/seeds-scripts/test/match-type.test.ts b/typescript/seeds-scripts/test/match-type.test.ts index 4bb64012a..8b61ba14c 100644 --- a/typescript/seeds-scripts/test/match-type.test.ts +++ b/typescript/seeds-scripts/test/match-type.test.ts @@ -1,14 +1,13 @@ import chalk from "chalk"; import { - GetGPTConfig, + ALL_GAMES, + type ChartDocument, + FormatGame, + GameToGameGroup, + GetGameConfig, type MatchTypes, - type MONGO_ChartDocument, - type MONGO_SongDocument, - v3AllGames, - V3FormatGame, + type SongDocument, type V3Game, - V3ToGameGroup, - V3ToGPTString, } from "tachi-common"; import { ReadCollection } from "../util"; @@ -16,10 +15,10 @@ import { ReadCollection } from "../util"; // check that a given matchType works for a given game. const uniquenessChecks: Array<{ game: V3Game; matchType: MatchTypes }> = []; -for (const game of v3AllGames) { - const gptConfig = GetGPTConfig(V3ToGPTString(game)); +for (const game of ALL_GAMES) { + const gameConfig = GetGameConfig(game); - for (const matchType of gptConfig.supportedMatchTypes) { + for (const matchType of gameConfig.supportedMatchTypes) { uniquenessChecks.push({ game, matchType }); } } @@ -44,7 +43,7 @@ const MATCH_TYPE_CHECKS: Record< }, bmsChartHash: { type: "CHARTS", - fn: (c: MONGO_ChartDocument<"bms:7K" | "bms:14K">) => [c.data.hashMD5, c.data.hashSHA256], + fn: (c: ChartDocument<"bms-7k" | "bms-14k">) => [c.data.hashMD5, c.data.hashSHA256], }, inGameID: { type: "CHARTS", fn: (c) => `${c.data.inGameID}-${c.difficulty}` }, inGameStrID: { type: "CHARTS", fn: (c) => `${c.data.inGameStrID}-${c.difficulty}` }, @@ -65,7 +64,7 @@ const MATCH_TYPE_CHECKS: Record< uscChartHash: { type: "CHARTS", fn: (c) => c.data.hashSHA1 }, ddrSongHash: { type: "SONGS", - fn: (s: MONGO_SongDocument<"ddr">) => { + fn: (s: SongDocument<"ddr">) => { // if there's no ddrSongHash then it's a konaste song / we're missing seed data // so just use the inGameID if (s.data.ddrSongHash === undefined) { @@ -80,7 +79,7 @@ let exitCode = 0; const suites: Array<{ good: boolean; name: string; report: unknown }> = []; for (const { game, matchType } of uniquenessChecks) { - const name = `${V3FormatGame(game)} ${matchType}`; + const name = `${FormatGame(game)} ${matchType}`; console.log(`[CHECKING MATCHTYPE] ${name}.`); const handler = MATCH_TYPE_CHECKS[matchType]; @@ -92,7 +91,7 @@ for (const { game, matchType } of uniquenessChecks) { const data = handler.type === "CHARTS" ? ReadCollection(`charts-${game}.json`) - : ReadCollection(`songs-${V3ToGameGroup(game)}.json`); + : ReadCollection(`songs-${GameToGameGroup(game)}.json`); const uniqueIDs = new Set(); for (const el of data) { @@ -140,7 +139,7 @@ for (const { game, matchType } of uniquenessChecks) { if (matchType === "songTitle") { console.log( chalk.yellow( - `Song title ${maybeUnique} wasn't case-insensitively unique in ${V3FormatGame( + `Song title ${maybeUnique} wasn't case-insensitively unique in ${FormatGame( game, )}. Imports using this song title *will* have their scores rejected.`, ), @@ -149,7 +148,7 @@ for (const { game, matchType } of uniquenessChecks) { } else { console.log( chalk.red( - `ID ${maybeUnique} wasn't unique in ${V3FormatGame( + `ID ${maybeUnique} wasn't unique in ${FormatGame( game, )} (matchType=${matchType}). It needs to be for this matchType to be legal.`, ), diff --git a/typescript/seeds-scripts/test/references.test.ts b/typescript/seeds-scripts/test/references.test.ts index f5b29fea4..2d8dc4d92 100644 --- a/typescript/seeds-scripts/test/references.test.ts +++ b/typescript/seeds-scripts/test/references.test.ts @@ -2,11 +2,28 @@ // and no quest-goal desync, etc. import chalk from "chalk"; -import { type GameGroup, v3AllGames, type V3Game, V3ToGameGroup } from "tachi-common"; +import { + ALL_GAMES, + computeFolderSlug, + type GameGroup, + GameToGameGroup, + type SeedFolderRow, + type V3Game, +} from "tachi-common"; import { ReadCollection } from "../util"; import { FormatFunctions } from "./test-utils"; +function folderSeedSlug(folder: Record): string { + const f = folder as SeedFolderRow; + + if (typeof f.slug === "string" && f.slug !== "") { + return f.slug; + } + + return computeFolderSlug(f); +} + interface ReferenceCheck { base: string; parent: string; @@ -17,12 +34,12 @@ interface ReferenceCheck { } const refChecks: Array = [ - ...v3AllGames.map((game) => ({ + ...ALL_GAMES.map((game) => ({ base: `charts-${game}`, - parent: `songs-${V3ToGameGroup(game)}`, + parent: `songs-${GameToGameGroup(game)}`, baseKey: "songID", parentKey: "id", - gameGroup: V3ToGameGroup(game), + gameGroup: GameToGameGroup(game), game, })), { @@ -35,9 +52,9 @@ const refChecks: Array = [ }, { base: "tables", - baseKey: (table) => table.folders, + baseKey: (table) => table.folders.map((slug: string) => `${table.game}:${slug}`), parent: "folders", - parentKey: "id", + parentKey: (folder) => `${folder.game}:${folderSeedSlug(folder)}`, gameGroup: null, game: null, }, diff --git a/typescript/seeds-scripts/test/schemas.ts b/typescript/seeds-scripts/test/schemas.ts index 4b9fb8e51..7dd4627a0 100644 --- a/typescript/seeds-scripts/test/schemas.ts +++ b/typescript/seeds-scripts/test/schemas.ts @@ -1,11 +1,10 @@ import { + ALL_GAMES, allSupportedGameGroups, + GAME_CONFIGS, GAME_GROUP_CONFIGS, - GAME_PT_CONFIGS, type GameGroup, - v3AllGames, type V3Game, - V3ToGPTString, } from "tachi-common"; import { z, type ZodType } from "zod"; @@ -19,7 +18,7 @@ export type AllCollections = | `charts-${V3Game}.json` | `songs-${GameGroup}.json`; -export const V3_GAME_SCHEMA = z.enum(v3AllGames as [V3Game, ...Array]); +export const V3_GAME_SCHEMA = z.enum(ALL_GAMES as [V3Game, ...Array]); export const V3_GAME_GROUP_SCHEMA = z.enum( allSupportedGameGroups as [GameGroup, ...Array], ); @@ -55,7 +54,7 @@ export const V3_BMS_COURSE_LOOKUP_SCHEMA = z.strictObject({ md5sums: z.string(), title: z.string(), set: z.string(), - playtype: z.string(), + game: z.enum(["bms-7k", "bms-14k"]), value: z.string(), }); @@ -82,7 +81,7 @@ export const V3_SONG_SCHEMAS: Record<`songs-${GameGroup}.json`, ZodType> = Objec // even when k is a subset of string export const V3_CHART_SCHEMAS: Record<`charts-${V3Game}.json`, ZodType> = Object.fromEntries( - v3AllGames.map((game) => [ + ALL_GAMES.map((game) => [ `charts-${game}.json` as const, z.strictObject({ id: V3_TACHI_ID("C"), @@ -94,7 +93,7 @@ export const V3_CHART_SCHEMAS: Record<`charts-${V3Game}.json`, ZodType> = Object levelNum: z.number(), songID: V3_TACHI_ID("S"), versions: z.array(z.string()), - data: GAME_PT_CONFIGS[V3ToGPTString(game)].chartData, + data: GAME_CONFIGS[game].chartData, }), ]), ) as unknown as Record<`charts-${V3Game}.json`, ZodType>; diff --git a/typescript/seeds-scripts/test/table-default.test.ts b/typescript/seeds-scripts/test/table-default.test.ts index 4dd6ce7c9..ae22a6567 100644 --- a/typescript/seeds-scripts/test/table-default.test.ts +++ b/typescript/seeds-scripts/test/table-default.test.ts @@ -1,7 +1,7 @@ import type { z } from "zod"; import chalk from "chalk"; -import { v3AllGames, V3FormatGame } from "tachi-common"; +import { ALL_GAMES, FormatGame } from "tachi-common"; import { ReadCollection } from "../util"; import { type V3_TABLE_SCHEMA } from "./schemas"; @@ -16,7 +16,7 @@ for (const t of tables) { if (t.default && t.inactive) { console.log( chalk.red( - `[TABLE-DEFAULT] The default table for ${V3FormatGame(t.game)} '${ + `[TABLE-DEFAULT] The default table for ${FormatGame(t.game)} '${ t.title }' is inactive. This is not legal.`, ), @@ -28,7 +28,7 @@ for (const t of tables) { if (defaultTableMap[t.game]) { console.log( chalk.red( - `[TABLE-DEFAULT] There are multiple default tables for ${V3FormatGame( + `[TABLE-DEFAULT] There are multiple default tables for ${FormatGame( t.game, )}. This is not legal.`, ), @@ -39,7 +39,7 @@ for (const t of tables) { } } -for (const game of v3AllGames) { +for (const game of ALL_GAMES) { if (!defaultTableMap[game]) { console.log(chalk.red(`[TABLE-DEFAULT] There is no default table for ${game}.`)); errs += 1; diff --git a/typescript/seeds-scripts/test/test-utils.ts b/typescript/seeds-scripts/test/test-utils.ts index 5a8bf1a98..af0f2fe03 100644 --- a/typescript/seeds-scripts/test/test-utils.ts +++ b/typescript/seeds-scripts/test/test-utils.ts @@ -1,4 +1,4 @@ -import { allSupportedGameGroups, type GameGroup, v3AllGames } from "tachi-common"; +import { ALL_GAMES, allSupportedGameGroups, type GameGroup } from "tachi-common"; import { ReadCollection } from "../util"; import { type V3_SCHEMAS } from "./schemas"; @@ -37,7 +37,7 @@ for (const gameGroup of allSupportedGameGroups) { FormatFunctions[`songs-${gameGroup}.json`] = songFormat; } -for (const game of v3AllGames) { +for (const game of ALL_GAMES) { // TODO(zk): again just a part of this shitty hack // eslint-disable-next-line @typescript-eslint/no-explicit-any FormatFunctions[`charts-${game}.json`] = chartFormat as any; diff --git a/typescript/seeds-scripts/util.js b/typescript/seeds-scripts/util.js index 3ee939619..90ecd0b4a 100644 --- a/typescript/seeds-scripts/util.js +++ b/typescript/seeds-scripts/util.js @@ -107,6 +107,12 @@ function GetSongCollectionGameGroup(filename) { return result[1]; } +function GetFreshSongIDGenerator(gameGroup) { + const existing = ReadCollection(`songs-${gameGroup}.json`); + let max = existing.reduce((acc, s) => Math.max(acc, s.id ?? 0), 0); + return () => ++max; +} + module.exports = { CreateChartID, CreateFolderID, @@ -118,6 +124,7 @@ module.exports = { CreateTableID, EfficientInPlaceDeepmerge, GetChartCollectionGame, + GetFreshSongIDGenerator, GetSongCollectionGame: GetSongCollectionGameGroup, IterateCollections, MutateCollection, diff --git a/typescript/server/.env b/typescript/server/.env index e49619c97..354d991dc 100644 --- a/typescript/server/.env +++ b/typescript/server/.env @@ -5,7 +5,7 @@ PORT=8080 LOG_LEVEL="debug" MSGPACKR_NATIVE_ACCELERATION_DISABLED=true -POSTGRES_URL="postgresql://tachi:tachi@tachi-postgres/kamai" +POSTGRES_URL="postgresql://tachi:tachi@tachi-postgres/tachi_dev" MIGRATIONS_DIR="/tachi/db/migrations" REDIS_URL=tachi-redis @@ -14,6 +14,11 @@ PORT=8080 MIGRATIONS_DIR=/tachi/db/migrations LOG_LEVEL=debug +TACHI_EMAIL_FROM=dev@localhost +TACHI_EMAIL_AUTH_POSTMARK=false +TACHI_EMAIL_HOST=tachi-mailpit +TACHI_EMAIL_PORT=1025 +TACHI_EMAIL_SECURE=false TACHI_CLIENT_DEV_SERVER=http://localhost:3000 TACHI_MONGO_DATABASE_NAME=tachi-dev TACHI_CAPTCHA_SECRET_KEY=unused @@ -47,5 +52,5 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_BUCKET=tachi-private TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=us-east-1 TACHI_NAME=Tachi Local Dev TACHI_TYPE=omni -TACHI_GAMES=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki +TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx diff --git a/typescript/server/.env.test b/typescript/server/.env.test index 3c2e98571..c5096b824 100644 --- a/typescript/server/.env.test +++ b/typescript/server/.env.test @@ -40,5 +40,11 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_BUCKET=tachi-private TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=us-east-1 TACHI_NAME=Tachi Testing TACHI_TYPE=omni -TACHI_GAMES=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki +TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx + +TACHI_EMAIL_FROM=test@example.com +TACHI_EMAIL_AUTH_POSTMARK=false +TACHI_EMAIL_HOST=127.0.0.1 +TACHI_EMAIL_PORT=1025 +TACHI_EMAIL_SECURE=false diff --git a/typescript/server/package.json b/typescript/server/package.json index fffdd7603..e99947f77 100644 --- a/typescript/server/package.json +++ b/typescript/server/package.json @@ -1,13 +1,13 @@ { "name": "tachi-server", - "version": "2.5.1", + "version": "3.0.0", "description": "A score tracking server.", "private": true, "type": "module", "scripts": { "dev": "bun --watch src/main.ts", "test": "vitest", - "bench": "vitest bench --passWithNoTests", + "bench": "vitest bench", "build": "tsgo -b tsconfig.build.json -v", "typecheck": "tsgo --noEmit", "lint": "eslint ./src", @@ -93,7 +93,6 @@ "src/*/!(test-utils)/**/*.ts" ], "exclude": [ - "src/**/*.oldtest.ts", "src/external/mongo/schemas.ts" ] }, diff --git a/typescript/server/src/@types/express/index.d.ts b/typescript/server/src/@types/express/index.d.ts index e47f5a359..fbaddccf9 100644 --- a/typescript/server/src/@types/express/index.d.ts +++ b/typescript/server/src/@types/express/index.d.ts @@ -1,8 +1,7 @@ +import type { SYMBOL_TACHI_API_AUTH, SYMBOL_TACHI_DATA } from "#lib/constants/tachi"; +import type { TachiRequestData, TachiSessionData } from "#utils/types"; import type { Session, SessionData } from "express-session"; -import type { MONGO_APITokenDocument } from "tachi-common"; - -import type { SYMBOL_TACHI_API_AUTH, SYMBOL_TACHI_DATA } from "../../lib/constants/tachi"; -import type { TachiRequestData, TachiSessionData } from "../../utils/types"; +import type { APITokenDocument } from "tachi-common"; // this only exists for testing context - it doesn't seem to work properly otherwise. declare module "express-session" { @@ -21,7 +20,7 @@ declare global { // even though this is technically *not* present on every request // it's always assigned in the main router, so its functionally equivalent. - [SYMBOL_TACHI_API_AUTH]: MONGO_APITokenDocument; + [SYMBOL_TACHI_API_AUTH]: APITokenDocument; } } } diff --git a/typescript/server/src/@types/mongodb/index.d.ts b/typescript/server/src/@types/mongodb/index.d.ts index b8524e703..4d883ee21 100644 --- a/typescript/server/src/@types/mongodb/index.d.ts +++ b/typescript/server/src/@types/mongodb/index.d.ts @@ -1,5 +1,4 @@ declare module "mongodb" { - // eslint-disable-next-line @typescript-eslint/no-unused-vars interface FindOneOptions { projectID?: boolean; } diff --git a/typescript/server/src/actions/add-goal.test.ts b/typescript/server/src/actions/add-goal.test.ts index 3ff812f5e..ac5a9749e 100644 --- a/typescript/server/src/actions/add-goal.test.ts +++ b/typescript/server/src/actions/add-goal.test.ts @@ -1,6 +1,6 @@ import DB from "#services/pg/db"; import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures"; -import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db.js"; +import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db"; import { beforeEach, describe, expect, it } from "vitest"; import { ACTION_AddGoal } from "./add-goal"; @@ -20,8 +20,7 @@ describe("ACTION_AddGoal", () => { await expect( ACTION_AddGoal(taker, { userID: other.id, - game: "iidx", - playtype: "SP", + game: "iidx-sp", charts: { type: "single", data: "chart" }, criteria: { key: "lamp", value: 0, mode: "single" }, }), @@ -34,8 +33,7 @@ describe("ACTION_AddGoal", () => { const { goalID } = await ACTION_AddGoal(taker, { userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", charts: { type: "single", data: chartId }, criteria: { key: "lamp", value: 7, mode: "single" }, }); @@ -77,8 +75,7 @@ describe("ACTION_AddGoal", () => { await ACTION_AddGoal(taker, { userID: userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", charts: { type: "single", data: chartId }, criteria: { key: "lamp", value: 7, mode: "single" }, }); @@ -103,8 +100,7 @@ describe("ACTION_AddGoal", () => { await expect( ACTION_AddGoal(taker, { userID: userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", charts: { type: "single", data: chartId }, criteria: { key: "not_a_real_metric", value: 0, mode: "single" }, }), @@ -117,9 +113,8 @@ describe("ACTION_AddGoal", () => { await expect( ACTION_AddGoal(taker, { userID: userID, - game: "iidx", - playtype: "SP", - charts: { type: "single", data: "00000000-0000-0000-0000-00000000dead" }, + game: "iidx-sp", + charts: { type: "single", data: "C_fake" }, criteria: { key: "lamp", value: 7, mode: "single" }, }), ).rejects.toMatchObject({ code: 400 }); @@ -131,8 +126,7 @@ describe("ACTION_AddGoal", () => { const input = { userID: userID, - game: "iidx" as const, - playtype: "SP" as const, + game: "iidx-sp" as const, charts: { type: "single" as const, data: chartId }, criteria: { key: "lamp" as const, value: 7, mode: "single" as const }, }; diff --git a/typescript/server/src/actions/add-goal.ts b/typescript/server/src/actions/add-goal.ts index c4bc1cb8a..e69a9be0d 100644 --- a/typescript/server/src/actions/add-goal.ts +++ b/typescript/server/src/actions/add-goal.ts @@ -1,35 +1,25 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { SubscribeFailReasons } from "#lib/constants/err-codes.js"; -import { ServerConfig } from "#lib/setup/config.js"; -import { ConstructGoal, SubscribeToGoal } from "#lib/targets/goals.js"; -import DB from "#services/pg/db.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { SubscribeFailReasons } from "#lib/constants/err-codes"; +import { ServerConfig } from "#lib/setup/config"; +import { ConstructGoal, SubscribeToGoal } from "#lib/targets/goals"; +import DB from "#services/pg/db"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; import { sql } from "kysely"; -import { - type GameGroup, - GamePTToV3, - GetGamePTConfig, - type MONGO_GoalDocument, - type Playtype, -} from "tachi-common"; +import { GetGameConfig, type GoalDocument } from "tachi-common"; export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => { - const { userID, game: gameStr, playtype: playtypeStr, charts, criteria } = input; - const game = gameStr as GameGroup; - const playtype = playtypeStr as Playtype; + const { userID, game, charts, criteria } = input; if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) { throw new ExpectedErr(403, "You are not authorised to modify this user's goals."); } - const v3Game = GamePTToV3(game, playtype); - const row = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") .select(sql`count(*)::int`.as("c")) .where("goal_sub.user_id", "=", userID) - .where("goal.game", "=", v3Game) + .where("goal.game", "=", game) .executeTakeFirst(); const existingGoalsCount = Number(row?.c ?? 0); @@ -41,11 +31,11 @@ export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => { ); } - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); const validCriteria = [ - ...Object.keys(gptConfig.providedMetrics), - ...Object.keys(gptConfig.derivedMetrics), + ...Object.keys(gameConfig.providedMetrics), + ...Object.keys(gameConfig.derivedMetrics), ]; const criteriaKey = (criteria as { key?: string }).key; @@ -57,14 +47,13 @@ export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => { ); } - let goal: MONGO_GoalDocument; + let goal: GoalDocument; try { goal = await ConstructGoal( - charts as MONGO_GoalDocument["charts"], - criteria as MONGO_GoalDocument["criteria"], + charts as GoalDocument["charts"], + criteria as GoalDocument["criteria"], game, - playtype, ); } catch (e) { throw new ExpectedErr(400, (e as Error).message); diff --git a/typescript/server/src/actions/backsync-bms-pms-seeds.ts b/typescript/server/src/actions/backsync-bms-pms-seeds.ts index abbaff31f..a690ecaa3 100644 --- a/typescript/server/src/actions/backsync-bms-pms-seeds.ts +++ b/typescript/server/src/actions/backsync-bms-pms-seeds.ts @@ -1,76 +1,34 @@ -import type { GameGroup, MONGO_ChartDocument, MONGO_SongDocument } from "tachi-common"; +import type { ChartDocument, GameGroup, SongDocument } from "tachi-common"; /* eslint-disable no-await-in-loop */ -import { MakeAction } from "#lib/actions/actions.js"; -import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart.js"; -import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song.js"; -import { log } from "#lib/log/log.js"; +import { MakeAction } from "#lib/actions/actions"; +import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; +import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song"; +import { log } from "#lib/log/log"; import { PullDatabaseSeeds } from "#lib/seeds/repo"; -import DB from "#services/pg/db.js"; -import { IsUserAdmin } from "#utils/user.js"; +import DB from "#services/pg/db"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; -const FETCH_CHUNK = 3_000; - -async function loadAllSongsForGame(game: GameGroup): Promise> { - const out: Array = []; - let lastLegacy: number | undefined; - - for (;;) { - let q = DB.selectFrom("song") - .select(SELECT_SONG_DOCUMENT) - .where("game_group", "=", game) - .orderBy("legacy_id", "asc") - .limit(FETCH_CHUNK); - - if (lastLegacy !== undefined) { - q = q.where("legacy_id", ">", lastLegacy); - } - - const rows = await q.execute(); - - if (rows.length === 0) { - break; - } - - for (const row of rows) { - out.push(ToSongDocument(row)); - } - - lastLegacy = rows[rows.length - 1]!.song_legacy_id; - } +async function loadAllSongsForGame(game: GameGroup): Promise> { + const out = await DB.selectFrom("song") + .select(SELECT_SONG_DOCUMENT) + .where("game_group", "=", game) + .orderBy("id", "asc") + .execute() + .then((r) => r.map(ToSongDocument)); return out; } -async function loadAllChartsForGame(game: GameGroup): Promise> { - const out: Array = []; - let lastChartId: string | undefined; - - for (;;) { - let q = DB.selectFrom("chart") - .innerJoin("song", "song.id", "chart.song_id") - .select(SELECT_CHART) - .where("song.game_group", "=", game) - .orderBy("chart.id", "asc") - .limit(FETCH_CHUNK); - - if (lastChartId !== undefined) { - q = q.where("chart.id", ">", lastChartId); - } - - const rows = await q.execute(); - - if (rows.length === 0) { - break; - } - - for (const row of rows) { - out.push(ToChartDocument(row)); - } - - lastChartId = rows[rows.length - 1]!.chart_id; - } +async function loadAllChartsForGame(game: GameGroup): Promise> { + const out = await DB.selectFrom("chart") + .innerJoin("song", "song.id", "chart.song_id") + .select(SELECT_CHART) + .where("song.game_group", "=", game) + .orderBy("chart.id", "asc") + .execute() + .then((r) => r.map(ToChartDocument)); return out; } diff --git a/typescript/server/src/actions/bms-table-sync.test.ts b/typescript/server/src/actions/bms-table-sync.test.ts index 6059b891d..b2638270a 100644 --- a/typescript/server/src/actions/bms-table-sync.test.ts +++ b/typescript/server/src/actions/bms-table-sync.test.ts @@ -14,7 +14,7 @@ const { testMd5, mockLoadBMSTable, fakeTable } = vi.hoisted(() => { name: "TestTable", prefix: "★", asciiPrefix: "t", - playtype: "7K" as const, + game: "bms-7k" as const, url: "https://test.invalid/bms-table-unit", description: "unit test", }; diff --git a/typescript/server/src/actions/bms-table-sync.ts b/typescript/server/src/actions/bms-table-sync.ts index 1a3ce1be0..29f5e486d 100644 --- a/typescript/server/src/actions/bms-table-sync.ts +++ b/typescript/server/src/actions/bms-table-sync.ts @@ -1,5 +1,4 @@ /* eslint-disable no-await-in-loop */ -import type { Game } from "tachi-db"; import { MakeAction } from "#lib/actions/actions"; import { log } from "#lib/log/log"; @@ -14,20 +13,15 @@ import { sql } from "kysely"; import _ from "lodash"; import { BMS_TABLES, + type BMSGames, type BMSTableInfo, - GamePTToV3, - type MONGO_ChartDocument, - type MONGO_ChartDocumentData, - type Playtypes, + type ChartDocument, + type ChartDocumentData, } from "tachi-common"; const UPDATE_CHUNK = 500; -type BmsChartData = MONGO_ChartDocumentData["bms:7K"]; - -function v3GameForBmsPlaytype(playtype: Playtypes["bms"]): Game { - return GamePTToV3("bms", playtype) as Game; -} +type BmsChartData = ChartDocumentData["bms-7k"]; function stripTableFoldersKeySql(prefix: string) { return sql`jsonb_set( @@ -46,7 +40,7 @@ function stripTableFoldersKeySql(prefix: string) { */ async function HandleTableRemovals( tableEntries: Array, - playtype: Playtypes["bms"], + game: BMSGames, prefix: string, ) { if (tableEntries.length === 0) { @@ -56,12 +50,10 @@ async function HandleTableRemovals( return; } - const v3Game = v3GameForBmsPlaytype(playtype); - const rows = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(["chart.id as chart_id", "chart.data as chart_data"]) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where(sql`(chart.data::jsonb->'tableFolders') ? ${prefix}`) .execute(); @@ -115,15 +107,14 @@ async function HandleTableRemovals( async function ImportTableLevels( tableEntries: Array, prefix: string, - playtype: Playtypes["bms"], + game: BMSGames, ) { let failures = 0; let success = 0; const total = tableEntries.length; - const v3Game = v3GameForBmsPlaytype(playtype); - log.info(`Handling removals for ${playtype}:${prefix}...`); - await HandleTableRemovals(tableEntries, playtype, prefix); + log.info(`Handling removals for ${game}:${prefix}...`); + await HandleTableRemovals(tableEntries, game, prefix); const md5s = tableEntries.filter((e) => e.checksum.type === "md5").map((e) => e.checksum.value); const sha256s = tableEntries @@ -133,7 +124,7 @@ async function ImportTableLevels( if (md5s.length > 0) { await DB.updateTable("chart") .set({ data: stripTableFoldersKeySql(prefix) }) - .where("game", "=", v3Game) + .where("game", "=", game) .where(sql`(data::jsonb->>'hashMD5')::text`, "in", md5s) .execute(); } @@ -141,18 +132,20 @@ async function ImportTableLevels( if (sha256s.length > 0) { await DB.updateTable("chart") .set({ data: stripTableFoldersKeySql(prefix) }) - .where("game", "=", v3Game) + .where("game", "=", game) .where(sql`(data::jsonb->>'hashSHA256')::text`, "in", sha256s) .execute(); } for (const td of tableEntries) { - let chart: MONGO_ChartDocument<"bms:7K" | "bms:14K"> | null = - await FindBMSChartOnHashInGame(td.checksum.value, v3Game); + let chart: ChartDocument | null = await FindBMSChartOnHashInGame( + td.checksum.value, + game, + ); if (!chart) { chart = await DeorphanBmsIfInOrphanChartPg( - playtype, + game, td.checksum.type === "md5" ? "md5" : "sha256", td.checksum.value, ); @@ -196,7 +189,7 @@ async function ImportTableLevels( ? sql`jsonb_set(data::jsonb, '{tableString}', 'null'::jsonb)` : sql`jsonb_set(data::jsonb, '{tableString}', to_jsonb(${tableString}::text))`, }) - .where("legacy_id", "=", chart.songID) + .where("song.id", "=", chart.song.id) .where("song.game_group", "=", "bms") .execute(); @@ -211,7 +204,7 @@ export async function UpdateTable(tableInfo: BMSTableInfo) { const table = await LoadBMSTable(tableInfo.url); log.info(`Bumping levels...`); - await ImportTableLevels(table.body, tableInfo.prefix, tableInfo.playtype); + await ImportTableLevels(table.body, tableInfo.prefix, tableInfo.game); log.info(`Levels bumped.`); } diff --git a/typescript/server/src/actions/change-banner.ts b/typescript/server/src/actions/change-banner.ts index 53e3a7c53..59d1a0cd1 100644 --- a/typescript/server/src/actions/change-banner.ts +++ b/typescript/server/src/actions/change-banner.ts @@ -1,8 +1,8 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { CDNStoreOrOverwrite } from "#lib/cdn/cdn.js"; -import { GetProfileBannerURL } from "#lib/cdn/url-format.js"; -import DB from "#services/pg/db.js"; -import { HashSHA256 } from "#utils/crypto.js"; +import { MakeAction } from "#lib/actions/actions"; +import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { GetProfileBannerURL } from "#lib/cdn/url-format"; +import DB from "#services/pg/db"; +import { HashSHA256 } from "#utils/crypto"; import { ExpectedErr } from "bliss"; export const ACTION_ChangeBanner = MakeAction( diff --git a/typescript/server/src/actions/change-email.test.ts b/typescript/server/src/actions/change-email.test.ts index e881e82f6..8722dd682 100644 --- a/typescript/server/src/actions/change-email.test.ts +++ b/typescript/server/src/actions/change-email.test.ts @@ -1,7 +1,6 @@ -import { ServerConfig } from "#lib/setup/config"; import DB from "#services/pg/db"; import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { ACTION_ChangeEmail } from "./change-email"; @@ -129,7 +128,7 @@ describe("ACTION_ChangeEmail", () => { ).rejects.toMatchObject({ code: 409 }); }); - // ── Success path (no EMAIL_CONFIG) ──────────────────────────────────────── + // ── Success path ────────────────────────────────────────────────────────── it("returns an empty object on success", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; @@ -152,7 +151,33 @@ describe("ACTION_ChangeEmail", () => { expect(row.email).toBe(NEW_EMAIL); }); - it("does not insert a verify-email token when EMAIL_CONFIG is absent", async () => { + it("inserts a verify-email token for the new address", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const row = await DB.selectFrom("priv_verify_email_token") + .selectAll() + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.email).toBe(NEW_EMAIL); + }); + + it("generates a non-empty hex token", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const row = await DB.selectFrom("priv_verify_email_token") + .select("token") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.token).toMatch(/^[0-9a-f]{40}$/u); + }); + + it("keeps exactly one token row for the user after the change", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); @@ -162,7 +187,44 @@ describe("ACTION_ChangeEmail", () => { .where("user_id", "=", userId) .execute(); - expect(rows).toHaveLength(0); + expect(rows).toHaveLength(1); + }); + + it("replaces any pre-existing verify-email token", async () => { + await seedVerifyEmailToken(userId, "old@example.com", "OLD_TOKEN_ABCDEF1234"); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const rows = await DB.selectFrom("priv_verify_email_token") + .select(["token", "email"]) + .where("user_id", "=", userId) + .execute(); + + expect(rows).toHaveLength(1); + expect(rows[0]?.token).not.toBe("OLD_TOKEN_ABCDEF1234"); + expect(rows[0]?.email).toBe(NEW_EMAIL); + }); + + it("does not touch verify-email tokens belonging to other users", async () => { + const other = await seedUser({ + username: "other_user", + email: "other@example.com", + withCredential: true, + }); + await seedVerifyEmailToken(other.id, "other@example.com", "OTHER_TOKEN_XYZ"); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const row = await DB.selectFrom("priv_verify_email_token") + .select("token") + .where("user_id", "=", other.id) + .executeTakeFirstOrThrow(); + + expect(row.token).toBe("OTHER_TOKEN_XYZ"); }); it("does not affect other users' credential rows", async () => { @@ -184,97 +246,6 @@ describe("ACTION_ChangeEmail", () => { expect(row.email).toBe("other@example.com"); }); - // ── Success path (with EMAIL_CONFIG) ────────────────────────────────────── - - describe("when EMAIL_CONFIG is set", () => { - afterEach(() => { - // Restore the config after each test in this group. - (ServerConfig as Record).EMAIL_CONFIG = undefined; - }); - - beforeEach(() => { - (ServerConfig as Record).EMAIL_CONFIG = { - FROM: "noreply@example.com", - }; - }); - - it("inserts a verify-email token for the new address", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); - - const row = await DB.selectFrom("priv_verify_email_token") - .selectAll() - .where("user_id", "=", userId) - .executeTakeFirstOrThrow(); - - expect(row.email).toBe(NEW_EMAIL); - }); - - it("generates a non-empty hex token", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); - - const row = await DB.selectFrom("priv_verify_email_token") - .select("token") - .where("user_id", "=", userId) - .executeTakeFirstOrThrow(); - - expect(row.token).toMatch(/^[0-9a-f]{40}$/u); - }); - - it("keeps exactly one token row for the user after the change", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); - - const rows = await DB.selectFrom("priv_verify_email_token") - .select("token") - .where("user_id", "=", userId) - .execute(); - - expect(rows).toHaveLength(1); - }); - - it("replaces any pre-existing verify-email token", async () => { - await seedVerifyEmailToken(userId, "old@example.com", "OLD_TOKEN_ABCDEF1234"); - - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); - - const rows = await DB.selectFrom("priv_verify_email_token") - .select(["token", "email"]) - .where("user_id", "=", userId) - .execute(); - - expect(rows).toHaveLength(1); - expect(rows[0]?.token).not.toBe("OLD_TOKEN_ABCDEF1234"); - expect(rows[0]?.email).toBe(NEW_EMAIL); - }); - - it("does not touch verify-email tokens belonging to other users", async () => { - const other = await seedUser({ - username: "other_user", - email: "other@example.com", - withCredential: true, - }); - await seedVerifyEmailToken(other.id, "other@example.com", "OTHER_TOKEN_XYZ"); - - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); - - const row = await DB.selectFrom("priv_verify_email_token") - .select("token") - .where("user_id", "=", other.id) - .executeTakeFirstOrThrow(); - - expect(row.token).toBe("OTHER_TOKEN_XYZ"); - }); - }); - // ── Audit log ───────────────────────────────────────────────────────────── it("writes a GOOD action row to the audit log on success", async () => { diff --git a/typescript/server/src/actions/change-email.ts b/typescript/server/src/actions/change-email.ts index 4afe2afa0..6221af106 100644 --- a/typescript/server/src/actions/change-email.ts +++ b/typescript/server/src/actions/change-email.ts @@ -1,11 +1,10 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { PasswordCompare } from "#lib/auth/auth.js"; -import { SendEmail } from "#lib/email/client.js"; -import { EmailFormatVerifyEmail } from "#lib/email/formats.js"; -import { ServerConfig } from "#lib/setup/config.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; -import { CheckIfEmailInUse } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { PasswordCompare } from "#lib/auth/auth"; +import { SendEmail } from "#lib/email/client"; +import { EmailFormatVerifyEmail } from "#lib/email/formats"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; +import { CheckIfEmailInUse } from "#utils/user"; import { ExpectedErr, log } from "bliss"; export const ACTION_ChangeEmail = MakeAction( @@ -45,29 +44,24 @@ export const ACTION_ChangeEmail = MakeAction( .where("user_id", "=", taker.acct.id) .execute(); - if (ServerConfig.EMAIL_CONFIG) { - const resetEmailCode = Random20Hex(); + const resetEmailCode = Random20Hex(); - // clear out the previous email code! - await DB.deleteFrom("priv_verify_email_token") - .where("user_id", "=", taker.acct.id) - .execute(); + // clear out the previous email code! + await DB.deleteFrom("priv_verify_email_token") + .where("user_id", "=", taker.acct.id) + .execute(); - await DB.insertInto("priv_verify_email_token") - .values({ - email, - token: resetEmailCode, - user_id: taker.acct.id, - }) - .execute(); + await DB.insertInto("priv_verify_email_token") + .values({ + email, + token: resetEmailCode, + user_id: taker.acct.id, + }) + .execute(); - const { text, html } = EmailFormatVerifyEmail( - taker.acct.username, - resetEmailCode, - ); + const { text, html } = EmailFormatVerifyEmail(taker.acct.username, resetEmailCode); - void SendEmail(email, "Email Verification", html, text); - } + void SendEmail(email, "Email Verification", html, text); }); return {}; diff --git a/typescript/server/src/actions/change-password.ts b/typescript/server/src/actions/change-password.ts index e64a74096..8254b9f16 100644 --- a/typescript/server/src/actions/change-password.ts +++ b/typescript/server/src/actions/change-password.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { HashPassword, PasswordCompare } from "#lib/auth/auth.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import { HashPassword, PasswordCompare } from "#lib/auth/auth"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ACTION_ChangePassword = MakeAction( diff --git a/typescript/server/src/actions/change-pfp.ts b/typescript/server/src/actions/change-pfp.ts index d8842c861..a60af0db0 100644 --- a/typescript/server/src/actions/change-pfp.ts +++ b/typescript/server/src/actions/change-pfp.ts @@ -1,8 +1,8 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { CDNStoreOrOverwrite } from "#lib/cdn/cdn.js"; -import { GetProfilePictureURL } from "#lib/cdn/url-format.js"; -import DB from "#services/pg/db.js"; -import { HashSHA256 } from "#utils/crypto.js"; +import { MakeAction } from "#lib/actions/actions"; +import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { GetProfilePictureURL } from "#lib/cdn/url-format"; +import DB from "#services/pg/db"; +import { HashSHA256 } from "#utils/crypto"; import { ExpectedErr } from "bliss"; export const ACTION_ChangePfp = MakeAction( diff --git a/typescript/server/src/actions/change-username.ts b/typescript/server/src/actions/change-username.ts index d34f4e75d..a3c8fbfd6 100644 --- a/typescript/server/src/actions/change-username.ts +++ b/typescript/server/src/actions/change-username.ts @@ -1,8 +1,8 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { PasswordCompare } from "#lib/auth/auth.js"; -import DB from "#services/pg/db.js"; -import { NowISO8601 } from "#utils/time.js"; -import { CanChangeUsername } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { PasswordCompare } from "#lib/auth/auth"; +import DB from "#services/pg/db"; +import { NowISO8601 } from "#utils/time"; +import { CanChangeUsername } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_ChangeUsername = MakeAction( diff --git a/typescript/server/src/actions/create-api-client.ts b/typescript/server/src/actions/create-api-client.ts index 6502265d8..a0f59d8a7 100644 --- a/typescript/server/src/actions/create-api-client.ts +++ b/typescript/server/src/actions/create-api-client.ts @@ -1,9 +1,9 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { ServerConfig } from "#lib/setup/config.js"; -import DB from "#services/pg/db.js"; -import { DedupeArr, Random20Hex } from "#utils/misc.js"; -import { IsUserAdmin } from "#utils/user.js"; -import { validateWebhookUri } from "#utils/validate-webhook-uri.js"; +import { MakeAction } from "#lib/actions/actions"; +import { ServerConfig } from "#lib/setup/config"; +import DB from "#services/pg/db"; +import { DedupeArr, Random20Hex } from "#utils/misc"; +import { IsUserAdmin } from "#utils/user"; +import { validateWebhookUri } from "#utils/validate-webhook-uri"; import { ExpectedErr } from "bliss"; import { ALL_PERMISSIONS, type APIPermissions } from "tachi-common"; diff --git a/typescript/server/src/actions/create-api-token.ts b/typescript/server/src/actions/create-api-token.ts index 090cae478..43d14b98e 100644 --- a/typescript/server/src/actions/create-api-token.ts +++ b/typescript/server/src/actions/create-api-token.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; import { ExpectedErr } from "bliss"; import { ALL_PERMISSIONS } from "tachi-common"; diff --git a/typescript/server/src/actions/create-invite.ts b/typescript/server/src/actions/create-invite.ts index c9f61a52c..3f25335eb 100644 --- a/typescript/server/src/actions/create-invite.ts +++ b/typescript/server/src/actions/create-invite.ts @@ -1,9 +1,9 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user.js"; -import { GetTotalAllowedInvites } from "#lib/invites/invites.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; -import { ISO8601ToUnixMilliseconds } from "#utils/time.js"; +import { MakeAction } from "#lib/actions/actions"; +import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; +import { GetTotalAllowedInvites } from "#lib/invites/invites"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; +import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { ExpectedErr } from "bliss"; import { sql } from "kysely"; diff --git a/typescript/server/src/actions/create-oauth2-auth-code.ts b/typescript/server/src/actions/create-oauth2-auth-code.ts index 5e8c25421..2e04c9ba4 100644 --- a/typescript/server/src/actions/create-oauth2-auth-code.ts +++ b/typescript/server/src/actions/create-oauth2-auth-code.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; export const ACTION_CreateOAuth2AuthCode = MakeAction("CREATE_OAUTH2_AUTH_CODE", async (taker) => { const code = Random20Hex(); diff --git a/typescript/server/src/actions/customise-score.test.ts b/typescript/server/src/actions/customise-score.test.ts index 6f4381939..e37295ed4 100644 --- a/typescript/server/src/actions/customise-score.test.ts +++ b/typescript/server/src/actions/customise-score.test.ts @@ -1,8 +1,8 @@ -import { LoadScoreDocumentById } from "#lib/db-formats/score.js"; +import { LoadScoreDocumentById } from "#lib/db-formats/score"; import { mongoScoreDataToPg, pgScoreDataToMongo } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; -import { type MONGO_ScoreData } from "tachi-common"; +import { type ScoreData } from "tachi-common"; import { describe, expect, it } from "vitest"; import { ACTION_CustomiseScore } from "./customise-score"; @@ -17,9 +17,9 @@ describe("mergeScoreDataFromPg", () => { percent: 50, score: 200, optional: {}, - } as MONGO_ScoreData<"iidx:SP">; + } as ScoreData<"iidx-sp">; - const pg = mongoScoreDataToPg("iidx:SP", { ...original, judgements: {} }); + const pg = mongoScoreDataToPg("iidx-sp", { ...original, judgements: {} }); const back = pgScoreDataToMongo("iidx-sp", pg); expect(back).toMatchObject({ @@ -72,14 +72,14 @@ describe("ACTION_CustomiseScore", () => { }) .execute(); - const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { grade: "F", lamp: "FAILED", percent: 0, score: 100, optional: {}, judgements: {}, - } as MONGO_ScoreData<"iidx:SP">); + } as ScoreData<"iidx-sp">); await DB.insertInto("score") .values({ diff --git a/typescript/server/src/actions/customise-score.ts b/typescript/server/src/actions/customise-score.ts index 2df5ca21b..0b06bdc5f 100644 --- a/typescript/server/src/actions/customise-score.ts +++ b/typescript/server/src/actions/customise-score.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ACTION_CustomiseScore = MakeAction("CUSTOMISE_SCORE", async (_taker, input) => { diff --git a/typescript/server/src/actions/delete-all-notifications.ts b/typescript/server/src/actions/delete-all-notifications.ts index 22ee75673..0953a45ef 100644 --- a/typescript/server/src/actions/delete-all-notifications.ts +++ b/typescript/server/src/actions/delete-all-notifications.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { ONE_SECOND } from "#lib/constants/time.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import { ONE_SECOND } from "#lib/constants/time"; +import DB from "#services/pg/db"; export const ACTION_DeleteAllNotifications = MakeAction( "DELETE_ALL_NOTIFICATIONS", diff --git a/typescript/server/src/actions/delete-api-client.ts b/typescript/server/src/actions/delete-api-client.ts index cf5b3a35b..5796806d3 100644 --- a/typescript/server/src/actions/delete-api-client.ts +++ b/typescript/server/src/actions/delete-api-client.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ACTION_DeleteApiClient = MakeAction( diff --git a/typescript/server/src/actions/delete-api-token.ts b/typescript/server/src/actions/delete-api-token.ts index a283ccdc5..fb53e067b 100644 --- a/typescript/server/src/actions/delete-api-token.ts +++ b/typescript/server/src/actions/delete-api-token.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ACTION_DeleteApiToken = MakeAction("DELETE_API_TOKEN", async (taker, { token }) => { diff --git a/typescript/server/src/actions/delete-banner.ts b/typescript/server/src/actions/delete-banner.ts index 785d7c2ee..fd083cc7c 100644 --- a/typescript/server/src/actions/delete-banner.ts +++ b/typescript/server/src/actions/delete-banner.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { CDNDelete } from "#lib/cdn/cdn.js"; -import { GetProfileBannerURL } from "#lib/cdn/url-format.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import { CDNDelete } from "#lib/cdn/cdn"; +import { GetProfileBannerURL } from "#lib/cdn/url-format"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ACTION_DeleteBanner = MakeAction("DELETE_BANNER", async (taker) => { diff --git a/typescript/server/src/actions/delete-cg-card-info.ts b/typescript/server/src/actions/delete-cg-card-info.ts index 6be296595..080ddf59b 100644 --- a/typescript/server/src/actions/delete-cg-card-info.ts +++ b/typescript/server/src/actions/delete-cg-card-info.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; export const ACTION_DeleteCgCardInfo = MakeAction( "DELETE_CG_CARD_INFO", diff --git a/typescript/server/src/actions/delete-import.ts b/typescript/server/src/actions/delete-import.ts index 345aeec4c..ca78f9e74 100644 --- a/typescript/server/src/actions/delete-import.ts +++ b/typescript/server/src/actions/delete-import.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { LoadImportDocumentById } from "#lib/db-formats/import-document.js"; -import { RevertImport } from "#lib/imports/imports.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { LoadImportDocumentById } from "#lib/db-formats/import-document"; +import { RevertImport } from "#lib/imports/imports"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_DeleteImport = MakeAction("DELETE_IMPORT", async (taker, { id }) => { diff --git a/typescript/server/src/actions/delete-myt-card-info.ts b/typescript/server/src/actions/delete-myt-card-info.ts index 443a5765a..71e55d2b9 100644 --- a/typescript/server/src/actions/delete-myt-card-info.ts +++ b/typescript/server/src/actions/delete-myt-card-info.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; export const ACTION_DeleteMytCardInfo = MakeAction( "DELETE_MYT_CARD_INFO", diff --git a/typescript/server/src/actions/delete-pfp.ts b/typescript/server/src/actions/delete-pfp.ts index 876c7026c..15c40b6b7 100644 --- a/typescript/server/src/actions/delete-pfp.ts +++ b/typescript/server/src/actions/delete-pfp.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { CDNDelete } from "#lib/cdn/cdn.js"; -import { GetProfilePictureURL } from "#lib/cdn/url-format.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import { CDNDelete } from "#lib/cdn/cdn"; +import { GetProfilePictureURL } from "#lib/cdn/url-format"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ACTION_DeletePfp = MakeAction("DELETE_PFP", async (taker) => { diff --git a/typescript/server/src/actions/delete-score.test.ts b/typescript/server/src/actions/delete-score.test.ts index 313ba4517..c566a4e5c 100644 --- a/typescript/server/src/actions/delete-score.test.ts +++ b/typescript/server/src/actions/delete-score.test.ts @@ -1,7 +1,7 @@ import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; -import { type MONGO_ScoreData } from "tachi-common"; +import { type ScoreData } from "tachi-common"; import { describe, expect, it } from "vitest"; import { ACTION_DeleteScore } from "./delete-score"; @@ -45,14 +45,14 @@ describe("ACTION_DeleteScore", () => { }) .execute(); - const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { grade: "F", lamp: "FAILED", percent: 0, score: 100, optional: {}, judgements: {}, - } as MONGO_ScoreData<"iidx:SP">); + } as ScoreData<"iidx-sp">); await DB.insertInto("score") .values({ diff --git a/typescript/server/src/actions/delete-score.ts b/typescript/server/src/actions/delete-score.ts index 1c5ce3dba..f68869a05 100644 --- a/typescript/server/src/actions/delete-score.ts +++ b/typescript/server/src/actions/delete-score.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { LoadScoreDocumentById } from "#lib/db-formats/score.js"; -import { DeleteScore } from "#lib/score-mutation/delete-scores.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { LoadScoreDocumentById } from "#lib/db-formats/score"; +import { DeleteScore } from "#lib/score-mutation/delete-scores"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_DeleteScore = MakeAction("DELETE_SCORE", async (taker, { id, blacklist }) => { diff --git a/typescript/server/src/actions/delete-session.test.ts b/typescript/server/src/actions/delete-session.test.ts index f53981d3a..409d9019d 100644 --- a/typescript/server/src/actions/delete-session.test.ts +++ b/typescript/server/src/actions/delete-session.test.ts @@ -1,7 +1,7 @@ import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; -import { type MONGO_ScoreData } from "tachi-common"; +import { type ScoreData } from "tachi-common"; import { describe, expect, it } from "vitest"; import { ACTION_DeleteSession } from "./delete-session"; @@ -46,14 +46,14 @@ describe("ACTION_DeleteSession", () => { }) .execute(); - const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { grade: "F", lamp: "FAILED", percent: 0, score: 100, optional: {}, judgements: {}, - } as MONGO_ScoreData<"iidx:SP">); + } as ScoreData<"iidx-sp">); await DB.insertInto("session") .values({ diff --git a/typescript/server/src/actions/delete-session.ts b/typescript/server/src/actions/delete-session.ts index a538ce067..e2b815982 100644 --- a/typescript/server/src/actions/delete-session.ts +++ b/typescript/server/src/actions/delete-session.ts @@ -1,8 +1,8 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { LoadSessionDocumentById } from "#lib/db-formats/session.js"; -import { DeleteMultipleScores } from "#lib/score-mutation/delete-scores.js"; -import { GetScoresFromSession } from "#utils/session.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { LoadSessionDocumentById } from "#lib/db-formats/session"; +import { DeleteMultipleScores } from "#lib/score-mutation/delete-scores"; +import { GetScoresFromSession } from "#utils/session"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_DeleteSession = MakeAction("DELETE_SESSION", async (taker, { id }) => { diff --git a/typescript/server/src/actions/follow-user.test.ts b/typescript/server/src/actions/follow-user.test.ts index a57751551..361c76bd1 100644 --- a/typescript/server/src/actions/follow-user.test.ts +++ b/typescript/server/src/actions/follow-user.test.ts @@ -1,7 +1,7 @@ import { ServerConfig } from "#lib/setup/config"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { ACTION_FollowUser } from "./follow-user"; @@ -23,13 +23,13 @@ describe("ACTION_FollowUser", () => { it("throws when userID is 0 (not a positive integer)", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await expect(ACTION_FollowUser(taker, { userID: 0 })).rejects.toThrow("invalid input"); + await expect(ACTION_FollowUser(taker, { userID: 0 })).rejects.toMatchObject({ code: 400 }); }); it("throws when userID is negative", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await expect(ACTION_FollowUser(taker, { userID: -1 })).rejects.toThrow("invalid input"); + await expect(ACTION_FollowUser(taker, { userID: -1 })).rejects.toMatchObject({ code: 400 }); }); // ── Self-follow guard ───────────────────────────────────────────────────── diff --git a/typescript/server/src/actions/follow-user.ts b/typescript/server/src/actions/follow-user.ts index 6fc7044f1..0e973fba4 100644 --- a/typescript/server/src/actions/follow-user.ts +++ b/typescript/server/src/actions/follow-user.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { ServerConfig } from "#lib/setup/config.js"; -import DB from "#services/pg/db.js"; -import { GetUserWithID } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { ServerConfig } from "#lib/setup/config"; +import DB from "#services/pg/db"; +import { GetUserWithID } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_FollowUser = MakeAction("FOLLOW_USER", async (taker, { userID: toFollow }) => { diff --git a/typescript/server/src/actions/mark-all-notifications-read.ts b/typescript/server/src/actions/mark-all-notifications-read.ts index e8a36cb45..084e63ea2 100644 --- a/typescript/server/src/actions/mark-all-notifications-read.ts +++ b/typescript/server/src/actions/mark-all-notifications-read.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { ONE_SECOND } from "#lib/constants/time.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import { ONE_SECOND } from "#lib/constants/time"; +import DB from "#services/pg/db"; export const ACTION_MarkAllNotificationsRead = MakeAction( "MARK_ALL_NOTIFICATIONS_READ", diff --git a/typescript/server/src/actions/patch-ugpt-settings.test.ts b/typescript/server/src/actions/patch-ugpt-settings.test.ts index f2e19d105..111454453 100644 --- a/typescript/server/src/actions/patch-ugpt-settings.test.ts +++ b/typescript/server/src/actions/patch-ugpt-settings.test.ts @@ -1,5 +1,5 @@ import { SELECT_ACTION } from "#lib/db-formats/action"; -import { GetUGPTSettingsDocument, SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings.js"; +import { GetUGPTSettingsDocument, SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; @@ -33,12 +33,11 @@ describe("ACTION_PatchUGPTSettings", () => { await ACTION_PatchUGPTSettings(taker, { userID: userId, - game: "iidx", - playtype: "SP", + game: "iidx-sp", preferences: { preferredScoreAlg: "ktLampRating" }, }); - const settings = await GetUGPTSettingsDocument(userId, "iidx", "SP"); + const settings = await GetUGPTSettingsDocument(userId, "iidx-sp"); expect(settings?.preferences.preferredScoreAlg).toBe("ktLampRating"); @@ -56,8 +55,7 @@ describe("ACTION_PatchUGPTSettings", () => { await ACTION_PatchUGPTSettings(taker, { userID: userId, - game: "iidx", - playtype: "SP", + game: "iidx-sp", preferences: { preferredProfileAlg: "ktRating" }, }); @@ -76,8 +74,7 @@ describe("ACTION_PatchUGPTSettings", () => { await expect( ACTION_PatchUGPTSettings(taker, { userID: other.id, - game: "iidx", - playtype: "SP", + game: "iidx-sp", preferences: { preferredScoreAlg: "ktLampRating" }, }), ).rejects.toMatchObject({ code: 403 }); diff --git a/typescript/server/src/actions/patch-ugpt-settings.ts b/typescript/server/src/actions/patch-ugpt-settings.ts index 3efeaa062..f17068c9a 100644 --- a/typescript/server/src/actions/patch-ugpt-settings.ts +++ b/typescript/server/src/actions/patch-ugpt-settings.ts @@ -1,32 +1,23 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings"; -import DB from "#services/pg/db.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { GetUGPTSettingsDocument, SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings"; +import DB from "#services/pg/db"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; -import { - type GameGroup, - GamePTToV3, - type MONGO_UGPTSettingsDocument, - type Playtype, -} from "tachi-common"; +import { type UGPTSettingsDocument } from "tachi-common"; import { type GameSettingsUpdate } from "tachi-db"; export const ACTION_PatchUGPTSettings = MakeAction("PATCH_UGPT_SETTINGS", async (taker, input) => { - const { userID, game: gameStr, playtype: playtypeStr, preferences } = input; - const game = gameStr as GameGroup; - const playtype = playtypeStr as Playtype; - const body = preferences as Partial; + const { userID, game, preferences } = input; + const body = preferences as Partial; if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) { throw new ExpectedErr(403, "You are not authorised to modify this user's settings."); } - const v3Game = GamePTToV3(game, playtype); - if (typeof body.defaultTable === "string") { const table = await DB.selectFrom("table") .select("id") - .where("game", "=", v3Game) + .where("game", "=", game) .where("legacy_id", "=", body.defaultTable) .executeTakeFirst(); @@ -54,13 +45,17 @@ export const ACTION_PatchUGPTSettings = MakeAction("PATCH_UGPT_SETTINGS", async hasGameSpecificKeys; if (!hasUpdates) { - return {}; + const settings = await GetUGPTSettingsDocument(userID, game); + if (!settings) { + throw new ExpectedErr(404, "You do not have an account for this game."); + } + return { settings }; } const row = await DB.selectFrom("game_settings") .select(SELECT_GAME_SETTINGS) .where("game_settings.user_id", "=", userID) - .where("game_settings.game", "=", v3Game) + .where("game_settings.game", "=", game) .executeTakeFirst(); if (!row) { @@ -97,14 +92,22 @@ export const ACTION_PatchUGPTSettings = MakeAction("PATCH_UGPT_SETTINGS", async } if (Object.keys(set).length === 0) { - return {}; + const settings = await GetUGPTSettingsDocument(userID, game); + if (!settings) { + throw new ExpectedErr(404, "You do not have an account for this game."); + } + return { settings }; } await DB.updateTable("game_settings") .set(set) .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .execute(); - return {}; + const settings = await GetUGPTSettingsDocument(userID, game); + if (!settings) { + throw new ExpectedErr(500, "Settings were updated but could not be reloaded."); + } + return { settings }; }); diff --git a/typescript/server/src/actions/rebuild-folder-chart-lookup.ts b/typescript/server/src/actions/rebuild-folder-chart-lookup.ts index 7ace653c2..ec651cdb3 100644 --- a/typescript/server/src/actions/rebuild-folder-chart-lookup.ts +++ b/typescript/server/src/actions/rebuild-folder-chart-lookup.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup.js"; -import DB from "#services/pg/db.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup"; +import DB from "#services/pg/db"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_RebuildFolderChartLookup = MakeAction( diff --git a/typescript/server/src/actions/remove-goal-subscription.test.ts b/typescript/server/src/actions/remove-goal-subscription.test.ts index bee8082aa..9e4cfdacb 100644 --- a/typescript/server/src/actions/remove-goal-subscription.test.ts +++ b/typescript/server/src/actions/remove-goal-subscription.test.ts @@ -20,8 +20,7 @@ describe("ACTION_RemoveGoalSubscription", () => { await expect( ACTION_RemoveGoalSubscription(taker, { userID: other.id, - game: "iidx", - playtype: "SP", + game: "iidx-sp", goalID: "Gfake", }), ).rejects.toMatchObject({ code: 403 }); @@ -33,8 +32,7 @@ describe("ACTION_RemoveGoalSubscription", () => { await expect( ACTION_RemoveGoalSubscription(taker, { userID: userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", goalID: "Gnonexistent_goal_id_xyz", }), ).rejects.toMatchObject({ code: 404 }); @@ -46,16 +44,14 @@ describe("ACTION_RemoveGoalSubscription", () => { const { goalID } = await ACTION_AddGoal(taker, { userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", charts: { type: "single", data: chartId }, criteria: { key: "lamp", value: 7, mode: "single" }, }); await ACTION_RemoveGoalSubscription(taker, { userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", goalID, }); @@ -74,16 +70,14 @@ describe("ACTION_RemoveGoalSubscription", () => { const { goalID } = await ACTION_AddGoal(taker, { userID: userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", charts: { type: "single", data: chartId }, criteria: { key: "lamp", value: 7, mode: "single" }, }); await ACTION_RemoveGoalSubscription(taker, { userID: userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", goalID, }); @@ -106,8 +100,7 @@ describe("ACTION_RemoveGoalSubscription", () => { await expect( ACTION_RemoveGoalSubscription(taker, { userID: userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", goalID: "Gmissing_subscription", }), ).rejects.toMatchObject({ code: 404 }); diff --git a/typescript/server/src/actions/remove-goal-subscription.ts b/typescript/server/src/actions/remove-goal-subscription.ts index f14cdb12d..4867a8fac 100644 --- a/typescript/server/src/actions/remove-goal-subscription.ts +++ b/typescript/server/src/actions/remove-goal-subscription.ts @@ -1,32 +1,27 @@ -import { MakeAction } from "#lib/actions/actions.js"; +import { MakeAction } from "#lib/actions/actions"; import { SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; -import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents.js"; -import { UnsubscribeFromGoal } from "#lib/targets/goals.js"; -import DB from "#services/pg/db.js"; -import { staticAssertUnreachable } from "#utils/misc.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents"; +import { UnsubscribeFromGoal } from "#lib/targets/goals"; +import DB from "#services/pg/db"; +import { staticAssertUnreachable } from "#utils/misc"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; -import { type GameGroup, GamePTToV3, type Playtype } from "tachi-common"; export const ACTION_RemoveGoalSubscription = MakeAction( "REMOVE_GOAL_SUBSCRIPTION", async (taker, input) => { - const { userID, game: gameStr, playtype: playtypeStr, goalID } = input; - const game = gameStr as GameGroup; - const playtype = playtypeStr as Playtype; + const { userID, game, goalID } = input; if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) { throw new ExpectedErr(403, "You are not authorised to modify this user's goals."); } - const v3Game = GamePTToV3(game, playtype); - const row = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) .where("goal_sub.goal_id", "=", goalID) - .where("goal.game", "=", v3Game) + .where("goal.game", "=", game) .executeTakeFirst(); if (!row) { diff --git a/typescript/server/src/actions/reset-api-client-secret.ts b/typescript/server/src/actions/reset-api-client-secret.ts index 23a297d98..f14459ed3 100644 --- a/typescript/server/src/actions/reset-api-client-secret.ts +++ b/typescript/server/src/actions/reset-api-client-secret.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; -import { GetClientByID } from "#utils/queries/api-clients.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; +import { GetClientByID } from "#utils/queries/api-clients"; import { ExpectedErr } from "bliss"; export const ACTION_ResetApiClientSecret = MakeAction( diff --git a/typescript/server/src/actions/revoke-kai-auth-token.ts b/typescript/server/src/actions/revoke-kai-auth-token.ts index 0b2554bfa..555ffa0df 100644 --- a/typescript/server/src/actions/revoke-kai-auth-token.ts +++ b/typescript/server/src/actions/revoke-kai-auth-token.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; export const ACTION_RevokeKaiAuthToken = MakeAction( "REVOKE_KAI_AUTH_TOKEN", diff --git a/typescript/server/src/actions/set-rivals.test.ts b/typescript/server/src/actions/set-rivals.test.ts index 478502e18..43593050a 100644 --- a/typescript/server/src/actions/set-rivals.test.ts +++ b/typescript/server/src/actions/set-rivals.test.ts @@ -38,8 +38,7 @@ describe("ACTION_SetRivals", () => { await ACTION_SetRivals(taker, { userID: userId, - game: "iidx", - playtype: "SP", + game: "iidx-sp", rivalIDs: [rivalId], }); @@ -58,8 +57,7 @@ describe("ACTION_SetRivals", () => { await ACTION_SetRivals(taker, { userID: userId, - game: "iidx", - playtype: "SP", + game: "iidx-sp", rivalIDs: [rivalId], }); @@ -78,8 +76,7 @@ describe("ACTION_SetRivals", () => { await expect( ACTION_SetRivals(taker, { userID: other.id, - game: "iidx", - playtype: "SP", + game: "iidx-sp", rivalIDs: [rivalId], }), ).rejects.toMatchObject({ code: 403 }); @@ -91,8 +88,7 @@ describe("ACTION_SetRivals", () => { await expect( ACTION_SetRivals(taker, { userID: userId, - game: "iidx", - playtype: "SP", + game: "iidx-sp", rivalIDs: [userId], }), ).rejects.toMatchObject({ code: 400 }); @@ -129,8 +125,7 @@ describe("ACTION_SetRivals", () => { await expect( ACTION_SetRivals(taker, { userID: main.id, - game: "iidx", - playtype: "SP", + game: "iidx-sp", rivalIDs: rivalUsers.map((u) => u.id), }), ).rejects.toMatchObject({ code: 400 }); diff --git a/typescript/server/src/actions/set-rivals.ts b/typescript/server/src/actions/set-rivals.ts index eb1043bef..7cbc4e3a3 100644 --- a/typescript/server/src/actions/set-rivals.ts +++ b/typescript/server/src/actions/set-rivals.ts @@ -1,24 +1,17 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { SetRivalsFailReasons } from "#lib/constants/err-codes.js"; -import { setRivalsWithResult } from "#lib/rivals/rivals.js"; -import { staticAssertUnreachable } from "#utils/misc.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { SetRivalsFailReasons } from "#lib/constants/err-codes"; +import { setRivalsWithResult } from "#lib/rivals/rivals"; +import { staticAssertUnreachable } from "#utils/misc"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; -import { FormatGameGroup, type GameGroup, type Playtype } from "tachi-common"; +import { type V3Game } from "tachi-common"; -function throwIfSetRivalsFailed( - game: GameGroup, - playtype: Playtype, - reason: SetRivalsFailReasons, -): never { +function throwIfSetRivalsFailed(game: V3Game, reason: SetRivalsFailReasons): never { switch (reason) { case SetRivalsFailReasons.RIVALED_SELF: throw new ExpectedErr(400, `You cannot rival yourself.`); case SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT: - throw new ExpectedErr( - 400, - `Not all of the rivals you specified have played ${FormatGameGroup(game, playtype)}.`, - ); + throw new ExpectedErr(400, `Not all of the rivals you specified have played ${game}.`); case SetRivalsFailReasons.TOO_MANY: throw new ExpectedErr(400, `You can't set more than 5 rivals.`); default: @@ -27,17 +20,15 @@ function throwIfSetRivalsFailed( } export const ACTION_SetRivals = MakeAction("SET_RIVALS", async (taker, input) => { - const { userID, game: gameStr, playtype: playtypeStr, rivalIDs } = input; - const game = gameStr as GameGroup; - const playtype = playtypeStr as Playtype; + const { userID, game, rivalIDs } = input; if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) { throw new ExpectedErr(403, "You are not authorised to modify this user's rivals."); } - const err = await setRivalsWithResult(userID, game, playtype, rivalIDs); + const err = await setRivalsWithResult(userID, game, rivalIDs); if (err !== null) { - throwIfSetRivalsFailed(game, playtype, err); + throwIfSetRivalsFailed(game, err); } return {}; diff --git a/typescript/server/src/actions/set-user-supporter-status.test.ts b/typescript/server/src/actions/set-user-supporter-status.test.ts index d9fbc0641..0f6697052 100644 --- a/typescript/server/src/actions/set-user-supporter-status.test.ts +++ b/typescript/server/src/actions/set-user-supporter-status.test.ts @@ -28,16 +28,16 @@ describe("ACTION_SetUserSupporterStatus", () => { it("throws when userID is 0 (not a positive integer)", async () => { await expect( ACTION_SetUserSupporterStatus(adminTaker(), { userID: 0, isSupporter: true }), - ).rejects.toThrow(/SET_USER_SUPPORTER_STATUS received invalid input/u); + ).rejects.toMatchObject({ code: 400 }); }); it("throws when userID is negative", async () => { await expect( ACTION_SetUserSupporterStatus(adminTaker(), { userID: -1, isSupporter: true }), - ).rejects.toThrow(/SET_USER_SUPPORTER_STATUS received invalid input/u); + ).rejects.toMatchObject({ code: 400 }); }); - it("writes a THROW action row when input fails Zod validation", async () => { + it("writes a BAD action row when input fails Zod validation", async () => { await expect( ACTION_SetUserSupporterStatus(adminTaker("9.9.9.9"), { userID: 0, isSupporter: true }), ).rejects.toThrow(); @@ -50,7 +50,7 @@ describe("ACTION_SetUserSupporterStatus", () => { expect(action).toMatchObject({ kind: "SET_USER_SUPPORTER_STATUS", - result: "THROW", + result: "BAD", ip: "9.9.9.9", }); }); diff --git a/typescript/server/src/actions/set-user-supporter-status.ts b/typescript/server/src/actions/set-user-supporter-status.ts index 419a8f420..5ed5b8796 100644 --- a/typescript/server/src/actions/set-user-supporter-status.ts +++ b/typescript/server/src/actions/set-user-supporter-status.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_SetUserSupporterStatus = MakeAction( diff --git a/typescript/server/src/actions/test-utils/api-tokens.ts b/typescript/server/src/actions/test-utils/api-tokens.ts index 8aa0d2405..8ed48f46b 100644 --- a/typescript/server/src/actions/test-utils/api-tokens.ts +++ b/typescript/server/src/actions/test-utils/api-tokens.ts @@ -1,5 +1,8 @@ import { SELECT_API_TOKEN } from "#lib/db-formats/api-token"; import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; + +export { seedUser }; // ─── seedApiClient ──────────────────────────────────────────────────────────── diff --git a/typescript/server/src/actions/ugs-snapshot.ts b/typescript/server/src/actions/ugs-snapshot.ts index e13bcde45..b65222a3e 100644 --- a/typescript/server/src/actions/ugs-snapshot.ts +++ b/typescript/server/src/actions/ugs-snapshot.ts @@ -1,13 +1,13 @@ import type { Game } from "tachi-db"; /* eslint-disable no-await-in-loop */ -import { MakeAction } from "#lib/actions/actions.js"; -import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js"; -import { log } from "#lib/log/log.js"; -import DB from "#services/pg/db.js"; -import { GetMillisecondsSince } from "#utils/misc.js"; -import { UnixMillisecondsToISO8601 } from "#utils/time.js"; -import { GetAllRankings, GetUGPTPlaycount, IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles"; +import { log } from "#lib/log/log"; +import DB from "#services/pg/db"; +import { GetMillisecondsSince } from "#utils/misc"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { GetAllRankings, GetUGPTPlaycount, IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; const PROFILE_BATCH = 500; @@ -91,10 +91,10 @@ export async function runUgsSnapshotCore() { for (const row of rows) { const stats = ToGameStatsDocument(row); - log.debug(`Snapshotting ${stats.userID} ${stats.playtype} ${stats.game}.`); + log.debug(`Snapshotting ${stats.userID} ${stats.game}.`); const [playcount, rankings] = await Promise.all([ - GetUGPTPlaycount(stats.userID, stats.game, stats.playtype), + GetUGPTPlaycount(stats.userID, stats.game), GetAllRankings(stats), ]); diff --git a/typescript/server/src/actions/unfollow-user.test.ts b/typescript/server/src/actions/unfollow-user.test.ts index a23635b2c..8a1cd3127 100644 --- a/typescript/server/src/actions/unfollow-user.test.ts +++ b/typescript/server/src/actions/unfollow-user.test.ts @@ -53,13 +53,17 @@ describe("ACTION_UnfollowUser", () => { it("throws when userID is 0 (not a positive integer)", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await expect(ACTION_UnfollowUser(taker, { userID: 0 })).rejects.toThrow("invalid input"); + await expect(ACTION_UnfollowUser(taker, { userID: 0 })).rejects.toMatchObject({ + code: 400, + }); }); it("throws when userID is negative", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await expect(ACTION_UnfollowUser(taker, { userID: -1 })).rejects.toThrow("invalid input"); + await expect(ACTION_UnfollowUser(taker, { userID: -1 })).rejects.toMatchObject({ + code: 400, + }); }); // ── Happy path ──────────────────────────────────────────────────────────── diff --git a/typescript/server/src/actions/unfollow-user.ts b/typescript/server/src/actions/unfollow-user.ts index 938d8c55a..44692e76a 100644 --- a/typescript/server/src/actions/unfollow-user.ts +++ b/typescript/server/src/actions/unfollow-user.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; -import { GetUserWithID } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { GetUserWithID } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_UnfollowUser = MakeAction( diff --git a/typescript/server/src/actions/update-api-client.ts b/typescript/server/src/actions/update-api-client.ts index 526780320..779fc1c7e 100644 --- a/typescript/server/src/actions/update-api-client.ts +++ b/typescript/server/src/actions/update-api-client.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; -import { GetClientByID } from "#utils/queries/api-clients.js"; -import { validateWebhookUri } from "#utils/validate-webhook-uri.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { GetClientByID } from "#utils/queries/api-clients"; +import { validateWebhookUri } from "#utils/validate-webhook-uri"; import { ExpectedErr } from "bliss"; export const ACTION_UpdateApiClient = MakeAction( diff --git a/typescript/server/src/actions/update-bpi-data.ts b/typescript/server/src/actions/update-bpi-data.ts index d98095b8d..87e8e6445 100644 --- a/typescript/server/src/actions/update-bpi-data.ts +++ b/typescript/server/src/actions/update-bpi-data.ts @@ -1,25 +1,27 @@ /* eslint-disable no-await-in-loop */ -import type { Difficulties, integer, MONGO_ChartDocument, Playtypes } from "tachi-common"; +import type { ChartDocument, Difficulties, GamesForGroup, integer } from "tachi-common"; -import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum"; +import { ComputeChartStabilityChecksum } from "#game-implementations/utils/derivation-checksum"; import { MakeAction } from "#lib/actions/actions"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; import fetch from "#utils/fetch"; -import { FindChartWithPTDFVersion } from "#utils/queries/charts"; +import { FindChartWithSongDifficultyVersion } from "#utils/queries/charts"; import { FindSongOnTitle } from "#utils/queries/songs"; import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; -const difficultyResolve: Record = - { - 3: ["SP", "HYPER"], - 4: ["SP", "ANOTHER"], - 8: ["DP", "HYPER"], - 9: ["DP", "ANOTHER"], - 10: ["SP", "LEGGENDARIA"], - 11: ["DP", "LEGGENDARIA"], - }; +const difficultyResolve: Record< + string, + [GamesForGroup["iidx"], Difficulties[GamesForGroup["iidx"]]] +> = { + 3: ["iidx-sp", "HYPER"], + 4: ["iidx-sp", "ANOTHER"], + 8: ["iidx-dp", "HYPER"], + 9: ["iidx-dp", "ANOTHER"], + 10: ["iidx-sp", "LEGGENDARIA"], + 11: ["iidx-dp", "LEGGENDARIA"], +}; interface PoyashiProxyBPIInfo { title: string; @@ -55,14 +57,14 @@ export async function updateBpiDataCore() { const updatedChartIDs: Array = []; for (const d of data.body) { - const res: ["DP" | "SP", Difficulties["iidx:DP" | "iidx:SP"]] | undefined = + const res: [GamesForGroup["iidx"], Difficulties[GamesForGroup["iidx"]]] | undefined = difficultyResolve[d.difficulty]; if (!res) { throw new Error(`Unknown difficulty ${d.difficulty}`); } - const [playtype, diff] = res; + const [game, diff] = res; const tachiSong = await FindSongOnTitle("iidx", d.title); @@ -71,25 +73,18 @@ export async function updateBpiDataCore() { continue; } - const tachiChart = await FindChartWithPTDFVersion( - "iidx", - tachiSong.id, - playtype, - diff, - "29", - ); + // TODO(zk): why 29? shouldn't this be updated by now? + const tachiChart = await FindChartWithSongDifficultyVersion(game, tachiSong.id, diff, "29"); if (!tachiChart) { - log.warn( - `Cannot find chart ${tachiSong.title} (${tachiSong.id}) ${playtype}, ${diff}?`, - ); + log.warn(`Cannot find chart ${tachiSong.title} (${tachiSong.id}) ${game}, ${diff}?`); continue; } const kavg = Number(d.avg); if (kavg < 0) { - log.warn(`${tachiSong.title} (${playtype} ${diff}). Invalid kavg ${d.avg}, Skipping.`); + log.warn(`${tachiSong.title} (${game} ${diff}). Invalid kavg ${d.avg}, Skipping.`); continue; } @@ -102,9 +97,7 @@ export async function updateBpiDataCore() { const newKavg = Number(d.avg); const newWR = Number(d.wr); - const iidxChart = tachiChart as - | MONGO_ChartDocument<"iidx:DP"> - | MONGO_ChartDocument<"iidx:SP">; + const iidxChart = tachiChart as ChartDocument; if ( iidxChart.data.bpiCoefficient !== newCoef || @@ -120,9 +113,8 @@ export async function updateBpiDataCore() { worldRecord: newWR, }; - const gpt = `iidx:${playtype}` as const; - const updatedChart = { ...tachiChart, data: mergedData } as MONGO_ChartDocument; - const checksum = computeDerivationChecksumForGPT(gpt, updatedChart); + const updatedChart = { ...tachiChart, data: mergedData } as ChartDocument; + const checksum = ComputeChartStabilityChecksum(game, updatedChart); await DB.updateTable("chart") .set({ diff --git a/typescript/server/src/actions/update-cg-card-info.ts b/typescript/server/src/actions/update-cg-card-info.ts index ba9bc42a1..ab4cacccb 100644 --- a/typescript/server/src/actions/update-cg-card-info.ts +++ b/typescript/server/src/actions/update-cg-card-info.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; export const ACTION_UpdateCgCardInfo = MakeAction( "UPDATE_CG_CARD_INFO", diff --git a/typescript/server/src/actions/update-dp-tiers.ts b/typescript/server/src/actions/update-dp-tiers.ts index 077409138..587cc8105 100644 --- a/typescript/server/src/actions/update-dp-tiers.ts +++ b/typescript/server/src/actions/update-dp-tiers.ts @@ -1,12 +1,12 @@ -import type { MONGO_ChartDocument } from "tachi-common"; +import type { ChartDocument } from "tachi-common"; /* eslint-disable no-await-in-loop */ -import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum"; +import { ComputeChartStabilityChecksum } from "#game-implementations/utils/derivation-checksum"; import { MakeAction } from "#lib/actions/actions"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; import fetch from "#utils/fetch"; -import { FindChartWithPTDFVersion } from "#utils/queries/charts"; +import { FindChartWithSongDifficultyVersion } from "#utils/queries/charts"; import { FindSongOnTitle } from "#utils/queries/songs"; import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; @@ -42,7 +42,7 @@ export async function updateDpTiersCore() { log.info(`Got DP tier data. Applying it.`); - const updatedSongIDs = new Set(); + const updatedSongIDs = new Set(); for (const d of parsedData) { const song = await FindSongOnTitle("iidx", d.songTitle); @@ -57,7 +57,12 @@ export async function updateDpTiersCore() { continue; } - const chart = await FindChartWithPTDFVersion("iidx", song.id, "DP", difficulty, "29"); + const chart = await FindChartWithSongDifficultyVersion( + "iidx-dp", + song.id, + difficulty, + "29", + ); if (!chart) { log.warn( @@ -72,7 +77,7 @@ export async function updateDpTiersCore() { individualDifference: false, }; - const dpChart = chart as MONGO_ChartDocument<"iidx:DP">; + const dpChart = chart as ChartDocument<"iidx-dp">; const prev = dpChart.data.dpTier; if ( @@ -89,8 +94,8 @@ export async function updateDpTiersCore() { dpTier: nextTier, }; - const updatedChart = { ...chart, data: mergedData } as MONGO_ChartDocument; - const checksum = computeDerivationChecksumForGPT("iidx:DP", updatedChart); + const updatedChart = { ...chart, data: mergedData } as ChartDocument; + const checksum = ComputeChartStabilityChecksum("iidx-dp", updatedChart); await DB.updateTable("chart") .set({ diff --git a/typescript/server/src/actions/update-fervidex-settings.ts b/typescript/server/src/actions/update-fervidex-settings.ts index c8e0b2fce..4a0fb4968 100644 --- a/typescript/server/src/actions/update-fervidex-settings.ts +++ b/typescript/server/src/actions/update-fervidex-settings.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; export const ACTION_UpdateFervidexSettings = MakeAction( "UPDATE_FERVIDEX_SETTINGS", diff --git a/typescript/server/src/actions/update-kshook-sv6c-settings.ts b/typescript/server/src/actions/update-kshook-sv6c-settings.ts index 61eae58d3..fba87b516 100644 --- a/typescript/server/src/actions/update-kshook-sv6c-settings.ts +++ b/typescript/server/src/actions/update-kshook-sv6c-settings.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; export const ACTION_UpdateKshookSv6cSettings = MakeAction( "UPDATE_KSHOOK_SV6C_SETTINGS", diff --git a/typescript/server/src/actions/update-myt-card-info.ts b/typescript/server/src/actions/update-myt-card-info.ts index be62b2d42..aedc5bbb0 100644 --- a/typescript/server/src/actions/update-myt-card-info.ts +++ b/typescript/server/src/actions/update-myt-card-info.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; export const ACTION_UpdateMytCardInfo = MakeAction( "UPDATE_MYT_CARD_INFO", diff --git a/typescript/server/src/actions/update-session.ts b/typescript/server/src/actions/update-session.ts index 370785fbc..f2cb0fc4b 100644 --- a/typescript/server/src/actions/update-session.ts +++ b/typescript/server/src/actions/update-session.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ACTION_UpdateSession = MakeAction("UPDATE_SESSION", async (taker, input) => { diff --git a/typescript/server/src/actions/update-sp12-data.ts b/typescript/server/src/actions/update-sp12-data.ts index bc146e33f..c939d9957 100644 --- a/typescript/server/src/actions/update-sp12-data.ts +++ b/typescript/server/src/actions/update-sp12-data.ts @@ -1,12 +1,12 @@ -import type { Difficulties, integer, MONGO_ChartDocument } from "tachi-common"; +import type { ChartDocument, Difficulties, integer } from "tachi-common"; /* eslint-disable no-await-in-loop */ -import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum"; +import { ComputeChartStabilityChecksum } from "#game-implementations/utils/derivation-checksum"; import { MakeAction } from "#lib/actions/actions"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; import fetch from "#utils/fetch"; -import { FindChartWithPTDF } from "#utils/queries/charts"; +import { FindChartWithSongDifficulty } from "#utils/queries/charts"; import { FindSongOnTitle } from "#utils/queries/songs"; import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; @@ -57,7 +57,7 @@ export async function updateSp12DataCore() { const updatedChartIDs: Array = []; for (const sh of rj.sheets) { - let chart: MONGO_ChartDocument<"iidx:SP">; + let chart: ChartDocument<"iidx-sp">; try { chart = await HumanisedTitleLookup(sh.title); @@ -179,8 +179,8 @@ export async function updateSp12DataCore() { }, }; - const updatedChart = { ...chart, data: chartData } as MONGO_ChartDocument; - const checksum = computeDerivationChecksumForGPT("iidx:SP", updatedChart); + const updatedChart = { ...chart, data: chartData } as ChartDocument; + const checksum = ComputeChartStabilityChecksum("iidx-sp", updatedChart); await DB.updateTable("chart") .set({ @@ -205,7 +205,7 @@ export async function updateSp12DataCore() { } async function HumanisedTitleLookup(originalTitle: string) { - let difficulty: Difficulties["iidx:SP"] = "ANOTHER"; + let difficulty: Difficulties["iidx-sp"] = "ANOTHER"; let title: string | undefined = originalTitle; @@ -234,7 +234,7 @@ async function HumanisedTitleLookup(originalTitle: string) { ); } - const chart = await FindChartWithPTDF("iidx", song.id, "SP", difficulty); + const chart = await FindChartWithSongDifficulty("iidx-sp", song.id, difficulty); if (!chart) { throw new Error( @@ -242,7 +242,7 @@ async function HumanisedTitleLookup(originalTitle: string) { ); } - return chart as MONGO_ChartDocument<"iidx:SP">; + return chart as ChartDocument<"iidx-sp">; } export const ACTION_UpdateSp12Data = MakeAction("UPDATE_SP12_DATA", async (taker, _input) => { diff --git a/typescript/server/src/actions/update-ugpt-showcase.ts b/typescript/server/src/actions/update-ugpt-showcase.ts index 45840a3c1..08b837f50 100644 --- a/typescript/server/src/actions/update-ugpt-showcase.ts +++ b/typescript/server/src/actions/update-ugpt-showcase.ts @@ -1,27 +1,23 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings.js"; -import DB from "#services/pg/db.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings"; +import DB from "#services/pg/db"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; -import { type GameGroup, GamePTToV3, type Playtype, type ShowcaseStatDetails } from "tachi-common"; +import { type ShowcaseStatDetails } from "tachi-common"; export const ACTION_UpdateUgptShowcase = MakeAction( "UPDATE_UGPT_SHOWCASE", async (taker, input) => { - const { userID, game: gameStr, playtype: playtypeStr, stats } = input; - const game = gameStr as GameGroup; - const playtype = playtypeStr as Playtype; + const { userID, game, stats } = input; if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) { throw new ExpectedErr(403, "You are not authorised to modify this user's showcase."); } - const v3Game = GamePTToV3(game, playtype); - const settingsRow = await DB.selectFrom("game_settings") .select("user_id") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst(); if (!settingsRow) { @@ -33,7 +29,7 @@ export const ACTION_UpdateUgptShowcase = MakeAction( await DB.insertInto("game_settings_showcase") .values({ user_id: userID, - game: v3Game, + game, data: JSON.stringify(payload), }) .onConflict((oc) => @@ -41,7 +37,7 @@ export const ACTION_UpdateUgptShowcase = MakeAction( ) .execute(); - const newSettings = await GetUGPTSettingsDocument(userID, game, playtype); + const newSettings = await GetUGPTSettingsDocument(userID, game); if (!newSettings) { throw new ExpectedErr(500, "Failed to load settings after updating showcase."); diff --git a/typescript/server/src/actions/update-user-settings.ts b/typescript/server/src/actions/update-user-settings.ts index bc3915d60..4269690b7 100644 --- a/typescript/server/src/actions/update-user-settings.ts +++ b/typescript/server/src/actions/update-user-settings.ts @@ -1,5 +1,5 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ACTION_UpdateUserSettings = MakeAction( diff --git a/typescript/server/src/actions/update-user.ts b/typescript/server/src/actions/update-user.ts index 8e051157d..6609d7dd8 100644 --- a/typescript/server/src/actions/update-user.ts +++ b/typescript/server/src/actions/update-user.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; -import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "#utils/misc.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "#utils/misc"; import { ExpectedErr } from "bliss"; export const ACTION_UpdateUser = MakeAction("UPDATE_USER", async (taker, body) => { diff --git a/typescript/server/src/actions/upsert-kai-auth-token.ts b/typescript/server/src/actions/upsert-kai-auth-token.ts index d40f8cf6c..5fd1cabfa 100644 --- a/typescript/server/src/actions/upsert-kai-auth-token.ts +++ b/typescript/server/src/actions/upsert-kai-auth-token.ts @@ -1,6 +1,6 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { upsertKaiAuthTokensInDb } from "#lib/kai-auth-token/persist.js"; -import DB from "#services/pg/db.js"; +import { MakeAction } from "#lib/actions/actions"; +import { upsertKaiAuthTokensInDb } from "#lib/kai-auth-token/persist"; +import DB from "#services/pg/db"; export const ACTION_UpsertKaiAuthToken = MakeAction( "UPSERT_KAI_AUTH_TOKEN", diff --git a/typescript/server/src/anon-actions/forgot-password.ts b/typescript/server/src/anon-actions/forgot-password.ts index 8b86a3f90..94aae037d 100644 --- a/typescript/server/src/anon-actions/forgot-password.ts +++ b/typescript/server/src/anon-actions/forgot-password.ts @@ -1,7 +1,6 @@ import { MakeAnonAction } from "#lib/actions/actions"; import { SendEmail } from "#lib/email/client"; import { EmailFormatResetPassword } from "#lib/email/formats"; -import { Env, ServerConfig } from "#lib/setup/config"; import DB from "#services/pg/db"; import { Random20Hex } from "#utils/misc"; import { GetUserWithIDGuaranteed } from "#utils/user"; @@ -10,10 +9,6 @@ import { ExpectedErr, log } from "bliss"; export const ANON_ACTION_ForgotPassword = MakeAnonAction( "FORGOT_PASSWORD", async (taker, { email }) => { - if (!ServerConfig.EMAIL_CONFIG && Env.NODE_ENV !== "test") { - throw new ExpectedErr(501, "This server does not support password resets."); - } - if (taker.ip === null) { throw new ExpectedErr(400, "IP address is required to send a password reset email."); } diff --git a/typescript/server/src/anon-actions/register.ts b/typescript/server/src/anon-actions/register.ts index 0ec30f44e..6bac4c44b 100644 --- a/typescript/server/src/anon-actions/register.ts +++ b/typescript/server/src/anon-actions/register.ts @@ -7,7 +7,7 @@ import DB from "#services/pg/db"; import { Random20Hex } from "#utils/misc"; import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user"; import { ExpectedErr, log } from "bliss"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; export const ANON_ACTION_Register = MakeAnonAction( "REGISTER", @@ -43,7 +43,7 @@ export const ANON_ACTION_Register = MakeAnonAction( throw new ExpectedErr(409, `This email is already in use.`); } - const newUser = await DB.transaction().execute(async (txn): Promise => { + const newUser = await DB.transaction().execute(async (txn): Promise => { // if we get to this point, We're good to create the user. const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email); @@ -65,7 +65,7 @@ export const ANON_ACTION_Register = MakeAnonAction( if (!inviteCodeDoc) { log.info(`Invalid invite code given: ${inviteCode}.`); - throw new Error(`Invalid invite code given: ${inviteCode}.`); + throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`); } log.info(`Consumed invite ${inviteCodeDoc.code}.`); @@ -81,26 +81,21 @@ export const ANON_ACTION_Register = MakeAnonAction( .execute(); } - // If we have an EMAIL_CONFIG set, send out - // authentication emails. - // Otherwise, don't bother; this is equivalent to - // automatically verifying all users' emails. - if (ServerConfig.EMAIL_CONFIG) { - const resetEmailCode = Random20Hex(); + const resetEmailCode = Random20Hex(); - await DB.insertInto("priv_verify_email_token") - .values({ - token: resetEmailCode, - user_id: newUser.id, - email, - }) - .execute(); + await txn + .insertInto("priv_verify_email_token") + .values({ + token: resetEmailCode, + user_id: newUser.id, + email, + }) + .execute(); - // TODO: Put this on job queue - const { text, html } = EmailFormatVerifyEmail(newUser.username, resetEmailCode); + // TODO: Put this on job queue + const { text, html } = EmailFormatVerifyEmail(newUser.username, resetEmailCode); - void SendEmail(email, "Email Verification", html, text); - } + void SendEmail(email, "Email Verification", html, text); return newUser; }); diff --git a/typescript/server/src/game-implementations/game-implementations.ts b/typescript/server/src/game-implementations/game-implementations.ts index 7457e628e..133179d43 100644 --- a/typescript/server/src/game-implementations/game-implementations.ts +++ b/typescript/server/src/game-implementations/game-implementations.ts @@ -1,4 +1,4 @@ -import type { GPTImplementations } from "./types"; +import type { GameImplementations } from "./types"; import { ARCAEA_IMPL } from "./games/arcaea"; import { BMS_7K_IMPL, BMS_14K_IMPL, PMS_CONTROLLER_IMPL, PMS_KEYBOARD_IMPL } from "./games/bms-pms"; @@ -12,7 +12,7 @@ import { MAIMAI_IMPL } from "./games/maimai"; import { MAIMAIDX_IMPL } from "./games/maimaidx"; import { MUSECA_IMPL } from "./games/museca"; import { ONGEKI_IMPL } from "./games/ongeki"; -import { POPN_9B_IMPL } from "./games/popn"; +import { POPN_IMPL } from "./games/popn"; import { SDVX_IMPL } from "./games/sdvx"; import { USC_CONTROLLER_IMPL, USC_KEYBOARD_IMPL } from "./games/usc"; import { WACCA_IMPL } from "./games/wacca"; @@ -25,28 +25,28 @@ import { WACCA_IMPL } from "./games/wacca"; * * Basically, anything that can't be done in the common config, specific to the server. */ -export const GPT_SERVER_IMPLEMENTATIONS: GPTImplementations = { - "bms:14K": BMS_14K_IMPL, - "bms:7K": BMS_7K_IMPL, - "pms:Controller": PMS_CONTROLLER_IMPL, - "pms:Keyboard": PMS_KEYBOARD_IMPL, - "iidx:SP": IIDX_SP_IMPL, - "iidx:DP": IIDX_DP_IMPL, - "wacca:Single": WACCA_IMPL, - "chunithm:Single": CHUNITHM_IMPL, - "gitadora:Dora": GITADORA_DORA_IMPL, - "gitadora:Gita": GITADORA_GITA_IMPL, - "itg:Stamina": ITG_STAMINA_IMPL, - "jubeat:Single": JUBEAT_IMPL, - "maimai:Single": MAIMAI_IMPL, - "maimaidx:Single": MAIMAIDX_IMPL, - "museca:Single": MUSECA_IMPL, - "popn:9B": POPN_9B_IMPL, - "usc:Controller": USC_CONTROLLER_IMPL, - "usc:Keyboard": USC_KEYBOARD_IMPL, - "sdvx:Single": SDVX_IMPL, - "arcaea:Touch": ARCAEA_IMPL, - "ongeki:Single": ONGEKI_IMPL, - "ddr:SP": DDR_IMPL, - "ddr:DP": DDR_IMPL, +export const GAME_IMPLEMENTATIONS: GameImplementations = { + "bms-14k": BMS_14K_IMPL, + "bms-7k": BMS_7K_IMPL, + "pms-controller": PMS_CONTROLLER_IMPL, + "pms-keyboard": PMS_KEYBOARD_IMPL, + "iidx-sp": IIDX_SP_IMPL, + "iidx-dp": IIDX_DP_IMPL, + wacca: WACCA_IMPL, + chunithm: CHUNITHM_IMPL, + "gitadora-dora": GITADORA_DORA_IMPL, + "gitadora-gita": GITADORA_GITA_IMPL, + "itg-stamina": ITG_STAMINA_IMPL, + jubeat: JUBEAT_IMPL, + maimai: MAIMAI_IMPL, + maimaidx: MAIMAIDX_IMPL, + museca: MUSECA_IMPL, + popn: POPN_IMPL, + "usc-controller": USC_CONTROLLER_IMPL, + "usc-keyboard": USC_KEYBOARD_IMPL, + sdvx: SDVX_IMPL, + arcaea: ARCAEA_IMPL, + ongeki: ONGEKI_IMPL, + "ddr-sp": DDR_IMPL, + "ddr-dp": DDR_IMPL, }; diff --git a/typescript/server/src/game-implementations/games/_common.ts b/typescript/server/src/game-implementations/games/_common.ts index 4e6a5f06c..fb5a7712b 100644 --- a/typescript/server/src/game-implementations/games/_common.ts +++ b/typescript/server/src/game-implementations/games/_common.ts @@ -19,22 +19,29 @@ import { SessionAvgBest10For } from "#game-implementations/utils/session-calc"; import { IsNullish, NumToDP } from "#utils/misc"; import { Volforce } from "rg-stats"; import { + type ChartDocument, FmtNum, FmtNumCompact, GetGrade, GetGradeDeltas, - type GPTString, - type GPTStrings, type GradeBoundary, - IIDXLIKE_GBOUNDARIES, IIDXLikeGetGrade, type integer, - type MONGO_ChartDocument, - type MONGO_ScoreDocument, - type MONGO_SpecificUserGameStats, + type ScoreDocument, SDVXLIKE_GBOUNDARIES, + type SpecificUserGameStats, + type V3Game, } from "tachi-common"; +/** BMS/PMS v3 games (e.g. `bms:7K` → `bms-7k`). */ +type BmsPmsGames = "bms-7k" | "bms-14k" | "pms-controller" | "pms-keyboard"; + +/** IIDX/BMS/PMS shared chart logic (e.g. `iidx:SP` → `iidx-sp`). */ +type IIDXLikes = "iidx-dp" | "iidx-sp" | BmsPmsGames; + +/** SDVX and USC v3 games (`sdvx:Single` → `sdvx`; `usc:Controller` → `usc-controller`). */ +type SDVXLikes = "sdvx" | "usc-controller" | "usc-keyboard"; + export const EX_SCORE_CHECK: ChartSpecificMetricValidator = (exScore, chart) => { if (exScore < 0) { return `EX Score cannot be negative.`; @@ -51,8 +58,6 @@ function calculateIIDXLikePercent(exScore: integer, notecount: integer) { return (100 * exScore) / (notecount * 2); } -type IIDXLikes = GPTStrings["bms" | "iidx" | "pms"]; - export const IIDXLIKE_SCORE_DERIVER: GPTScoreDeriver = (scoreData, chart) => ({ percent: calculateIIDXLikePercent(scoreData.score, chart.data.notecount), grade: IIDXLikeGetGrade(scoreData.score, chart.data.notecount), @@ -76,8 +81,6 @@ export const IIDXLIKE_SCORE_VALIDATORS: Array> = [ }, ]; -type SDVXLikes = GPTStrings["sdvx" | "usc"]; - export const SDVXLIKE_SCORE_DERIVER: GPTScoreDeriver = (scoreData, _chart) => ({ grade: GetGrade(SDVXLIKE_GBOUNDARIES, scoreData.score), }); @@ -91,9 +94,7 @@ export const IIDXLIKE_PB_RANKING_VALUES: PBRankingValuesFunction = (p tb5: null, }); -export function VF6ToClass( - vf: number, -): MONGO_SpecificUserGameStats<"sdvx:Single">["classes"]["vfClass"] { +export function VF6ToClass(vf: number): SpecificUserGameStats<"sdvx">["classes"]["vfClass"] { // jesus christ man if (vf >= 23) { return "IMPERIAL_IV"; @@ -188,12 +189,8 @@ export const SDVXLIKE_SESSION_CALCS: GPTSessionCalcs = (arr) => { return { ProfileVF6: v !== null ? v * 50 : null }; }; -export const SDVXLIKE_PROFILE_CALCS: GPTProfileCalcs = async ( - game, - playtype, - userID, -) => ({ - VF6: await ProfileSumBestN("VF6", 50)(game, playtype, userID), +export const SDVXLIKE_PROFILE_CALCS: GPTProfileCalcs = async (game, userID) => ({ + VF6: await ProfileSumBestN("VF6", 50)(game, userID), }); export const SDVXLIKE_CLASS_DERIVERS: GPTClassDerivers = (ratings) => ({ @@ -221,9 +218,14 @@ export const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters = { }; export const SDVXLIKE_PB_MERGERS: Array> = [ - CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), + CreatePBMergeFor( + "largest", + { type: "REGULAR", metric: "lamp" }, + "Best Lamp", + (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }, + ), ]; export const SDVXLIKE_DEFAULT_MERGE_NAME = "Best Score"; @@ -250,23 +252,15 @@ export const SDVXLIKE_SCORE_VALIDATORS: Array> = [ }, ]; -export const SGL_SESSION_CALCS: GPTSessionCalcs = (arr) => ({ +export const SGL_SESSION_CALCS: GPTSessionCalcs = (arr) => ({ sieglinde: SessionAvgBest10For("sieglinde")(arr), }); -export const SGL_PROFILE_CALCS: GPTProfileCalcs = async ( - game, - playtype, - userID, -) => ({ - sieglinde: await ProfileAvgBestN("sieglinde", 20)(game, playtype, userID), +export const SGL_PROFILE_CALCS: GPTProfileCalcs = async (game, userID) => ({ + sieglinde: await ProfileAvgBestN("sieglinde", 20)(game, userID), }); -export const SGL_SCORE_CALCS: GPTScoreCalcs = ( - scoreData, - _derivedData, - chart, -) => { +export const SGL_SCORE_CALCS: GPTScoreCalcs = (scoreData, _derivedData, chart) => { const ecValue = chart.data.sglEC ?? 0; const hcValue = chart.data.sglHC ?? 0; @@ -341,10 +335,10 @@ export function GradeGoalFormatter( * * @returns undefined on success, an array of error messages (strings) on failure. */ -export function RunValidators( - validators: Array>, - score: MONGO_ScoreDocument, - chart: MONGO_ChartDocument, +export function RunValidators( + validators: Array>, + score: ScoreDocument, + chart: ChartDocument, ) { const errs = []; diff --git a/typescript/server/src/game-implementations/games/arcaea.oldtest.ts b/typescript/server/src/game-implementations/games/arcaea.oldtest.ts deleted file mode 100644 index 849d39f64..000000000 --- a/typescript/server/src/game-implementations/games/arcaea.oldtest.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { DeepPartial } from "#utils/types"; - -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestSnapshot } from "#test-utils/single-process-snapshot"; -import { TestingArcaeaSheriruthFTR, TestingWaccaPupaExp } from "#test-utils/test-data"; -import { - ARCAEA_GRADES, - ARCAEA_LAMPS, - type ChartDocument, - type ProvidedMetrics, - type ScoreData, - type ScoreDocument, -} from "tachi-common"; -import t from "tap"; - -import { RunValidators } from "./_common"; -import { ARCAEA_IMPL } from "./arcaea"; - -const baseMetrics: ProvidedMetrics["arcaea:Touch"] = { - lamp: "CLEAR", - score: 9_979_366, -}; - -const scoreData: ScoreData<"arcaea:Touch"> = { - lamp: "CLEAR", - score: 9_979_366, - grade: "EX+", - judgements: { - pure: 1148, - far: 1, - lost: 2, - }, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: ARCAEA_GRADES.EX_PLUS, - lamp: ARCAEA_LAMPS.CLEAR, - }, -}; - -const mockScore = mkMockScore("arcaea", "Touch", TestingArcaeaSheriruthFTR, scoreData); -const mockPB = mkMockPB("arcaea", "Touch", TestingArcaeaSheriruthFTR, scoreData); - -t.test("Arcaea Implementation", (t) => { - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: string) => - t.equal( - ARCAEA_IMPL.scoreDeriver( - dmf(baseMetrics, { score }) as any, - TestingArcaeaSheriruthFTR, - ).grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "D"); - f(8_600_000, "C"); - f(8_900_000, "B"); - f(9_200_000, "A"); - f(9_500_000, "AA"); - f(9_800_000, "EX"); - f(9_900_000, "EX+"); - - t.end(); - }); - - t.test("Rating Calc", (t) => { - t.equal( - ARCAEA_IMPL.scoreCalcs( - scoreData, - ARCAEA_IMPL.scoreDeriver(scoreData, TestingArcaeaSheriruthFTR), - TestingArcaeaSheriruthFTR, - ).potential, - 11.99, - "Basic potential check", - ); - - t.end(); - }); - - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Colour Deriver", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - ARCAEA_IMPL.classDerivers({ naivePotential: v }).badge, - expected, - `A naivePotential of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "BLUE"); - f(3.5, "GREEN"); - f(7, "ASH_PURPLE"); - f(10, "PURPLE"); - f(11, "RED"); - f(12, "ONE_STAR"); - f(12.5, "TWO_STARS"); - f(13, "THREE_STARS"); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal( - ARCAEA_IMPL.goalCriteriaFormatters.score(10_002_221), - "Get a score of 10,002,221 on", - ); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof ARCAEA_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - ARCAEA_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "EX", score: 9_897_342 }, ARCAEA_GRADES.EX_PLUS, "(EX+)-2.7K"); - f("score", { score: 9_982_123 }, 10_000_000, "9,982,123"); - f("lamp", { lamp: "CLEAR" }, ARCAEA_LAMPS.CLEAR, "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(ARCAEA_IMPL.goalOutOfFormatters.score(10_001_003), "10,001,003"); - t.equal(ARCAEA_IMPL.goalOutOfFormatters.score(9_983_132), "9,983,132"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - score: 0, - lamp: "FULL RECALL", - enumIndexes: { lamp: ARCAEA_LAMPS.FULL_RECALL }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("arcaea:Touch", 1, TestingArcaeaSheriruthFTR, log), { - composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], - scoreData: { - score: mockScore.scoreData.score, - lamp: "FULL RECALL", - enumIndexes: { lamp: ARCAEA_LAMPS.FULL_RECALL }, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.test("Score Validations", (t) => { - const f = (s: DeepPartial>) => - RunValidators( - ARCAEA_IMPL.scoreValidators, - dmf(mockScore, s), - TestingArcaeaSheriruthFTR, - ); - - t.strictSame( - f({ - scoreData: { - lamp: "PURE MEMORY", - score: 10_001_151, - judgements: { - pure: 1151, - far: 0, - lost: 0, - }, - }, - }), - undefined, - ); - t.strictSame(f({ scoreData: { lamp: "FULL RECALL", judgements: { lost: 0 } } }), undefined); - t.strictSame( - ARCAEA_IMPL.chartSpecificValidators.score( - mockScore.scoreData.score, - TestingArcaeaSheriruthFTR, - ), - true, - ); - - TestSnapshot( - t, - f({ scoreData: { lamp: "PURE MEMORY", score: 9_999_999 } }), - "Arcaea IMPL Validators: Should disallow PURE MEMORY with non-perfect score", - ); - - TestSnapshot( - t, - f({ scoreData: { lamp: "FULL RECALL", score: 4_999_999 } }), - "Arcaea IMPL Validators: Should disallow FULL RECALL scores smaller than the minimum possible score", - ); - - TestSnapshot( - t, - f({ scoreData: { lamp: "FULL RECALL", judgements: { lost: 1 } } }), - `Arcaea IMPL Validators: Should disallow FR with lost`, - ); - TestSnapshot( - t, - f({ scoreData: { lamp: "PURE MEMORY", judgements: { far: 1 } } }), - "Arcaea IMPL Validators: Should disallow PURE MEMORY with non-perfect Far judgements.", - ); - TestSnapshot( - t, - f({ scoreData: { lamp: "PURE MEMORY", judgements: { lost: 1 } } }), - "Arcaea IMPL Validators: Should disallow PURE MEMORY with non-perfect Lost judgements.", - ); - - TestSnapshot( - t, - ARCAEA_IMPL.chartSpecificValidators.score(-1, TestingArcaeaSheriruthFTR), - "Arcaea IMPL Validators: Should disallow negative scores.", - ); - TestSnapshot( - t, - ARCAEA_IMPL.chartSpecificValidators.score(10_001_152, TestingArcaeaSheriruthFTR), - "Arcaea IMPL Validators: Should disallow scores that exceed 10 million + notecount.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/arcaea.test.ts b/typescript/server/src/game-implementations/games/arcaea.test.ts new file mode 100644 index 000000000..d2fb25d17 --- /dev/null +++ b/typescript/server/src/game-implementations/games/arcaea.test.ts @@ -0,0 +1,325 @@ +import type { DeepPartial } from "#utils/types"; + +import { RunValidators } from "#game-implementations/games/_common"; +import { ARCAEA_IMPL } from "#game-implementations/games/arcaea"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingArcaeaSheriruthFTR, TestingArcaeaSheriruthSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + ARCAEA_GRADES, + ARCAEA_LAMPS, + type MongoProvidedMetrics, + type ScoreData, + type ScoreDocument, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const chart = TestingArcaeaSheriruthFTR; + +const baseMetrics: MongoProvidedMetrics["arcaea"] = { + lamp: "CLEAR", + score: 9_979_366, +}; + +const scoreData: ScoreData<"arcaea"> = { + lamp: "CLEAR", + score: 9_979_366, + grade: "EX+", + judgements: { + pure: 1148, + far: 1, + lost: 2, + }, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: ARCAEA_GRADES.EX_PLUS, + lamp: ARCAEA_LAMPS.CLEAR, + }, +}; + +async function seedSheriruthChart() { + await DB.insertInto("song") + .values({ + id: TestingArcaeaSheriruthSong.id, + legacy_id: 19, + game_group: "arcaea", + title: TestingArcaeaSheriruthSong.title, + artist: TestingArcaeaSheriruthSong.artist, + search_terms: TestingArcaeaSheriruthSong.searchTerms, + alt_titles: TestingArcaeaSheriruthSong.altTitles, + data: TestingArcaeaSheriruthSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "arcaea", + song_id: TestingArcaeaSheriruthSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertArcaeaScoreRow(opts: { + scoreData: ScoreData<"arcaea">; + scoreId: string; + timeAchievedMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("arcaea", opts.scoreData); + const t = UnixMillisecondsToISO8601(opts.timeAchievedMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "arcaea", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: t, + time_added: t, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("ARCAEA_IMPL", () => { + describe("scoreDeriver (grade)", () => { + const g = (score: number, expected: string) => + expect( + ARCAEA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never) + .grade, + ).toBe(expected); + + it("maps score thresholds to letter grades", () => { + g(0, "D"); + g(8_600_000, "C"); + g(8_900_000, "B"); + g(9_200_000, "A"); + g(9_500_000, "AA"); + g(9_800_000, "EX"); + g(9_900_000, "EX+"); + }); + }); + + it("scoreCalcs potential", () => { + expect( + ARCAEA_IMPL.scoreCalcs(scoreData, ARCAEA_IMPL.scoreDeriver(scoreData, chart), chart) + .potential, + ).toBe(11.99); + }); + + describe("classDerivers (naivePotential → badge)", () => { + const badge = ( + v: number | null, + expected: ReturnType["badge"], + ) => expect(ARCAEA_IMPL.classDerivers({ naivePotential: v }).badge).toBe(expected); + + it("maps potential tiers to badge colours", () => { + badge(null, null); + badge(0, "BLUE"); + badge(3.5, "GREEN"); + badge(7, "ASH_PURPLE"); + badge(10, "PURPLE"); + badge(11, "RED"); + badge(12, "ONE_STAR"); + badge(12.5, "TWO_STARS"); + badge(13, "THREE_STARS"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("arcaea", chart, scoreData); + + it("formats score criteria", () => { + expect(ARCAEA_IMPL.goalCriteriaFormatters.score(10_002_221)).toBe( + "Get a score of 10,002,221 on", + ); + }); + + it("formats progress for grade, score, and lamp", () => { + const f = ( + k: keyof typeof ARCAEA_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + ARCAEA_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "EX", score: 9_897_342 }, ARCAEA_GRADES.EX_PLUS, "(EX+)-2.7K"); + f("score", { score: 9_982_123 }, 10_000_000, "9,982,123"); + f("lamp", { lamp: "CLEAR" }, ARCAEA_LAMPS.CLEAR, "CLEAR"); + }); + + it("formats out-of score", () => { + expect(ARCAEA_IMPL.goalOutOfFormatters.score(10_001_003)).toBe("10,001,003"); + expect(ARCAEA_IMPL.goalOutOfFormatters.score(9_983_132)).toBe("9,983,132"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedSheriruthChart); + + it("joins best lamp into the default-metric PB", async () => { + const { id: userId } = await seedUser({ username: "arcaea_pb_lamp" }); + const main = mkMockScore("arcaea", chart, scoreData); + + const lampScoreData: ScoreData<"arcaea"> = { + ...scoreData, + score: 0, + lamp: "FULL RECALL", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: ARCAEA_LAMPS.FULL_RECALL, + }, + }; + + await insertArcaeaScoreRow({ + userId, + scoreId: main.scoreID, + scoreData, + timeAchievedMs: 1_000, + }); + + await insertArcaeaScoreRow({ + userId, + scoreId: "bestLamp", + scoreData: lampScoreData, + timeAchievedMs: 2_000, + }); + + const pb = await CreatePBDoc("arcaea", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], + scoreData: { + score: scoreData.score, + lamp: "FULL RECALL", + enumIndexes: { lamp: ARCAEA_LAMPS.FULL_RECALL }, + }, + }); + }); + }); + + describe("scoreValidators & chartSpecificValidators", () => { + const mockScore = mkMockScore("arcaea", chart, scoreData); + + const runVal = (s: DeepPartial>) => + RunValidators(ARCAEA_IMPL.scoreValidators, dmf(mockScore, s) as never, chart as never); + + it("accepts valid PM, FR, and chart score bounds", () => { + expect( + runVal({ + scoreData: { + lamp: "PURE MEMORY", + score: 10_001_151, + judgements: { + pure: 1151, + far: 0, + lost: 0, + }, + }, + }), + ).toBeUndefined(); + + expect( + runVal({ scoreData: { lamp: "FULL RECALL", judgements: { lost: 0 } } }), + ).toBeUndefined(); + + expect( + ARCAEA_IMPL.chartSpecificValidators.score(mockScore.scoreData.score, chart), + ).toBe(true); + }); + + it("rejects inconsistent lamps and judgements", () => { + expect( + runVal({ + scoreData: { + lamp: "PURE MEMORY", + score: 9_999_999, + judgements: { pure: 1151, far: 0, lost: 0 }, + }, + }), + ).toEqual([ + "PURE MEMORY scores must have a score larger than 10 million. Got 9999999 instead.", + ]); + + expect( + runVal({ + scoreData: { + lamp: "FULL RECALL", + score: 4_999_999, + judgements: { pure: 500, far: 500, lost: 0 }, + }, + }), + ).toEqual([ + "FULL RECALL scores must have a score larger than 5 million. Got 4999999 instead.", + ]); + + expect( + runVal({ + scoreData: { + lamp: "FULL RECALL", + score: 8_000_000, + judgements: { pure: 800, far: 0, lost: 1 }, + }, + }), + ).toEqual(["Cannot have a FULL RECALL with non-zero lost count."]); + + expect( + runVal({ + scoreData: { + lamp: "PURE MEMORY", + score: 10_001_151, + judgements: { pure: 1150, far: 1, lost: 0 }, + }, + }), + ).toEqual(["Cannot have a PURE MEMORY with any fars or losts."]); + + expect( + runVal({ + scoreData: { + lamp: "PURE MEMORY", + score: 10_001_151, + judgements: { pure: 1150, far: 0, lost: 1 }, + }, + }), + ).toEqual(["Cannot have a PURE MEMORY with any fars or losts."]); + }); + + it("rejects out-of-range chart scores", () => { + expect(ARCAEA_IMPL.chartSpecificValidators.score(-1, chart)).toBe( + "Score must be non-negative. Got -1", + ); + + expect(ARCAEA_IMPL.chartSpecificValidators.score(10_001_152, chart)).toBe( + `Score cannot exceed ${10_000_000 + chart.data.notecount} for this chart.`, + ); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/arcaea.ts b/typescript/server/src/game-implementations/games/arcaea.ts index 2bb9bd564..cc8a8769b 100644 --- a/typescript/server/src/game-implementations/games/arcaea.ts +++ b/typescript/server/src/game-implementations/games/arcaea.ts @@ -1,15 +1,14 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; - +import { type GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "#game-implementations/utils/session-calc"; import { IsNullish } from "#utils/misc"; import { Potential } from "rg-stats"; -import { ARCAEA_GBOUNDARIES, FmtNum, GetGrade, type GPTStrings } from "tachi-common"; +import { ARCAEA_GBOUNDARIES, FmtNum, GetGrade } from "tachi-common"; import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; -export const ARCAEA_IMPL: GPTServerImplementation = { +export const ARCAEA_IMPL: GameImplementation<"arcaea"> = { chartSpecificValidators: { score: (score, chart) => { if (score < 0) { @@ -40,8 +39,8 @@ export const ARCAEA_IMPL: GPTServerImplementation = { sessionCalcs: (arr) => ({ naivePotential: SessionAvgBest10For("potential")(arr), }), - profileCalcs: async (game, playtype, userID) => ({ - naivePotential: await ProfileAvgBestN("potential", 30)(game, playtype, userID), + profileCalcs: async (game, userID) => ({ + naivePotential: await ProfileAvgBestN("potential", 30)(game, userID), }), classDerivers: (ratings) => { const potential = ratings.naivePotential; @@ -96,7 +95,7 @@ export const ARCAEA_IMPL: GPTServerImplementation = { ), ], defaultMergeRefName: "Best Score", - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if (s.scoreData.lamp === "PURE MEMORY" && s.scoreData.score < 10_000_000) { diff --git a/typescript/server/src/game-implementations/games/bms-pms.impl.test.ts b/typescript/server/src/game-implementations/games/bms-pms.impl.test.ts new file mode 100644 index 000000000..588556b59 --- /dev/null +++ b/typescript/server/src/game-implementations/games/bms-pms.impl.test.ts @@ -0,0 +1,408 @@ +import { + BMS_7K_IMPL, + BMS_14K_IMPL, + PMS_CONTROLLER_IMPL, + PMS_KEYBOARD_IMPL, +} from "#game-implementations/games/bms-pms"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { BMSGazerChart, BMSGazerSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + type BMSGames, + type ChartDocument, + type ChartDocumentData, + IIDX_GRADES, + IIDX_LAMPS, + type MongoProvidedMetrics, + type ScoreData, + type SongDocument, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const max = 2256 * 2; + +function percentToScore(percent: number) { + return (percent / 100) * max; +} + +function scoreToPercent(score: number) { + return (100 * score) / max; +} + +const pmsGazerSong: SongDocument<"pms"> = { + id: BMSGazerSong.id, + title: BMSGazerSong.title, + artist: BMSGazerSong.artist, + searchTerms: BMSGazerSong.searchTerms, + altTitles: BMSGazerSong.altTitles, + data: { + genre: BMSGazerSong.data.genre, + subtitle: BMSGazerSong.data.subtitle, + subartist: BMSGazerSong.data.subartist, + tableString: BMSGazerSong.data.tableString, + }, +}; + +function makeChart(game: G): ChartDocument { + if (game === "bms-7k" || game === "bms-14k") { + return { + ...BMSGazerChart, + game, + song: BMSGazerSong, + } as ChartDocument; + } + + const { aiLevel: _omit, ...pmsData } = BMSGazerChart.data; + + return { + game, + chartID: BMSGazerChart.chartID, + data: pmsData as ChartDocumentData[G], + level: BMSGazerChart.level, + levelNum: BMSGazerChart.levelNum, + difficulty: "CHART", + isPrimary: true, + versions: [], + song: pmsGazerSong, + } as ChartDocument; +} + +describe.each([ + ["bms-7k", BMS_7K_IMPL] as const, + ["bms-14k", BMS_14K_IMPL] as const, + ["pms-controller", PMS_CONTROLLER_IMPL] as const, + ["pms-keyboard", PMS_KEYBOARD_IMPL] as const, +])("%s", (game, impl) => { + type G = typeof game; + + const chart = makeChart(game); + + const baseMetrics: MongoProvidedMetrics[G] = { + lamp: "CLEAR", + score: Math.floor(percentToScore(79.123)), + }; + + const scoreData: ScoreData = { + lamp: "CLEAR", + score: Math.floor(percentToScore(79.123)), + grade: "AA", + percent: 79.123, + enumIndexes: { grade: IIDX_GRADES.AA, lamp: IIDX_LAMPS.CLEAR }, + judgements: {}, + optional: { enumIndexes: {} }, + }; + + const mockScore = mkMockScore(game, chart, scoreData); + const mockPB = mkMockPB(game, chart, scoreData); + + describe("scoreDeriver", () => { + it("percent", () => { + const f = (modifant: Partial, expected: number, _msg?: string) => { + expect( + impl.scoreDeriver(dmf(baseMetrics, modifant) as never, chart as never).percent, + ).toBe(expected); + }; + + f({ score: 1000 }, (100 * 1000) / max); + f({ score: 0 }, 0); + f({ score: chart.data.notecount * 2 }, 100); + }); + + it("grade from percent", () => { + const f = (percent: number, expected: string) => { + expect( + impl.scoreDeriver( + dmf(baseMetrics, { score: percentToScore(percent) }) as never, + chart as never, + ).grade, + ).toBe(expected); + }; + + f(0, "F"); + f(22.23, "E"); + f(33.34, "D"); + f(44.45, "C"); + f(55.56, "B"); + f(66.67, "A"); + f(77.78, "AA"); + f(88.89, "AAA"); + f(94.45, "MAX-"); + f(100, "MAX"); + + f(0, "F"); + f(11.11, "F"); + f(22.22, "F"); + f(33.33, "E"); + f(44.44, "D"); + f(55.55, "C"); + f(66.66, "B"); + f(77.77, "A"); + f(88.88, "AA"); + f(94.44, "AAA"); + f(99.99, "MAX-"); + }); + }); + + describe("chartSpecificValidators", () => { + it("accepts in-range EX score", () => { + expect(impl.chartSpecificValidators.score(1000, chart as never)).toBe(true); + expect(impl.chartSpecificValidators.score(0, chart as never)).toBe(true); + expect( + impl.chartSpecificValidators.score(chart.data.notecount * 2, chart as never), + ).toBe(true); + }); + + it("rejects out-of-range EX score", () => { + expect(impl.chartSpecificValidators.score(-1, chart as never)).toBe( + "EX Score cannot be negative.", + ); + expect( + impl.chartSpecificValidators.score(chart.data.notecount * 2 + 1, chart as never), + ).toBe(`EX Score cannot be greater than ${chart.data.notecount * 2} for this chart.`); + }); + }); + + describe("scoreCalcs sieglinde", () => { + const f = ( + sd: Partial>, + chartData: Partial, + expected: number, + _msg?: string, + ) => { + const mergedSd = dmf(mockScore.scoreData, sd); + const ch = dmf(chart, { data: chartData as never }); + + expect( + impl.scoreCalcs(mergedSd, impl.scoreDeriver(mergedSd, ch as never), ch as never) + .sieglinde, + ).toBe(expected); + }; + + it("null / failed lamps", () => { + f({ lamp: "FAILED" }, {}, 0); + f({ lamp: "ASSIST CLEAR" }, {}, 0); + f({ lamp: "EASY CLEAR" }, {}, 0); + f({ lamp: "CLEAR" }, {}, 0); + f({ lamp: "HARD CLEAR" }, {}, 0); + f({ lamp: "EX HARD CLEAR" }, {}, 0); + f({ lamp: "FULL COMBO" }, {}, 0); + }); + + it("sieglinde tiers", () => { + f({ lamp: "FAILED" }, { sglEC: 10, sglHC: 11 }, 0); + f({ lamp: "ASSIST CLEAR" }, { sglEC: 10, sglHC: 11 }, 0); + f({ lamp: "EASY CLEAR" }, { sglEC: 10, sglHC: 11 }, 10); + f({ lamp: "CLEAR" }, { sglEC: 10, sglHC: 11 }, 10); + f({ lamp: "HARD CLEAR" }, { sglEC: 10, sglHC: 11 }, 11); + f({ lamp: "EX HARD CLEAR" }, { sglEC: 10, sglHC: 11 }, 11); + f({ lamp: "FULL COMBO" }, { sglEC: 10, sglHC: 11 }, 11); + + f({ lamp: "HARD CLEAR" }, { sglEC: 10 }, 10); + f({ lamp: "EX HARD CLEAR" }, { sglEC: 10 }, 10); + f({ lamp: "FULL COMBO" }, { sglEC: 10 }, 10); + }); + }); + + describe("goal formatters", () => { + it("criteria", () => { + expect(impl.goalCriteriaFormatters.percent(94.42123)).toBe("Get 94.42% on"); + expect(impl.goalCriteriaFormatters.percent(94.426)).toBe("Get 94.43% on"); + expect(impl.goalCriteriaFormatters.score(3570)).toBe("Get a score of 3570 on"); + expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on"); + }); + + it("progress", () => { + const f = ( + k: keyof typeof impl.goalProgressFormatters, + modifant: Partial>, + goalValue: unknown, + expected: string, + ) => { + expect( + impl.goalProgressFormatters[k]( + dmf(mockPB, { + scoreData: modifant, + }) as never, + goalValue as never, + ), + ).toBe(expected); + }; + + f("score", { score: 3570 }, 1000, "3570"); + f("score", { score: 0 }, 1000, "0"); + + f("percent", { percent: 92.17472 }, 100, "92.17%"); + f("percent", { percent: 92.17572 }, 100, "92.18%"); + + f( + "grade", + { score: 3570, percent: scoreToPercent(3570), grade: "AA" }, + IIDX_GRADES.AAA, + "AAA-441", + ); + f( + "grade", + { score: 3670, percent: scoreToPercent(3670), grade: "AA" }, + IIDX_GRADES.AAA, + "AAA-341", + ); + f( + "grade", + { score: 4070, percent: scoreToPercent(4070), grade: "AAA" }, + IIDX_GRADES.AAA, + "AAA+59", + ); + + f( + "lamp", + { lamp: "HARD CLEAR", optional: { bp: 2 } as never }, + IIDX_LAMPS.HARD_CLEAR, + "HARD CLEAR (BP: 2)", + ); + + f( + "lamp", + { lamp: "HARD CLEAR", optional: { bp: null } as never }, + IIDX_LAMPS.HARD_CLEAR, + "HARD CLEAR", + ); + }); + + it("outOf", () => { + expect(impl.goalOutOfFormatters.percent(94.42123)).toBe("94.42%"); + expect(impl.goalOutOfFormatters.score(3570)).toBe("3570"); + }); + }); + + describe("CreatePBDoc merges", () => { + async function seedSongAndChart() { + const gameGroup = game.startsWith("pms") ? "pms" : "bms"; + + await DB.insertInto("song") + .values({ + id: chart.song.id, + legacy_id: 27_339, + game_group: gameGroup, + title: chart.song.title, + artist: chart.song.artist, + search_terms: chart.song.searchTerms, + alt_titles: chart.song.altTitles, + data: chart.song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game, + song_id: chart.song.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); + } + + async function insertScoreRow(opts: { + scoreId: string; + sd: ScoreData; + timeMs: number; + userId: number; + }) { + const { data, derived, judgements } = mongoScoreDataToPg(game, opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game, + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); + } + + beforeEach(seedSongAndChart); + + it("joins best lamp", async () => { + const { id: userId } = await seedUser({ username: `bms_pms_pb_lamp_${game}` }); + const main = mkMockScore(game, chart, scoreData); + + const lampScore: ScoreData = { + ...scoreData, + score: 0, + lamp: "FULL COMBO", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: IIDX_LAMPS.FULL_COMBO, + }, + }; + + await insertScoreRow({ scoreId: main.scoreID, sd: scoreData, timeMs: 1000, userId }); + await insertScoreRow({ scoreId: "bestLamp", sd: lampScore, timeMs: 2000, userId }); + + const pb = await CreatePBDoc(game, userId, chart as never, log); + + expect(pb).toMatchObject({ + composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], + scoreData: { + score: scoreData.score, + lamp: "FULL COMBO", + enumIndexes: { + lamp: IIDX_LAMPS.FULL_COMBO, + }, + }, + }); + }); + + it("joins lowest BP", async () => { + const { id: userId } = await seedUser({ username: `bms_pms_pb_bp_${game}` }); + const main = mkMockScore(game, chart, scoreData); + + await insertScoreRow({ scoreId: main.scoreID, sd: scoreData, timeMs: 1000, userId }); + + const hiBp: ScoreData = { + ...scoreData, + optional: { ...scoreData.optional, bp: 100 }, + }; + const loBp: ScoreData = { + ...scoreData, + optional: { ...scoreData.optional, bp: 1 }, + }; + + await insertScoreRow({ scoreId: "whateverBP", sd: hiBp, timeMs: 2000, userId }); + await insertScoreRow({ scoreId: "lowestBP", sd: loBp, timeMs: 3000, userId }); + + const pb = await CreatePBDoc(game, userId, chart as never, log); + + expect(pb).toMatchObject({ + composedFrom: [{ name: "Best Score" }, { name: "Lowest BP", scoreID: "lowestBP" }], + scoreData: { + score: scoreData.score, + optional: { bp: 1 }, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/bms-pms.oldtest.ts b/typescript/server/src/game-implementations/games/bms-pms.oldtest.ts deleted file mode 100644 index 551dfeae0..000000000 --- a/typescript/server/src/game-implementations/games/bms-pms.oldtest.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestSnapshot } from "#test-utils/single-process-snapshot"; -import { - type ChartDocument, - type ChartDocumentData, - GetGPTString, - type GPTStrings, - IIDX_GRADES, - IIDX_LAMPS, - type ProvidedMetrics, - type ScoreData, -} from "tachi-common"; -import t from "tap"; - -import { BMS_7K_IMPL, BMS_14K_IMPL, PMS_CONTROLLER_IMPL, PMS_KEYBOARD_IMPL } from "./bms-pms"; - -type BMSPMS = GPTStrings["bms" | "pms"]; - -const max = 2256 * 2; - -const baseMetrics: ProvidedMetrics[BMSPMS] = { - lamp: "CLEAR", - score: Math.floor(percentToScore(79.123)), -}; - -const scoreData: ScoreData = { - lamp: "CLEAR", - score: Math.floor(percentToScore(79.123)), - grade: "AA", - percent: 79.123, - enumIndexes: { grade: IIDX_GRADES.AA, lamp: IIDX_LAMPS.CLEAR }, - judgements: {}, - optional: { enumIndexes: {} }, -}; - -function percentToScore(percent: number) { - return (percent / 100) * max; -} - -function scoreToPercent(score: number) { - return (100 * score) / max; -} - -for (const [game, playtype, impl] of [ - ["bms", "7K", BMS_7K_IMPL], - ["bms", "14K", BMS_14K_IMPL], - ["pms", "Controller", PMS_CONTROLLER_IMPL], - ["pms", "Keyboard", PMS_KEYBOARD_IMPL], -] as const) { - const chart: ChartDocument = { - songID: 27339, - chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", - data: { - aiLevel: "0", - notecount: 2256, - hashMD5: "38616b85332037cc12924f2ae2840262", - hashSHA256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", - tableFolders: [ - { - level: "17", - table: "★", - }, - ], - sglEC: null, - sglHC: null, - }, - level: "?", - levelNum: 0, - difficulty: "CHART", - playtype, - isPrimary: true, - versions: [], - }; - - const mockScore = mkMockScore(game, playtype, chart, scoreData); - const mockPB = mkMockPB(game, playtype, chart, scoreData); - const gptStr = GetGPTString(game, playtype); - - t.test("Derivers", (t) => { - t.test("Percent", (t) => { - const f = (modifant: Partial, expected: any, msg: string) => - t.equal( - impl.scoreDeriver(dmf(baseMetrics, modifant) as any, chart as any).percent, - expected, - msg, - ); - - f( - { score: 1000 }, - (100 * 1000) / max, - "An EXScore of 1000 should result in a percent of 1000 / (notecount*2)", - ); - - f({ score: 0 }, 0, "An EXScore of 0 should count as 0%."); - - f({ score: chart.data.notecount * 2 }, 100, "A perfect score should be worth 100%"); - - t.end(); - }); - - t.test("Grade", (t) => { - const f = (percent: number, expected: any) => - t.equal( - impl.scoreDeriver( - dmf(baseMetrics, { score: percentToScore(percent) }) as any, - chart as any, - ).grade, - expected, - `A percent of ${percent}% should result in grade=${expected}.`, - ); - - f(0, "F"); - f(22.23, "E"); - f(33.34, "D"); - f(44.45, "C"); - f(55.56, "B"); - f(66.67, "A"); - f(77.78, "AA"); - f(88.89, "AAA"); - f(94.45, "MAX-"); - f(100, "MAX"); - - // almosts - f(0, "F"); - f(11.11, "F"); - f(22.22, "F"); - f(33.33, "E"); - f(44.44, "D"); - f(55.55, "C"); - f(66.66, "B"); - f(77.77, "A"); - f(88.88, "AA"); - f(94.44, "AAA"); - f(99.99, "MAX-"); - - t.end(); - }); - - t.end(); - }); - - t.test("Validators", (t) => { - t.equal(impl.chartSpecificValidators.score(1000, chart as any), true); - t.equal(impl.chartSpecificValidators.score(0, chart as any), true); - t.equal(impl.chartSpecificValidators.score(chart.data.notecount * 2, chart as any), true); - - TestSnapshot( - t, - impl.chartSpecificValidators.score(-1, chart as any), - `EX Score Validator ${playtype}: negative`, - ); - TestSnapshot( - t, - impl.chartSpecificValidators.score(chart.data.notecount * 2 + 1, chart as any), - `EX Score Validator ${playtype}: excessive`, - ); - - t.end(); - }); - - t.test("CalculatedData", (t) => { - t.test("Sieglinde", (t) => { - const f = ( - scoreData: Partial>, - chartData: Partial, - expected: any, - msg: string, - ) => { - const sd = dmf(mockScore.scoreData, scoreData); - const ch = dmf(chart, { data: chartData as any }); - - return t.equal( - impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).sieglinde, - expected, - msg, - ); - }; - - t.test("null sieglinde", (t) => { - f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0"); - f({ lamp: "ASSIST CLEAR" }, {}, 0, "ac should be worth 0"); - f({ lamp: "EASY CLEAR" }, {}, 0, "ec should be worth 0"); - f({ lamp: "CLEAR" }, {}, 0, "nc should be worth 0"); - f({ lamp: "HARD CLEAR" }, {}, 0, "hc should be worth 0"); - f({ lamp: "EX HARD CLEAR" }, {}, 0, "exhc should be worth 0"); - f({ lamp: "FULL COMBO" }, {}, 0, "fc should be worth 0"); - - t.end(); - }); - - f({ lamp: "FAILED" }, { sglEC: 10, sglHC: 11 }, 0, "fails should be worth 0"); - f({ lamp: "ASSIST CLEAR" }, { sglEC: 10, sglHC: 11 }, 0, "ac should be worth 0"); - f({ lamp: "EASY CLEAR" }, { sglEC: 10, sglHC: 11 }, 10, "ec should be worth sglEC"); - f({ lamp: "CLEAR" }, { sglEC: 10, sglHC: 11 }, 10, "nc should be worth sglEC"); - f({ lamp: "HARD CLEAR" }, { sglEC: 10, sglHC: 11 }, 11, "hc should be worth sglHC"); - f( - { lamp: "EX HARD CLEAR" }, - { sglEC: 10, sglHC: 11 }, - 11, - "exhc should be worth sglHC", - ); - f({ lamp: "FULL COMBO" }, { sglEC: 10, sglHC: 11 }, 11, "fc should be worth sglHC"); - - f({ lamp: "HARD CLEAR" }, { sglEC: 10 }, 10, "hc should fallback to EC"); - f({ lamp: "EX HARD CLEAR" }, { sglEC: 10 }, 10, "exhc should fallback to EC"); - f({ lamp: "FULL COMBO" }, { sglEC: 10 }, 10, "fc should fallback to EC"); - - t.end(); - }); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal(impl.goalCriteriaFormatters.percent(94.42123), "Get 94.42% on"); - t.equal(impl.goalCriteriaFormatters.percent(94.426), "Get 94.43% on"); - - t.equal(impl.goalCriteriaFormatters.score(3570), "Get a score of 3570 on"); - t.equal(impl.goalCriteriaFormatters.score(0), "Get a score of 0 on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof impl.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - impl.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("score", { score: 3570 }, 1000, "3570"); - f("score", { score: 0 }, 1000, "0"); - - f("percent", { percent: 92.17472 }, 100, "92.17%"); - f("percent", { percent: 92.17572 }, 100, "92.18%"); - - f( - "grade", - { score: 3570, percent: scoreToPercent(3570), grade: "AA" }, - IIDX_GRADES.AAA, - "AAA-441", - ); - f( - "grade", - { score: 3670, percent: scoreToPercent(3670), grade: "AA" }, - IIDX_GRADES.AAA, - "AAA-341", - ); - f( - "grade", - { score: 4070, percent: scoreToPercent(4070), grade: "AAA" }, - IIDX_GRADES.AAA, - "AAA+59", - ); - - f( - "lamp", - { lamp: "HARD CLEAR", optional: { bp: 2 } as any }, - IIDX_LAMPS.HARD_CLEAR, - "HARD CLEAR (BP: 2)", - ); - - f( - "lamp", - { lamp: "HARD CLEAR", optional: { bp: null } as any }, - IIDX_LAMPS.HARD_CLEAR, - "HARD CLEAR", - ); - - t.end(); - }); - - t.test("OutOf", (t) => { - t.equal(impl.goalOutOfFormatters.percent(94.42123), "94.42%"); - t.equal(impl.goalOutOfFormatters.score(3570), "3570"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Mergers", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.scores.insert(mockScore)); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - score: 0, - lamp: "FULL COMBO", - enumIndexes: { lamp: IIDX_LAMPS.FULL_COMBO }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc(gptStr, 1, chart, log), { - composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], - scoreData: { - score: 3570, - lamp: "FULL COMBO", - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - }, - }, - }); - - t.end(); - }); - - t.test("Should join best BP", async (t) => { - await MONGODB_KILL.scores.insert([ - dmf(mockScore, { - scoreID: "whateverBP", - scoreData: { optional: { bp: 100 } }, - }), - dmf(mockScore, { - scoreID: "lowestBP", - scoreData: { optional: { bp: 1 } }, - }), - ]); - - t.hasStrict(await CreatePBDoc(gptStr, 1, chart, log), { - composedFrom: [{ name: "Best Score" }, { name: "Lowest BP", scoreID: "lowestBP" }], - scoreData: { - score: 3570, - optional: { bp: 1 }, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.end(); -} diff --git a/typescript/server/src/game-implementations/games/bms-pms.ts b/typescript/server/src/game-implementations/games/bms-pms.ts index 93226b177..90b73cd4f 100644 --- a/typescript/server/src/game-implementations/games/bms-pms.ts +++ b/typescript/server/src/game-implementations/games/bms-pms.ts @@ -1,7 +1,7 @@ -import type { GPTServerImplementation, PBMergeFunction } from "#game-implementations/types"; +import type { GameImplementation, PBMergeFunction } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; -import { type GPTStrings, IIDXLIKE_GBOUNDARIES } from "tachi-common"; +import { type BMSGames, IIDXLIKE_GBOUNDARIES } from "tachi-common"; import { GoalFmtPercent, @@ -16,7 +16,7 @@ import { SGL_SESSION_CALCS, } from "./_common"; -const BMS_PMS_MERGERS: Array> = [ +const BMS_PMS_MERGERS: Array> = [ CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, lamp) => { base.scoreData.lamp = lamp.scoreData.lamp; @@ -36,7 +36,7 @@ const BMS_PMS_MERGERS: Array> = [ // bms and pms currently have *identical* // implementations. Nice. -const BMS_IMPL: GPTServerImplementation = { +const BMS_IMPL: GameImplementation = { scoreDeriver: IIDXLIKE_SCORE_DERIVER, scoreCalcs: SGL_SCORE_CALCS, sessionCalcs: SGL_SESSION_CALCS, @@ -84,12 +84,12 @@ const BMS_IMPL: GPTServerImplementation = { pbMergeFunctions: BMS_PMS_MERGERS, defaultMergeRefName: "Best Score", scoreValidators: IIDXLIKE_SCORE_VALIDATORS, - derivationRelevantFields: ["data.notecount", "data.sglEC", "data.sglHC"], + chartDataRelevantFields: ["data.notecount", "data.sglEC", "data.sglHC"], }; -export const BMS_14K_IMPL: GPTServerImplementation<"bms:14K"> = BMS_IMPL; +export const BMS_14K_IMPL: GameImplementation<"bms-14k"> = BMS_IMPL; -export const BMS_7K_IMPL: GPTServerImplementation<"bms:7K"> = BMS_IMPL; +export const BMS_7K_IMPL: GameImplementation<"bms-7k"> = BMS_IMPL; -export const PMS_CONTROLLER_IMPL: GPTServerImplementation<"pms:Controller"> = BMS_IMPL; -export const PMS_KEYBOARD_IMPL: GPTServerImplementation<"pms:Keyboard"> = BMS_IMPL; +export const PMS_CONTROLLER_IMPL: GameImplementation<"pms-controller"> = BMS_IMPL; +export const PMS_KEYBOARD_IMPL: GameImplementation<"pms-keyboard"> = BMS_IMPL; diff --git a/typescript/server/src/game-implementations/games/chunithm.oldtest.ts b/typescript/server/src/game-implementations/games/chunithm.oldtest.ts deleted file mode 100644 index a66737dc6..000000000 --- a/typescript/server/src/game-implementations/games/chunithm.oldtest.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { CHUNITHMBBKKChart, TestingChunithmScorePB } from "#test-utils/test-data"; -import { - CHUNITHM_CLEAR_LAMPS, - CHUNITHM_GRADES, - CHUNITHM_NOTE_LAMPS, - type ProvidedMetrics, - type ScoreData, -} from "tachi-common"; -import t from "tap"; - -import { CHUNITHM_IMPL } from "./chunithm"; - -const baseMetrics: ProvidedMetrics["chunithm:Single"] = { - clearLamp: "CLEAR", - noteLamp: "NONE", - score: 1_003_000, -}; - -const scoreData: ScoreData<"chunithm:Single"> = { - clearLamp: "CLEAR", - noteLamp: "NONE", - score: 1_003_000, - grade: "SS", - judgements: {}, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: CHUNITHM_GRADES.SS, - clearLamp: CHUNITHM_CLEAR_LAMPS.CLEAR, - noteLamp: CHUNITHM_NOTE_LAMPS.NONE, - }, -}; - -const mockScore = mkMockScore("chunithm", "Single", CHUNITHMBBKKChart, scoreData); -const mockPB = mkMockPB("chunithm", "Single", CHUNITHMBBKKChart, scoreData); - -t.test("CHUNITHM Implementation", (t) => { - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: any) => - t.equal( - CHUNITHM_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, CHUNITHMBBKKChart) - .grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "D"); - f(500_000, "C"); - f(600_000, "B"); - f(700_000, "BB"); - f(800_000, "BBB"); - f(900_000, "A"); - f(925_000, "AA"); - f(950_000, "AAA"); - f(975_000, "S"); - f(990_000, "S+"); - f(1_000_000, "SS"); - f(1_005_000, "SS+"); - f(1_007_500, "SSS"); - f(1_009_000, "SSS+"); - - t.end(); - }); - - t.test("Rating Calc", (t) => { - t.equal( - CHUNITHM_IMPL.scoreCalcs( - scoreData, - CHUNITHM_IMPL.scoreDeriver(scoreData, CHUNITHMBBKKChart), - CHUNITHMBBKKChart, - ).rating, - 4.3, - "Basic rating check", - ); - - t.end(); - }); - - t.todo("Session Calcs"); - - t.test("Profile Calcs", (t) => { - t.beforeEach(ResetDBState); - - const mockPBs = async (ratings: Array) => { - await Promise.all( - ratings.map((rating, idx) => - MONGODB_KILL["personal-bests"].insert({ - ...TestingChunithmScorePB, - chartID: `TEST${idx}`, - calculatedData: { - ...TestingChunithmScorePB.calculatedData, - rating, - }, - }), - ), - ); - }; - - t.test("Floating-point edge case", async (t) => { - await mockPBs(Array(50).fill(17.15)); - - t.equal((await CHUNITHM_IMPL.profileCalcs("chunithm", "Single", 1)).naiveRating, 17.15); - - t.end(); - }); - - t.test("Profile with fewer than 50 scores", async (t) => { - await mockPBs([16, 16, 16, 16]); - - t.equal((await CHUNITHM_IMPL.profileCalcs("chunithm", "Single", 1)).naiveRating, 1.28); - - t.end(); - }); - - t.end(); - }); - - t.test("Colour Deriver", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - CHUNITHM_IMPL.classDerivers({ naiveRating: v }).colour, - expected, - `A naiveRating of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "BLUE"); - f(2, "GREEN"); - f(4, "ORANGE"); - f(7, "RED"); - f(10, "PURPLE"); - f(12, "COPPER"); - f(13.25, "SILVER"); - f(14.5, "GOLD"); - f(15.25, "PLATINUM"); - f(16, "RAINBOW"); - f(16.25, "RAINBOW_II"); - f(16.5, "RAINBOW_III"); - f(16.75, "RAINBOW_IV"); - f(17, "RAINBOW_EX_I"); - f(17.25, "RAINBOW_EX_II"); - f(17.5, "RAINBOW_EX_III"); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal( - CHUNITHM_IMPL.goalCriteriaFormatters.score(1_008_182), - "Get a score of 1,008,182 on", - ); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof CHUNITHM_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - CHUNITHM_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "S+", score: 997_342 }, CHUNITHM_GRADES.SS, "SS-2.7K"); - f("score", { score: 982_123 }, 1_000_000, "982,123"); - f("clearLamp", { clearLamp: "CLEAR" }, CHUNITHM_CLEAR_LAMPS.CLEAR, "CLEAR"); - f("noteLamp", { noteLamp: "FULL COMBO" }, CHUNITHM_NOTE_LAMPS.FULL_COMBO, "FULL COMBO"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(CHUNITHM_IMPL.goalOutOfFormatters.score(1_001_003), "1,001,003"); - t.equal(CHUNITHM_IMPL.goalOutOfFormatters.score(983_132), "983,132"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestNoteLamp", - scoreData: { - score: 0, - noteLamp: "FULL COMBO", - enumIndexes: { noteLamp: CHUNITHM_NOTE_LAMPS.FULL_COMBO }, - }, - }), - ); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestClearLamp", - scoreData: { - score: 0, - clearLamp: "ABSOLUTE", - enumIndexes: { clearLamp: CHUNITHM_CLEAR_LAMPS.ABSOLUTE }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("chunithm:Single", 1, CHUNITHMBBKKChart, log), { - composedFrom: [ - { name: "Best Score" }, - { name: "Best Note Lamp", scoreID: "bestNoteLamp" }, - { name: "Best Clear Lamp", scoreID: "bestClearLamp" }, - ], - scoreData: { - score: mockScore.scoreData.score, - clearLamp: "ABSOLUTE", - noteLamp: "FULL COMBO", - enumIndexes: { - clearLamp: CHUNITHM_CLEAR_LAMPS.ABSOLUTE, - noteLamp: CHUNITHM_NOTE_LAMPS.FULL_COMBO, - }, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/chunithm.test.ts b/typescript/server/src/game-implementations/games/chunithm.test.ts new file mode 100644 index 000000000..06c653deb --- /dev/null +++ b/typescript/server/src/game-implementations/games/chunithm.test.ts @@ -0,0 +1,333 @@ +import { CHUNITHM_IMPL } from "#game-implementations/games/chunithm"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { CHUNITHMBBKKChart, CHUNITHMBBKKSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + CHUNITHM_CLEAR_LAMPS, + CHUNITHM_GRADES, + CHUNITHM_NOTE_LAMPS, + type MongoProvidedMetrics, + type ScoreData, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const chart = CHUNITHMBBKKChart; + +const baseMetrics: MongoProvidedMetrics["chunithm"] = { + clearLamp: "CLEAR", + noteLamp: "NONE", + score: 1_003_000, +}; + +const scoreData: ScoreData<"chunithm"> = { + clearLamp: "CLEAR", + noteLamp: "NONE", + score: 1_003_000, + grade: "SS", + judgements: {}, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: CHUNITHM_GRADES.SS, + clearLamp: CHUNITHM_CLEAR_LAMPS.CLEAR, + noteLamp: CHUNITHM_NOTE_LAMPS.NONE, + }, +}; + +let profileChartCounter = 0; + +async function seedChunithmChartsWithPbs(userId: number, ratings: Array) { + const start = profileChartCounter; + + await Promise.all( + ratings.map(async (rating, i) => { + const n = start + i + 1; + const songId = `song-chuni-prof-${n}`; + const chartId = `chart-chuni-prof-${n}`; + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: 80_000 + n, + game_group: "chunithm", + title: "P", + artist: "A", + search_terms: [], + alt_titles: [], + data: {}, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "chunithm", + song_id: songId, + level: "12", + level_num: 12, + is_primary: true, + difficulty: "MASTER", + versions: [], + data: {}, + }) + .execute(); + + await DB.insertInto("pb") + .values({ + user_id: userId, + chart_id: chartId, + lens: null, + data: JSON.stringify({}), + derived_data: JSON.stringify({}), + calculated_data: JSON.stringify({ rating }), + judgements: JSON.stringify({}), + ranking_value: rating, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: false, + time_achieved: null, + }) + .execute(); + }), + ); + + profileChartCounter += ratings.length; +} + +async function seedBbkkChart() { + await DB.insertInto("song") + .values({ + id: CHUNITHMBBKKSong.id, + legacy_id: 3, + game_group: "chunithm", + title: CHUNITHMBBKKSong.title, + artist: CHUNITHMBBKKSong.artist, + search_terms: CHUNITHMBBKKSong.searchTerms, + alt_titles: CHUNITHMBBKKSong.altTitles, + data: CHUNITHMBBKKSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "chunithm", + song_id: CHUNITHMBBKKSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertChuniScore(opts: { + scoreId: string; + sd: ScoreData<"chunithm">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("chunithm", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "chunithm", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("CHUNITHM_IMPL", () => { + describe("scoreDeriver (grade)", () => { + const g = (score: number, expected: string) => + expect( + CHUNITHM_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never) + .grade, + ).toBe(expected); + + it("maps score to grade", () => { + g(0, "D"); + g(500_000, "C"); + g(600_000, "B"); + g(700_000, "BB"); + g(800_000, "BBB"); + g(900_000, "A"); + g(925_000, "AA"); + g(950_000, "AAA"); + g(975_000, "S"); + g(990_000, "S+"); + g(1_000_000, "SS"); + g(1_005_000, "SS+"); + g(1_007_500, "SSS"); + g(1_009_000, "SSS+"); + }); + }); + + it("scoreCalcs rating", () => { + expect( + CHUNITHM_IMPL.scoreCalcs(scoreData, CHUNITHM_IMPL.scoreDeriver(scoreData, chart), chart) + .rating, + ).toBe(4.3); + }); + + describe("profileCalcs naiveRating", () => { + it("handles floating-point mean of top 50", async () => { + const { id: userId } = await seedUser({ username: "chuni_prof_fp" }); + await seedChunithmChartsWithPbs(userId, Array(50).fill(17.15)); + + const res = await CHUNITHM_IMPL.profileCalcs("chunithm", userId); + + expect(res.naiveRating).toBe(17.15); + }); + + it("averages fewer than 50 PBs over 50", async () => { + const { id: userId } = await seedUser({ username: "chuni_prof_short" }); + await seedChunithmChartsWithPbs(userId, [16, 16, 16, 16]); + + const res = await CHUNITHM_IMPL.profileCalcs("chunithm", userId); + + expect(res.naiveRating).toBe(1.28); + }); + }); + + describe("classDerivers (naiveRating → colour)", () => { + const c = ( + v: number | null, + expected: ReturnType["colour"], + ) => expect(CHUNITHM_IMPL.classDerivers({ naiveRating: v }).colour).toBe(expected); + + it("maps rating tiers", () => { + c(null, null); + c(0, "BLUE"); + c(2, "GREEN"); + c(4, "ORANGE"); + c(7, "RED"); + c(10, "PURPLE"); + c(12, "COPPER"); + c(13.25, "SILVER"); + c(14.5, "GOLD"); + c(15.25, "PLATINUM"); + c(16, "RAINBOW"); + c(16.25, "RAINBOW_II"); + c(16.5, "RAINBOW_III"); + c(16.75, "RAINBOW_IV"); + c(17, "RAINBOW_EX_I"); + c(17.25, "RAINBOW_EX_II"); + c(17.5, "RAINBOW_EX_III"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("chunithm", chart, scoreData); + + it("formats criteria", () => { + expect(CHUNITHM_IMPL.goalCriteriaFormatters.score(1_008_182)).toBe( + "Get a score of 1,008,182 on", + ); + }); + + it("formats progress", () => { + const f = ( + k: keyof typeof CHUNITHM_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + CHUNITHM_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "S+", score: 997_342 }, CHUNITHM_GRADES.SS, "SS-2.7K"); + f("score", { score: 982_123 }, 1_000_000, "982,123"); + f("clearLamp", { clearLamp: "CLEAR" }, CHUNITHM_CLEAR_LAMPS.CLEAR, "CLEAR"); + f("noteLamp", { noteLamp: "FULL COMBO" }, CHUNITHM_NOTE_LAMPS.FULL_COMBO, "FULL COMBO"); + }); + + it("formats out-of", () => { + expect(CHUNITHM_IMPL.goalOutOfFormatters.score(1_001_003)).toBe("1,001,003"); + expect(CHUNITHM_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedBbkkChart); + + it("joins best note and clear lamps", async () => { + const { id: userId } = await seedUser({ username: "chuni_pb" }); + const main = mkMockScore("chunithm", chart, scoreData); + + const noteSd: ScoreData<"chunithm"> = { + ...scoreData, + score: 0, + noteLamp: "FULL COMBO", + enumIndexes: { + ...scoreData.enumIndexes, + noteLamp: CHUNITHM_NOTE_LAMPS.FULL_COMBO, + }, + }; + + const clearSd: ScoreData<"chunithm"> = { + ...scoreData, + score: 0, + clearLamp: "ABSOLUTE", + enumIndexes: { + ...scoreData.enumIndexes, + clearLamp: CHUNITHM_CLEAR_LAMPS.ABSOLUTE, + }, + }; + + await insertChuniScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertChuniScore({ userId, scoreId: "bestNoteLamp", sd: noteSd, timeMs: 2000 }); + await insertChuniScore({ userId, scoreId: "bestClearLamp", sd: clearSd, timeMs: 3000 }); + + const pb = await CreatePBDoc("chunithm", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Score" }, + { name: "Best Note Lamp", scoreID: "bestNoteLamp" }, + { name: "Best Clear Lamp", scoreID: "bestClearLamp" }, + ], + scoreData: { + score: scoreData.score, + clearLamp: "ABSOLUTE", + noteLamp: "FULL COMBO", + enumIndexes: { + clearLamp: CHUNITHM_CLEAR_LAMPS.ABSOLUTE, + noteLamp: CHUNITHM_NOTE_LAMPS.FULL_COMBO, + }, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/chunithm.ts b/typescript/server/src/game-implementations/games/chunithm.ts index 2530af2c3..9aa9ebcee 100644 --- a/typescript/server/src/game-implementations/games/chunithm.ts +++ b/typescript/server/src/game-implementations/games/chunithm.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc"; @@ -9,7 +9,7 @@ import { CHUNITHM_GBOUNDARIES, FmtNum, GetGrade } from "tachi-common"; import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; -export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = { +export const CHUNITHM_IMPL: GameImplementation<"chunithm"> = { chartSpecificValidators: {}, scoreDeriver: (scoreData, _chart) => ({ grade: GetGrade(CHUNITHM_GBOUNDARIES, scoreData.score), @@ -28,8 +28,8 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = { sessionCalcs: (arr) => ({ naiveRating: SessionAvgBest10For("rating")(arr), }), - profileCalcs: async (game, playtype, userID) => ({ - naiveRating: await ProfileAvgBestN("rating", 50, false, 100)(game, playtype, userID), + profileCalcs: async (game, userID) => ({ + naiveRating: await ProfileAvgBestN("rating", 50, false, 100)(game, userID), }), classDerivers: (ratings) => { const rating = ratings.naiveRating; @@ -109,7 +109,7 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = { ), ], defaultMergeRefName: "Best Score", - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if ( diff --git a/typescript/server/src/game-implementations/games/ddr.oldtest.ts b/typescript/server/src/game-implementations/games/ddr.oldtest.ts deleted file mode 100644 index 6d81efbab..000000000 --- a/typescript/server/src/game-implementations/games/ddr.oldtest.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { dmf, mkFakePBDDRSP } from "#test-utils/misc"; -import { Testing511SPA, TestingDDRSP, TestingDDRSPScore } from "#test-utils/test-data"; -import { - type ChartDocumentData, - DDR_GRADES, - DDR_LAMPS, - type ProvidedMetrics, - type ScoreData, -} from "tachi-common"; -import t from "tap"; - -import { TestSnapshot } from "../../test-utils/single-process-snapshot"; -import { DDR_IMPL } from "./ddr"; -import { IIDX_DP_IMPL, IIDX_SP_IMPL } from "./iidx"; - -const baseMetrics: ProvidedMetrics["ddr:DP" | "ddr:SP"] = { - lamp: "CLEAR", - score: 750_000, -}; - -t.test("DDR Implementation", (t) => { - t.test("Derivers", (t) => { - const impl = DDR_IMPL; - - t.test("Grade", (t) => { - const f = (score: number, expected: any) => - t.equal( - impl.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingDDRSP as any) - .grade, - expected, - `A score of ${score} should result in grade=${expected}.`, - ); - - f(990500, "AAA"); - f(950500, "AA+"); - f(900500, "AA"); - f(890500, "AA-"); - f(850500, "A+"); - f(800500, "A"); - f(790500, "A-"); - f(750500, "B+"); - f(700500, "B"); - f(690500, "B-"); - f(650500, "C+"); - f(600500, "C"); - f(590500, "C-"); - f(550500, "D+"); - f(500, "D"); - - t.end(); - }); - - t.end(); - }); - t.test("Class Derivers", (t) => { - const impl = DDR_IMPL; - - t.test("Flare", (t) => { - const f = (ratings: number, expected: any) => - t.equal( - impl.classDerivers({ flareSkill: ratings }).flare, - expected, - `A flare skill of ${ratings} should result in grade=${expected}.`, - ); - - f(1, "NONE"); - f(501, "NONE+"); - f(1001, "NONE++"); - f(1501, "NONE+++"); - f(2001, "MERCURY"); - f(3001, "MERCURY+"); - f(4001, "MERCURY++"); - f(5001, "MERCURY+++"); - f(6001, "VENUS"); - f(7001, "VENUS+"); - f(8001, "VENUS++"); - f(9001, "VENUS+++"); - f(10001, "EARTH"); - f(11501, "EARTH+"); - f(13001, "EARTH++"); - f(14501, "EARTH+++"); - f(16001, "MARS"); - f(18001, "MARS+"); - f(20001, "MARS++"); - f(22001, "MARS+++"); - f(24001, "JUPITER"); - f(26501, "JUPITER+"); - f(29001, "JUPITER++"); - f(31501, "JUPITER+++"); - f(34001, "SATURN"); - f(36751, "SATURN+"); - f(39501, "SATURN++"); - f(42251, "SATURN+++"); - f(45001, "URANUS"); - f(48751, "URANUS+"); - f(52501, "URANUS++"); - f(56251, "URANUS+++"); - f(60001, "NEPTUNE"); - f(63751, "NEPTUNE+"); - f(67501, "NEPTUNE++"); - f(71251, "NEPTUNE+++"); - f(75001, "SUN"); - f(78751, "SUN+"); - f(82501, "SUN++"); - f(86251, "SUN+++"); - f(90001, "WORLD"); - - t.end(); - }); - - t.end(); - }); - - t.test("CalculatedData", (t) => { - const impl = DDR_IMPL; - - t.test("Flare Skill", (t) => { - const f = ( - scoreData: Partial>, - chartData: Partial, - expected: any, - msg: string, - ) => { - const sd = dmf(TestingDDRSPScore.scoreData, scoreData); - const ch = dmf(TestingDDRSP, { data: chartData as any }) as any; - - return t.equal( - impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).flareSkill, - expected, - msg, - ); - }; - - f( - { grade: "AA", lamp: "CLEAR", optional: { flare: "II", enumIndexes: {} } }, - {}, - 257, - "Level 6 song with Flare II should give 257 flare skill", - ); - - f({ grade: "E", lamp: "FAILED" }, {}, 0, "Song failed: flare skill should be 0"); - - t.end(); - }); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - const impl = DDR_IMPL; - - t.test("Criteria", (t) => { - t.equal(impl.goalCriteriaFormatters.score(123456), "Get a score of 123,456 on"); - t.equal(impl.goalCriteriaFormatters.score(0), "Get a score of 0 on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof impl.goalProgressFormatters, - modifant: Partial, - goalValue: any, - expected: any, - ) => - t.equal( - impl.goalProgressFormatters[k]( - mkFakePBDDRSP({ - // @ts-expect-error lol deepmerge types - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("score", { score: 123_456 }, 1_000_000, "123,456"); - f("score", { score: 0 }, 1_000_000, "0"); - - f("grade", { score: 955000, grade: "AA+" }, DDR_GRADES.AAA, "AAA-35,000"); - f("grade", { score: 995000, grade: "AAA" }, DDR_GRADES.AAA, "AAA+5,000"); - - f("lamp", { lamp: "CLEAR", optional: {} as any }, DDR_LAMPS.FULL_COMBO, "CLEAR"); - - f( - "lamp", - { lamp: "FULL COMBO", optional: {} as any }, - DDR_LAMPS.FULL_COMBO, - "FULL COMBO", - ); - - t.end(); - }); - - t.test("OutOf", (t) => { - t.equal(impl.goalOutOfFormatters.score(123456), "123,456"); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/ddr.profile.test.ts b/typescript/server/src/game-implementations/games/ddr.profile.test.ts index b374a634e..b9aa0dab3 100644 --- a/typescript/server/src/game-implementations/games/ddr.profile.test.ts +++ b/typescript/server/src/game-implementations/games/ddr.profile.test.ts @@ -84,7 +84,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { it("returns flareSkill null when the user has no qualifying PBs", async () => { const { id: userId } = await seedUser(); - const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId); + const result = await DDR_IMPL.profileCalcs("ddr-sp", userId); expect(result).toEqual({ flareSkill: null }); }); @@ -93,12 +93,12 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { const { id: userId } = await seedUser(); await seedDdrPbRow(userId, { flareSkill: 42, flareCategory: "CLASSIC" }); - const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId); + const result = await DDR_IMPL.profileCalcs("ddr-sp", userId); expect(result).toEqual({ flareSkill: 42 }); }); - it("uses ddr-dp charts when playtype is DP", async () => { + it("uses ddr-dp charts when game is ddr-dp", async () => { const { id: userId } = await seedUser(); await seedDdrPbRow(userId, { flareSkill: 77, @@ -106,10 +106,10 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { game: "ddr-dp", }); - const spOnly = await DDR_IMPL.profileCalcs("ddr", "SP", userId); + const spOnly = await DDR_IMPL.profileCalcs("ddr-sp", userId); expect(spOnly).toEqual({ flareSkill: null }); - const dp = await DDR_IMPL.profileCalcs("ddr", "DP", userId); + const dp = await DDR_IMPL.profileCalcs("ddr-dp", userId); expect(dp).toEqual({ flareSkill: 77 }); }); @@ -117,7 +117,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { const { id: userId } = await seedUser(); await seedDdrPbRow(userId, { flareSkill: 999, flareCategory: "OTHER" }); - const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId); + const result = await DDR_IMPL.profileCalcs("ddr-sp", userId); expect(result).toEqual({ flareSkill: 0 }); }); @@ -130,7 +130,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { calculatedDataOverride: { flareSkill: "not-a-number" }, }); - const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId); + const result = await DDR_IMPL.profileCalcs("ddr-sp", userId); expect(result).toEqual({ flareSkill: null }); }); @@ -143,7 +143,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { isPrimary: false, }); - const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId); + const result = await DDR_IMPL.profileCalcs("ddr-sp", userId); expect(result).toEqual({ flareSkill: null }); }); @@ -158,7 +158,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { }); } - const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId); + const result = await DDR_IMPL.profileCalcs("ddr-sp", userId); const expectedSum = Array.from({ length: 30 }, (_, k) => 1031 - k).reduce( (a, b) => a + b, @@ -174,7 +174,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { await seedDdrPbRow(userId, { flareSkill: 200, flareCategory: "CLASSIC" }); await seedDdrPbRow(userId, { flareSkill: 100, flareCategory: "WHITE" }); - const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId); + const result = await DDR_IMPL.profileCalcs("ddr-sp", userId); expect(result).toEqual({ flareSkill: 600 }); }); diff --git a/typescript/server/src/game-implementations/games/ddr.test.ts b/typescript/server/src/game-implementations/games/ddr.test.ts new file mode 100644 index 000000000..3bf310333 --- /dev/null +++ b/typescript/server/src/game-implementations/games/ddr.test.ts @@ -0,0 +1,164 @@ +import { DDR_IMPL } from "#game-implementations/games/ddr"; +import { dmf, mkFakePBDDRSP } from "#test-utils/misc"; +import { TestingDDRSP, TestingDDRSPScore } from "#test-utils/test-data"; +import { + type ChartDocumentData, + DDR_GRADES, + DDR_LAMPS, + type integer, + type MongoProvidedMetrics, + type ScoreData, +} from "tachi-common"; +import { describe, expect, it } from "vitest"; + +const baseMetrics: MongoProvidedMetrics["ddr-dp" | "ddr-sp"] = { + lamp: "CLEAR", + score: 750_000, +}; + +describe("DDR_IMPL", () => { + describe("scoreDeriver grade", () => { + const impl = DDR_IMPL; + const f = (score: number, expected: string) => { + expect( + impl.scoreDeriver(dmf(baseMetrics, { score }) as never, TestingDDRSP as never) + .grade, + ).toBe(expected); + }; + + it("maps EX score to DDR letter grades", () => { + f(990500, "AAA"); + f(950500, "AA+"); + f(900500, "AA"); + f(890500, "AA-"); + f(850500, "A+"); + f(800500, "A"); + f(790500, "A-"); + f(750500, "B+"); + f(700500, "B"); + f(690500, "B-"); + f(650500, "C+"); + f(600500, "C"); + f(590500, "C-"); + f(550500, "D+"); + f(500, "D"); + }); + }); + + describe("classDerivers flare", () => { + const impl = DDR_IMPL; + const f = (ratings: number, expected: string) => { + expect(impl.classDerivers({ flareSkill: ratings }).flare).toBe(expected); + }; + + it("maps flare skill to flare rank", () => { + f(1, "NONE"); + f(501, "NONE+"); + f(1001, "NONE++"); + f(1501, "NONE+++"); + f(2001, "MERCURY"); + f(3001, "MERCURY+"); + f(4001, "MERCURY++"); + f(5001, "MERCURY+++"); + f(6001, "VENUS"); + f(7001, "VENUS+"); + f(8001, "VENUS++"); + f(9001, "VENUS+++"); + f(10001, "EARTH"); + f(11501, "EARTH+"); + f(13001, "EARTH++"); + f(14501, "EARTH+++"); + f(16001, "MARS"); + f(18001, "MARS+"); + f(20001, "MARS++"); + f(22001, "MARS+++"); + f(24001, "JUPITER"); + f(26501, "JUPITER+"); + f(29001, "JUPITER++"); + f(31501, "JUPITER+++"); + f(34001, "SATURN"); + f(36751, "SATURN+"); + f(39501, "SATURN++"); + f(42251, "SATURN+++"); + f(45001, "URANUS"); + f(48751, "URANUS+"); + f(52501, "URANUS++"); + f(56251, "URANUS+++"); + f(60001, "NEPTUNE"); + f(63751, "NEPTUNE+"); + f(67501, "NEPTUNE++"); + f(71251, "NEPTUNE+++"); + f(75001, "SUN"); + f(78751, "SUN+"); + f(82501, "SUN++"); + f(86251, "SUN+++"); + f(90001, "WORLD"); + }); + }); + + describe("scoreCalcs flareSkill", () => { + const impl = DDR_IMPL; + const f = ( + scoreData: Partial>, + chartData: Partial, + expected: number, + _msg?: string, + ) => { + const sd = dmf(TestingDDRSPScore.scoreData, scoreData); + const ch = dmf(TestingDDRSP, { data: chartData as never }) as never; + + expect(impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).flareSkill).toBe(expected); + }; + + it("computes flare skill from grade, lamp, and flare", () => { + f({ grade: "AA", lamp: "CLEAR", optional: { flare: "II", enumIndexes: {} } }, {}, 257); + f({ grade: "E", lamp: "FAILED" }, {}, 0); + }); + }); + + describe("goal formatters", () => { + const impl = DDR_IMPL; + + it("criteria", () => { + expect(impl.goalCriteriaFormatters.score(123456)).toBe("Get a score of 123,456 on"); + expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on"); + }); + + it("progress", () => { + const f = ( + k: keyof typeof impl.goalProgressFormatters, + modifant: Partial>, + goalValue: integer, + expected: string, + ) => { + expect( + impl.goalProgressFormatters[k]( + mkFakePBDDRSP({ + scoreData: modifant, + } as never), + goalValue, + ), + ).toBe(expected); + }; + + f("score", { score: 123_456 }, 1_000_000, "123,456"); + f("score", { score: 0 }, 1_000_000, "0"); + + f("grade", { score: 955000, grade: "AA+" }, DDR_GRADES.AAA, "AAA-35,000"); + f("grade", { score: 995000, grade: "AAA" }, DDR_GRADES.AAA, "AAA+5,000"); + + f("lamp", { lamp: "CLEAR", optional: {} as never }, DDR_LAMPS.FULL_COMBO, "CLEAR"); + + f( + "lamp", + { lamp: "FULL COMBO", optional: {} as never }, + DDR_LAMPS.FULL_COMBO, + "FULL COMBO", + ); + }); + + it("outOf", () => { + expect(impl.goalOutOfFormatters.score(123456)).toBe("123,456"); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/ddr.ts b/typescript/server/src/game-implementations/games/ddr.ts index 1ad65a2e4..50020ce65 100644 --- a/typescript/server/src/game-implementations/games/ddr.ts +++ b/typescript/server/src/game-implementations/games/ddr.ts @@ -1,38 +1,40 @@ import type { + GameImplementation, GPTGoalFormatters, GPTGoalProgressFormatters, GPTProfileCalcs, - GPTServerImplementation, ScoreValidator, } from "#game-implementations/types"; import DB from "#services/pg/db"; +import { IsNullish } from "#utils/misc"; import { sql } from "kysely"; import { DDRFlare } from "rg-stats"; import { + type ChartDocument, DDR_GBOUNDARIES, FmtNum, - GamePTToV3, + GetGameConfig, GetGrade, - GetSpecificGPTConfig, - type MONGO_ChartDocument, - type MONGO_ScoreDocument, + type ScoreDocument, } from "tachi-common"; -import { IsNullish } from "../../utils/misc"; import { CreatePBMergeFor } from "../utils/pb-merge"; import { SessionAvgBest10For } from "../utils/session-calc"; import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; -const DDR_GOAL_FMT: GPTGoalFormatters<"ddr:DP" | "ddr:SP"> = { +/** `ddr:SP` / `ddr:DP` as v3 games. */ +type DDRGames = "ddr-dp" | "ddr-sp"; + +const DDR_GOAL_FMT: GPTGoalFormatters = { score: GoalFmtScore, }; -const DDR_GOAL_OO_FMT: GPTGoalFormatters<"ddr:DP" | "ddr:SP"> = { +const DDR_GOAL_OO_FMT: GPTGoalFormatters = { score: GoalOutOfFmtScore, }; -const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters<"ddr:DP" | "ddr:SP"> = { +const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters = { score: (pb) => FmtNum(pb.scoreData.score), lamp: (pb) => pb.scoreData.lamp, grade: (pb, gradeIndex) => @@ -45,11 +47,8 @@ const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters<"ddr:DP" | "ddr:SP"> = { ), }; -export const DDR_SCORE_VALIDATORS: Array> = [ - ( - s: MONGO_ScoreDocument<"ddr:DP" | "ddr:SP">, - chart?: MONGO_ChartDocument<"ddr:DP" | "ddr:SP">, - ) => { +export const DDR_SCORE_VALIDATORS: Array> = [ + (s: ScoreDocument, chart?: ChartDocument) => { if (s.scoreData.lamp === "FAILED" || !chart || IsNullish(chart.data.stepCount)) { return; } @@ -147,9 +146,7 @@ export const DDR_SCORE_VALIDATORS: Array> = }, ]; -const DDR_PROFILE_CALCS: GPTProfileCalcs<"ddr:DP" | "ddr:SP"> = async (game, playtype, userID) => { - const v3Game = GamePTToV3(game, playtype); - +const DDR_PROFILE_CALCS: GPTProfileCalcs = async (game, userID) => { const rows = await DB.selectFrom("pb") .innerJoin("chart", "chart.id", "pb.chart_id") .innerJoin("song", "song.id", "chart.song_id") @@ -160,7 +157,7 @@ const DDR_PROFILE_CALCS: GPTProfileCalcs<"ddr:DP" | "ddr:SP"> = async (game, pla sql`song.data::jsonb->>'flareCategory'`.as("flare_category"), ]) .where("pb.user_id", "=", userID) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where("chart.is_primary", "=", true) .where((eb) => eb(sql`jsonb_typeof(pb.calculated_data::jsonb -> 'flareSkill')`, "=", sql`'number'`), @@ -284,7 +281,7 @@ function DeriveFlareClass(flarePoints: number) { } } -export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = { +export const DDR_IMPL: GameImplementation = { chartSpecificValidators: {}, classDerivers: (ratings) => { const flarePoints = ratings.flareSkill; @@ -300,11 +297,11 @@ export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = { return { flareSkill: 0 }; } - const flareLevel = scoreData.optional.flare - ? GetSpecificGPTConfig("ddr:SP").optionalMetrics.flare.values.indexOf( - scoreData.optional.flare, - ) - : 0; + const flareConf = GetGameConfig(chart.game).optionalMetrics.flare; + const flareLevel = + scoreData.optional.flare && flareConf.type === "ENUM" + ? flareConf.values.indexOf(scoreData.optional.flare) + : 0; return { flareSkill: DDRFlare.calculate(chart.levelNum, flareLevel) }; }, @@ -351,5 +348,5 @@ export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = { }), profileCalcs: DDR_PROFILE_CALCS, scoreValidators: DDR_SCORE_VALIDATORS, - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], }; diff --git a/typescript/server/src/game-implementations/games/gitadora.oldtest.ts b/typescript/server/src/game-implementations/games/gitadora.oldtest.ts deleted file mode 100644 index 089638dbc..000000000 --- a/typescript/server/src/game-implementations/games/gitadora.oldtest.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingGitadoraChart } from "#test-utils/test-data"; -import { - GetGPTString, - GITADORA_GRADES, - GITADORA_LAMPS, - type GPTStrings, - type PBScoreDocument, - type ProvidedMetrics, - type ScoreData, - type ScoreDocument, -} from "tachi-common"; -import t from "tap"; - -import { GITADORA_DORA_IMPL, GITADORA_GITA_IMPL } from "./gitadora"; - -const baseMetrics: ProvidedMetrics[GPTStrings["gitadora"]] = { - lamp: "CLEAR", - percent: 45, -}; - -const scoreData: ScoreData = { - lamp: "CLEAR", - percent: 96, - grade: "SS", - enumIndexes: { - grade: GITADORA_GRADES.SS, - lamp: GITADORA_LAMPS.CLEAR, - }, - judgements: {}, - optional: { enumIndexes: {} }, -}; - -for (const [playtype, impl] of [ - ["Dora", GITADORA_DORA_IMPL], - ["Gita", GITADORA_GITA_IMPL], -] as const) { - const mockScore = mkMockScore("gitadora", playtype, TestingGitadoraChart, scoreData); - const mockPB = mkMockPB("gitadora", playtype, TestingGitadoraChart, scoreData); - - t.test(`Gitadora ${playtype} Implementation`, (t) => { - t.test("Grade Deriver", (t) => { - const f = (percent: number, expected: any) => - t.equal( - impl.scoreDeriver( - dmf(baseMetrics, { percent }) as any, - TestingGitadoraChart as any, - ).grade, - expected, - `A percent of ${percent}% should result in grade=${expected}.`, - ); - - f(0, "C"); - f(62, "C"); - f(63, "B"); - f(73, "A"); - f(80, "S"); - f(94, "S"); - f(95, "SS"); - f(100, "MAX"); - - // almosts - f(62.99, "C"); - f(72.99, "B"); - f(79.99, "A"); - f(94.99, "S"); - f(99.99, "SS"); - - t.end(); - }); - - t.test("Skill Calc", (t) => { - const f = ( - modifant: Partial>, - expected: any, - msg: string, - ) => { - const sd = dmf(scoreData, modifant); - const ch = TestingGitadoraChart as any; - - return t.equal( - impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).skill, - expected, - msg, - ); - }; - - f({ percent: 76.57 }, 72.74, "Basic Skill Check"); - - t.end(); - }); - - t.todo("Profile Calc"); - - t.test("Colour Class", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - impl.classDerivers({ naiveSkill: v }).colour, - expected, - `A skill level of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(1, "WHITE"); - f(999, "WHITE"); - f(1000, "ORANGE"); - f(2000, "YELLOW"); - f(3000, "GREEN"); - f(4000, "BLUE"); - f(5000, "PURPLE"); - f(6000, "RED"); - - f(1500, "ORANGE_GRD"); - f(2500, "YELLOW_GRD"); - f(3500, "GREEN_GRD"); - f(4500, "BLUE_GRD"); - f(5500, "PURPLE_GRD"); - f(6500, "RED_GRD"); - - f(7000, "BRONZE"); - f(7500, "SILVER"); - f(8000, "GOLD"); - f(8500, "RAINBOW"); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal(impl.goalCriteriaFormatters.percent(28.194), "Get 28.19% on"); - t.equal(impl.goalCriteriaFormatters.percent(28.195), "Get 28.20% on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof impl.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - impl.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("percent", { percent: 12.32 }, 30, "12.32%"); - - f("grade", { grade: "SS", percent: 98.19 }, GITADORA_GRADES.MAX, "MAX-1.81%"); - - f("lamp", { lamp: "CLEAR" }, GITADORA_LAMPS.CLEAR, "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(impl.goalOutOfFormatters.percent(28.194), "28.19%"); - t.equal(impl.goalOutOfFormatters.percent(28.194), "28.19%"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - percent: 0, - lamp: "FULL COMBO", - enumIndexes: { lamp: GITADORA_LAMPS.FULL_COMBO }, - }, - }), - ); - - t.hasStrict( - await CreatePBDoc( - GetGPTString("gitadora", playtype), - 1, - TestingGitadoraChart as any, - log, - ), - { - composedFrom: [ - { name: "Best Percent" }, - { name: "Best Lamp", scoreID: "bestLamp" }, - ], - scoreData: { - percent: mockScore.scoreData.percent, - lamp: "FULL COMBO", - enumIndexes: { lamp: GITADORA_LAMPS.FULL_COMBO }, - }, - }, - ); - - t.end(); - }); - - t.end(); - }); - - t.end(); - }); -} diff --git a/typescript/server/src/game-implementations/games/gitadora.test.ts b/typescript/server/src/game-implementations/games/gitadora.test.ts new file mode 100644 index 000000000..f9b4b674f --- /dev/null +++ b/typescript/server/src/game-implementations/games/gitadora.test.ts @@ -0,0 +1,251 @@ +import { GITADORA_DORA_IMPL, GITADORA_GITA_IMPL } from "#game-implementations/games/gitadora"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingGitadoraChart, TestingGitadoraSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + GITADORA_GRADES, + GITADORA_LAMPS, + type MongoProvidedMetrics, + type ScoreData, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const baseMetrics: MongoProvidedMetrics["gitadora-dora" | "gitadora-gita"] = { + lamp: "CLEAR", + percent: 45, +}; + +const scoreData: ScoreData<"gitadora-dora" | "gitadora-gita"> = { + lamp: "CLEAR", + percent: 96, + grade: "SS", + enumIndexes: { + grade: GITADORA_GRADES.SS, + lamp: GITADORA_LAMPS.CLEAR, + }, + judgements: {}, + optional: { enumIndexes: {} }, +}; + +async function seedGitadoraChart(game: "gitadora-dora" | "gitadora-gita") { + const chart = dmf(TestingGitadoraChart, { game } as never); + + await DB.insertInto("song") + .values({ + id: TestingGitadoraSong.id, + legacy_id: 1, + game_group: "gitadora", + title: TestingGitadoraSong.title, + artist: TestingGitadoraSong.artist, + search_terms: TestingGitadoraSong.searchTerms, + alt_titles: TestingGitadoraSong.altTitles, + data: TestingGitadoraSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game, + song_id: TestingGitadoraSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); + + return chart; +} + +async function insertGitadoraScore(opts: { + chartId: string; + game: "gitadora-dora" | "gitadora-gita"; + scoreId: string; + sd: ScoreData<"gitadora-dora" | "gitadora-gita">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg(opts.game, opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: opts.chartId, + game: opts.game, + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe.each([ + ["gitadora-dora", GITADORA_DORA_IMPL] as const, + ["gitadora-gita", GITADORA_GITA_IMPL] as const, +])("%s", (game, impl) => { + const chart = dmf(TestingGitadoraChart, { game } as never); + + describe("scoreDeriver (grade)", () => { + const g = (percent: number, expected: string) => + expect( + impl.scoreDeriver(dmf(baseMetrics, { percent }) as never, chart as never).grade, + ).toBe(expected); + + it("maps percent to grade", () => { + g(0, "C"); + g(62, "C"); + g(63, "B"); + g(73, "A"); + g(80, "S"); + g(94, "S"); + g(95, "SS"); + g(100, "MAX"); + g(62.99, "C"); + g(72.99, "B"); + g(79.99, "A"); + g(94.99, "S"); + g(99.99, "SS"); + }); + }); + + it("scoreCalcs skill", () => { + const sd = dmf(scoreData, { percent: 76.57 }); + expect( + impl.scoreCalcs(sd, impl.scoreDeriver(sd, chart as never), chart as never).skill, + ).toBe(72.74); + }); + + describe("classDerivers (naiveSkill → colour)", () => { + const c = (v: number | null, expected: ReturnType["colour"]) => + expect(impl.classDerivers({ naiveSkill: v }).colour).toBe(expected); + + it("maps skill tiers", () => { + c(null, null); + c(1, "WHITE"); + c(999, "WHITE"); + c(1000, "ORANGE"); + c(2000, "YELLOW"); + c(3000, "GREEN"); + c(4000, "BLUE"); + c(5000, "PURPLE"); + c(6000, "RED"); + c(1500, "ORANGE_GRD"); + c(2500, "YELLOW_GRD"); + c(3500, "GREEN_GRD"); + c(4500, "BLUE_GRD"); + c(5500, "PURPLE_GRD"); + c(6500, "RED_GRD"); + c(7000, "BRONZE"); + c(7500, "SILVER"); + c(8000, "GOLD"); + c(8500, "RAINBOW"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB(game, chart, scoreData); + + it("criteria", () => { + expect(impl.goalCriteriaFormatters.percent(28.194)).toBe("Get 28.19% on"); + expect(impl.goalCriteriaFormatters.percent(28.195)).toBe("Get 28.20% on"); + }); + + it("progress", () => { + const f = ( + k: keyof typeof impl.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + impl.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("percent", { percent: 12.32 }, 30, "12.32%"); + f("grade", { grade: "SS", percent: 98.19 }, GITADORA_GRADES.MAX, "MAX-1.81%"); + f("lamp", { lamp: "CLEAR" }, GITADORA_LAMPS.CLEAR, "CLEAR"); + }); + + it("outOf", () => { + expect(impl.goalOutOfFormatters.percent(28.194)).toBe("28.19%"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + let chartRow: typeof chart; + + beforeEach(async () => { + chartRow = await seedGitadoraChart(game); + }); + + it("joins best lamp", async () => { + const { id: userId } = await seedUser({ username: `gitadora_pb_${game}` }); + const main = mkMockScore(game, chartRow, scoreData); + + const lampSd: ScoreData = { + ...scoreData, + percent: 0, + lamp: "FULL COMBO", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: GITADORA_LAMPS.FULL_COMBO, + }, + }; + + await insertGitadoraScore({ + userId, + game, + chartId: chartRow.chartID, + scoreId: main.scoreID, + sd: scoreData, + timeMs: 1000, + }); + + await insertGitadoraScore({ + userId, + game, + chartId: chartRow.chartID, + scoreId: "bestLamp", + sd: lampSd, + timeMs: 2000, + }); + + const pb = await CreatePBDoc(game, userId, chartRow, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Percent" }, + { name: "Best Lamp", scoreID: "bestLamp" }, + ], + scoreData: { + percent: scoreData.percent, + lamp: "FULL COMBO", + enumIndexes: { lamp: GITADORA_LAMPS.FULL_COMBO }, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/gitadora.ts b/typescript/server/src/game-implementations/games/gitadora.ts index f569f8055..3ed5da2d5 100644 --- a/typescript/server/src/game-implementations/games/gitadora.ts +++ b/typescript/server/src/game-implementations/games/gitadora.ts @@ -1,5 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; - +import { type GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "#game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "#game-implementations/utils/session-calc"; @@ -9,7 +8,7 @@ import { GetGrade, GITADORA_GBOUNDARIES } from "tachi-common"; import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common"; -const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita"> = { +const GITADORA_IMPL: GameImplementation<"gitadora-dora" | "gitadora-gita"> = { chartSpecificValidators: {}, scoreDeriver: (scoreData, _chart) => ({ grade: GetGrade(GITADORA_GBOUNDARIES, scoreData.percent), @@ -28,8 +27,8 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita"> sessionCalcs: (arr) => ({ skill: SessionAvgBest10For("skill")(arr), }), - profileCalcs: async (game, playtype, userID) => ({ - naiveSkill: await ProfileSumBestN("skill", 50)(game, playtype, userID), + profileCalcs: async (game, userID) => ({ + naiveSkill: await ProfileSumBestN("skill", 50)(game, userID), }), classDerivers: (ratings) => { const sk = ratings.naiveSkill; @@ -104,9 +103,9 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita"> ], defaultMergeRefName: "Best Percent", scoreValidators: [], - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], }; -export const GITADORA_GITA_IMPL: GPTServerImplementation<"gitadora:Gita"> = GITADORA_IMPL; +export const GITADORA_GITA_IMPL: GameImplementation<"gitadora-gita"> = GITADORA_IMPL; -export const GITADORA_DORA_IMPL: GPTServerImplementation<"gitadora:Dora"> = GITADORA_IMPL; +export const GITADORA_DORA_IMPL: GameImplementation<"gitadora-dora"> = GITADORA_IMPL; diff --git a/typescript/server/src/game-implementations/games/iidx.impl.extra.test.ts b/typescript/server/src/game-implementations/games/iidx.impl.extra.test.ts new file mode 100644 index 000000000..bfa2cb408 --- /dev/null +++ b/typescript/server/src/game-implementations/games/iidx.impl.extra.test.ts @@ -0,0 +1,172 @@ +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { mkFakeScoreIIDXSP } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { IIDX_LAMPS, type ScoreData } from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const chart = Testing511SPA; + +async function seedIidx511Chart() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertIidxScore(opts: { + scoreId: string; + sd: ScoreData<"iidx-sp">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("IIDX_IMPL CreatePBDoc integration", () => { + beforeEach(seedIidx511Chart); + + for (const game of ["iidx-sp", "iidx-dp"] as const) { + describe(`CreatePBDoc (${game})`, () => { + it("joins best lamp", async () => { + const { id: userId } = await seedUser({ username: `iidx_pb_lamp_${game}` }); + const main = mkFakeScoreIIDXSP({ userID: userId }); + + const lampSd: ScoreData<"iidx-sp"> = { + ...main.scoreData, + score: 0, + lamp: "FULL COMBO", + enumIndexes: { + ...main.scoreData.enumIndexes, + lamp: IIDX_LAMPS.FULL_COMBO, + }, + }; + + await insertIidxScore({ + userId, + scoreId: main.scoreID, + sd: main.scoreData, + timeMs: 1000, + }); + await insertIidxScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 }); + + const pb = await CreatePBDoc(game, userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Score" }, + { name: "Best Lamp", scoreID: "bestLamp" }, + ], + scoreData: { + score: 786, + lamp: "FULL COMBO", + enumIndexes: { + lamp: IIDX_LAMPS.FULL_COMBO, + }, + }, + }); + }); + + it("joins lowest BP", async () => { + const { id: userId } = await seedUser({ username: `iidx_pb_bp_${game}` }); + + const a = mkFakeScoreIIDXSP({ + userID: userId, + scoreID: "whateverBP", + scoreData: { optional: { bp: 100 } }, + }); + const b = mkFakeScoreIIDXSP({ + userID: userId, + scoreID: "lowestBP", + scoreData: { optional: { bp: 1 } }, + }); + const c = mkFakeScoreIIDXSP({ + userID: userId, + scoreID: "nullBP", + scoreData: { optional: { bp: null } }, + }); + + await insertIidxScore({ + userId, + scoreId: a.scoreID, + sd: a.scoreData, + timeMs: 1000, + }); + await insertIidxScore({ + userId, + scoreId: b.scoreID, + sd: b.scoreData, + timeMs: 2000, + }); + await insertIidxScore({ + userId, + scoreId: c.scoreID, + sd: c.scoreData, + timeMs: 3000, + }); + + const pb = await CreatePBDoc(game, userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Score" }, + { name: "Lowest BP", scoreID: "lowestBP" }, + ], + scoreData: { + score: 786, + optional: { bp: 1 }, + }, + }); + }); + }); + } +}); diff --git a/typescript/server/src/game-implementations/games/iidx.impl.test.ts b/typescript/server/src/game-implementations/games/iidx.impl.test.ts new file mode 100644 index 000000000..c0f3b5c3c --- /dev/null +++ b/typescript/server/src/game-implementations/games/iidx.impl.test.ts @@ -0,0 +1,322 @@ +import { IIDX_DP_IMPL, IIDX_SP_IMPL } from "#game-implementations/games/iidx"; +import { dmf, mkFakePBIIDXSP } from "#test-utils/misc"; +import { Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; +import { + type ChartDocumentData, + IIDX_GRADES, + IIDX_LAMPS, + type integer, + type MongoProvidedMetrics, + type ScoreData, +} from "tachi-common"; +import { describe, expect, it } from "vitest"; + +const baseMetrics: MongoProvidedMetrics["iidx-dp" | "iidx-sp"] = { + lamp: "HARD CLEAR", + score: 1000, +}; + +const max = Testing511SPA.data.notecount * 2; + +function percentToScore(percent: number) { + return (percent / 100) * max; +} + +function scoreToPercent(score: number) { + return (100 * score) / max; +} + +describe("IIDX_IMPL (unit)", () => { + describe("scoreDeriver", () => { + describe.each([IIDX_SP_IMPL, IIDX_DP_IMPL] as const)("SP/DP impl", (impl) => { + it("computes percent from EX score", () => { + const f = ( + modifant: Partial, + expected: number, + _msg?: string, + ) => { + expect( + impl.scoreDeriver( + dmf(baseMetrics, modifant) as never, + Testing511SPA as never, + ).percent, + ).toBe(expected); + }; + + f( + { score: 1000 }, + 63.61323155216285, + "An EXScore of 1000 should result in a percent of 1000 / (notecount*2)", + ); + f({ score: 0 }, 0, "An EXScore of 0 should count as 0%."); + f( + { score: Testing511SPA.data.notecount * 2 }, + 100, + "A perfect score should be worth 100%", + ); + }); + + it("maps percent to letter grade", () => { + const f = (percent: number, expected: string) => { + expect( + impl.scoreDeriver( + dmf(baseMetrics, { score: percentToScore(percent) }) as never, + Testing511SPA as never, + ).grade, + ).toBe(expected); + }; + + f(0, "F"); + f(22.23, "E"); + f(33.34, "D"); + f(44.45, "C"); + f(55.56, "B"); + f(66.67, "A"); + f(77.78, "AA"); + f(88.89, "AAA"); + f(94.45, "MAX-"); + f(100, "MAX"); + + f(0, "F"); + f(11.11, "F"); + f(22.22, "F"); + f(33.33, "E"); + f(44.44, "D"); + f(55.55, "C"); + f(66.66, "B"); + f(77.77, "A"); + f(88.88, "AA"); + f(94.44, "AAA"); + f(99.99, "MAX-"); + }); + }); + }); + + describe("chartSpecificValidators.score", () => { + it.each([ + ["SP", IIDX_SP_IMPL], + ["DP", IIDX_DP_IMPL], + ] as const)("accepts valid EX scores (%s)", (_playtype, impl) => { + expect(impl.chartSpecificValidators.score(1000, Testing511SPA as never)).toBe(true); + expect(impl.chartSpecificValidators.score(0, Testing511SPA as never)).toBe(true); + expect( + impl.chartSpecificValidators.score( + Testing511SPA.data.notecount * 2, + Testing511SPA as never, + ), + ).toBe(true); + }); + + it.each([ + ["SP", IIDX_SP_IMPL], + ["DP", IIDX_DP_IMPL], + ] as const)("rejects invalid EX scores (%s)", (_playtype, impl) => { + expect(impl.chartSpecificValidators.score(-1, Testing511SPA as never)).toBe( + "EX Score cannot be negative.", + ); + expect( + impl.chartSpecificValidators.score( + Testing511SPA.data.notecount * 2 + 1, + Testing511SPA as never, + ), + ).toBe( + `EX Score cannot be greater than ${Testing511SPA.data.notecount * 2} for this chart.`, + ); + }); + }); + + describe("scoreCalcs BPI", () => { + it.each([IIDX_SP_IMPL, IIDX_DP_IMPL] as const)("BPI cases", (impl) => { + const f = ( + scoreData: Partial>, + chartData: Partial, + expected: number, + _msg?: string, + ) => { + const sd = dmf(TestingIIDXSPScore.scoreData, scoreData); + const ch = dmf(Testing511SPA, { data: chartData as never }); + + expect( + impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch as never), ch as never).BPI, + ).toBe(expected); + }; + + f( + { score: 123 }, + { kaidenAverage: 123, worldRecord: 200 }, + 0, + "score == kaidenAverage", + ); + f({ score: 200 }, { kaidenAverage: 123, worldRecord: 200 }, 100, "score == WR"); + f( + { score: 180 }, + { kaidenAverage: 123, worldRecord: 200 }, + 69.64067840359507, + "general calc", + ); + f({ score: 100 }, { kaidenAverage: 123, worldRecord: 200 }, -15, "below kavg"); + }); + }); + + describe("scoreCalcs ktLampRating", () => { + it("IIDX SP", () => { + const f = ( + scoreData: Partial>, + chartData: Partial, + expected: number, + _msg?: string, + ) => { + const sd = dmf(TestingIIDXSPScore.scoreData, scoreData); + const ch = dmf(Testing511SPA, { data: chartData as never }); + + expect( + IIDX_SP_IMPL.scoreCalcs(sd, IIDX_SP_IMPL.scoreDeriver(sd, ch), ch).ktLampRating, + ).toBe(expected); + }; + + f({ lamp: "FAILED" }, {}, 0); + f({ lamp: "ASSIST CLEAR" }, {}, 0); + f({ lamp: "EASY CLEAR" }, {}, 0); + f({ lamp: "CLEAR" }, {}, Testing511SPA.levelNum); + f({ lamp: "HARD CLEAR" }, {}, Testing511SPA.levelNum); + f({ lamp: "EX HARD CLEAR" }, {}, Testing511SPA.levelNum); + f({ lamp: "FULL COMBO" }, {}, Testing511SPA.levelNum); + + function mkTier(v: number) { + return { value: v, text: "whatever", individualDifference: false }; + } + + f({ lamp: "CLEAR" }, { ncTier: mkTier(15) }, 15); + f({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15), hcTier: mkTier(16) }, 16); + f( + { lamp: "EX HARD CLEAR" }, + { ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) }, + 17, + ); + f({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15) }, 15); + f({ lamp: "EX HARD CLEAR" }, { ncTier: mkTier(15) }, 15); + f({ lamp: "EX HARD CLEAR" }, { ncTier: mkTier(15), hcTier: mkTier(16) }, 16); + }); + + it("IIDX DP", () => { + const f = ( + scoreData: Partial>, + chartData: Partial, + expected: number, + _msg?: string, + ) => { + const sd = dmf(TestingIIDXSPScore.scoreData, scoreData); + const ch = dmf(Testing511SPA, { data: chartData as never }); + + expect( + IIDX_DP_IMPL.scoreCalcs( + sd, + IIDX_DP_IMPL.scoreDeriver(sd, ch as never), + ch as never, + ).ktLampRating, + ).toBe(expected); + }; + + f({ lamp: "FAILED" }, {}, 0); + f({ lamp: "ASSIST CLEAR" }, {}, 0); + f({ lamp: "EASY CLEAR" }, {}, Testing511SPA.levelNum); + f({ lamp: "CLEAR" }, {}, Testing511SPA.levelNum); + f({ lamp: "HARD CLEAR" }, {}, Testing511SPA.levelNum); + f({ lamp: "EX HARD CLEAR" }, {}, Testing511SPA.levelNum); + f({ lamp: "FULL COMBO" }, {}, Testing511SPA.levelNum); + + function mkTier(v: number) { + return { value: v, text: "whatever", individualDifference: false }; + } + + f({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) }, 15); + f({ lamp: "CLEAR" }, { dpTier: mkTier(15) }, 15); + f({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) }, 15); + f({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) }, 15); + }); + }); + + describe("goal formatters", () => { + describe.each([IIDX_SP_IMPL, IIDX_DP_IMPL] as const)("impl", (impl) => { + it("criteria", () => { + expect(impl.goalCriteriaFormatters.percent(94.42123)).toBe("Get 94.42% on"); + expect(impl.goalCriteriaFormatters.percent(94.426)).toBe("Get 94.43% on"); + expect(impl.goalCriteriaFormatters.score(1234)).toBe("Get a score of 1234 on"); + expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on"); + }); + + it("progress", () => { + const f = ( + k: keyof typeof impl.goalProgressFormatters, + modifant: Partial>, + goalValue: integer, + expected: string, + ) => { + expect( + impl.goalProgressFormatters[k]( + mkFakePBIIDXSP({ scoreData: modifant } as never) as never, + goalValue, + ), + ).toBe(expected); + }; + + f("score", { score: 1234 }, 1000, "1234"); + f("score", { score: 0 }, 1000, "0"); + + f("percent", { percent: 92.17472 }, 100, "92.17%"); + f("percent", { percent: 92.17572 }, 100, "92.18%"); + + f( + "grade", + { score: 1333, percent: scoreToPercent(1333), grade: "AA" }, + IIDX_GRADES.AAA, + "AAA-64", + ); + f( + "grade", + { score: 1233, percent: scoreToPercent(1233), grade: "AA" }, + IIDX_GRADES.AAA, + "AAA-164", + ); + f( + "grade", + { score: 1400, percent: scoreToPercent(1400), grade: "AAA" }, + IIDX_GRADES.AAA, + "AAA+3", + ); + + f( + "lamp", + { lamp: "HARD CLEAR", optional: { bp: 2 } as never }, + IIDX_LAMPS.HARD_CLEAR, + "HARD CLEAR (BP: 2)", + ); + + f( + "lamp", + { lamp: "HARD CLEAR", optional: { bp: null } as never }, + IIDX_LAMPS.HARD_CLEAR, + "HARD CLEAR", + ); + f( + "grade", + { score: 1899, percent: (100 * 1899) / 1918, grade: "MAX-" }, + IIDX_GRADES.MAX, + "MAX-19", + ); + + f( + "grade", + { score: 2831, percent: (100 * 2831) / 3546, grade: "AA" }, + IIDX_GRADES.AA, + "AA+73", + ); + }); + + it("outOf", () => { + expect(impl.goalOutOfFormatters.percent(94.42123)).toBe("94.42%"); + expect(impl.goalOutOfFormatters.score(1234)).toBe("1234"); + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/iidx.oldtest.ts b/typescript/server/src/game-implementations/games/iidx.oldtest.ts deleted file mode 100644 index caf2c957c..000000000 --- a/typescript/server/src/game-implementations/games/iidx.oldtest.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkFakePBIIDXSP, mkFakeScoreIIDXSP } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestSnapshot } from "#test-utils/single-process-snapshot"; -import { Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; -import { - type ChartDocumentData, - GetGrade, - IIDX_GRADES, - IIDX_LAMPS, - IIDXLIKE_GBOUNDARIES, - type ProvidedMetrics, - type ScoreData, -} from "tachi-common"; -import t from "tap"; - -import { IIDX_DP_IMPL, IIDX_SP_IMPL } from "./iidx"; - -const baseMetrics: ProvidedMetrics["iidx:DP" | "iidx:SP"] = { - lamp: "HARD CLEAR", - score: 1000, -}; - -const max = Testing511SPA.data.notecount * 2; - -function percentToScore(percent: number) { - return (percent / 100) * max; -} - -function scoreToPercent(score: number) { - return (100 * score) / max; -} - -t.test("IIDX Implementation", (t) => { - t.test("Derivers", (t) => { - for (const impl of [IIDX_SP_IMPL, IIDX_DP_IMPL]) { - t.test("Percent", (t) => { - const f = (modifant: Partial, expected: any, msg: string) => - t.equal( - impl.scoreDeriver(dmf(baseMetrics, modifant) as any, Testing511SPA as any) - .percent, - expected, - msg, - ); - - f( - { score: 1000 }, - 63.61323155216285, - "An EXScore of 1000 should result in a percent of 1000 / (notecount*2)", - ); - - f({ score: 0 }, 0, "An EXScore of 0 should count as 0%."); - - f( - { score: Testing511SPA.data.notecount * 2 }, - 100, - "A perfect score should be worth 100%", - ); - - t.end(); - }); - - t.test("Grade", (t) => { - const f = (percent: number, expected: any) => - t.equal( - impl.scoreDeriver( - dmf(baseMetrics, { score: percentToScore(percent) }) as any, - Testing511SPA as any, - ).grade, - expected, - `A percent of ${percent}% should result in grade=${expected}.`, - ); - - f(0, "F"); - f(22.23, "E"); - f(33.34, "D"); - f(44.45, "C"); - f(55.56, "B"); - f(66.67, "A"); - f(77.78, "AA"); - f(88.89, "AAA"); - f(94.45, "MAX-"); - f(100, "MAX"); - - // almosts - f(0, "F"); - f(11.11, "F"); - f(22.22, "F"); - f(33.33, "E"); - f(44.44, "D"); - f(55.55, "C"); - f(66.66, "B"); - f(77.77, "A"); - f(88.88, "AA"); - f(94.44, "AAA"); - f(99.99, "MAX-"); - - t.end(); - }); - } - - t.end(); - }); - - t.test("Validators", (t) => { - for (const [playtype, impl] of [ - ["SP", IIDX_SP_IMPL], - ["DP", IIDX_DP_IMPL], - ] as const) { - t.equal(impl.chartSpecificValidators.score(1000, Testing511SPA as any), true); - t.equal(impl.chartSpecificValidators.score(0, Testing511SPA as any), true); - t.equal( - impl.chartSpecificValidators.score( - Testing511SPA.data.notecount * 2, - Testing511SPA as any, - ), - true, - ); - - TestSnapshot( - t, - impl.chartSpecificValidators.score(-1, Testing511SPA as any), - `EX Score Validator ${playtype}: negative`, - ); - TestSnapshot( - t, - impl.chartSpecificValidators.score( - Testing511SPA.data.notecount * 2 + 1, - Testing511SPA as any, - ), - `EX Score Validator ${playtype}: excessive`, - ); - } - - t.end(); - }); - - t.test("CalculatedData", (t) => { - for (const impl of [IIDX_SP_IMPL, IIDX_DP_IMPL]) { - t.test("BPI", (t) => { - const f = ( - scoreData: Partial>, - chartData: Partial, - expected: any, - msg: string, - ) => { - const sd = dmf(TestingIIDXSPScore.scoreData, scoreData); - const ch = dmf(Testing511SPA, { data: chartData as any }) as any; - - return t.equal( - impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).BPI, - expected, - msg, - ); - }; - - f( - { score: 123 }, - { kaidenAverage: 123, worldRecord: 200 }, - 0, - "score == kaidenAverage should be 0", - ); - - f( - { score: 200 }, - { kaidenAverage: 123, worldRecord: 200 }, - 100, - "score == WR should be 100", - ); - f( - { score: 180 }, - { kaidenAverage: 123, worldRecord: 200 }, - 69.64067840359507, - "general calc check", - ); - f( - { score: 100 }, - { kaidenAverage: 123, worldRecord: 200 }, - -15, - "score < kavg should be negative", - ); - - t.end(); - }); - } - - t.test("ktLampRating IIDX SP", (t) => { - const f = ( - scoreData: Partial>, - chartData: Partial, - expected: any, - msg: string, - ) => { - const sd = dmf(TestingIIDXSPScore.scoreData, scoreData); - const ch = dmf(Testing511SPA, { data: chartData as any }); - - return t.equal( - IIDX_SP_IMPL.scoreCalcs(sd, IIDX_SP_IMPL.scoreDeriver(sd, ch), ch).ktLampRating, - expected, - msg, - ); - }; - - f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0"); - f({ lamp: "ASSIST CLEAR" }, {}, 0, "ac should be worth 0"); - f({ lamp: "EASY CLEAR" }, {}, 0, "ec should be worth 0"); - f({ lamp: "CLEAR" }, {}, Testing511SPA.levelNum, "nc should be worth chart level"); - f({ lamp: "HARD CLEAR" }, {}, Testing511SPA.levelNum, "hc should be worth chart level"); - f( - { lamp: "EX HARD CLEAR" }, - {}, - Testing511SPA.levelNum, - "exhc should be worth chart level", - ); - f({ lamp: "FULL COMBO" }, {}, Testing511SPA.levelNum, "fc should be worth chart level"); - - function mkTier(v: number) { - return { value: v, text: "whatever", individualDifference: false }; - } - - f({ lamp: "CLEAR" }, { ncTier: mkTier(15) }, 15, "nc should be worth ncTier"); - f( - { lamp: "HARD CLEAR" }, - { ncTier: mkTier(15), hcTier: mkTier(16) }, - 16, - "hc should be worth hcTier", - ); - f( - { lamp: "EX HARD CLEAR" }, - { ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) }, - 17, - "exhc should be worth exhcTier", - ); - - f( - { lamp: "HARD CLEAR" }, - { ncTier: mkTier(15) }, - 15, - "hc should be worth ncTier if no hcTier defined", - ); - - f( - { lamp: "EX HARD CLEAR" }, - { ncTier: mkTier(15) }, - 15, - "exhc should be worth ncTier if no exhcTier/hcTier defined", - ); - f( - { lamp: "EX HARD CLEAR" }, - { ncTier: mkTier(15), hcTier: mkTier(16) }, - 16, - "exhc should be worth hcTier if no exhcTier defined", - ); - - t.end(); - }); - - t.test("ktLampRating IIDX SP", (t) => { - const f = ( - scoreData: Partial>, - chartData: Partial, - expected: any, - msg: string, - ) => { - const sd = dmf(TestingIIDXSPScore.scoreData, scoreData); - const ch = dmf(Testing511SPA as any, { data: chartData as any }); - - return t.equal( - IIDX_DP_IMPL.scoreCalcs(sd, IIDX_DP_IMPL.scoreDeriver(sd, ch), ch).ktLampRating, - expected, - msg, - ); - }; - - f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0"); - f({ lamp: "ASSIST CLEAR" }, {}, 0, "ac should be worth 0"); - f({ lamp: "EASY CLEAR" }, {}, Testing511SPA.levelNum, "ec should be worth chart level"); - f({ lamp: "CLEAR" }, {}, Testing511SPA.levelNum, "nc should be worth chart level"); - f({ lamp: "HARD CLEAR" }, {}, Testing511SPA.levelNum, "hc should be worth chart level"); - f( - { lamp: "EX HARD CLEAR" }, - {}, - Testing511SPA.levelNum, - "exhc should be worth chart level", - ); - f({ lamp: "FULL COMBO" }, {}, Testing511SPA.levelNum, "fc should be worth chart level"); - - function mkTier(v: number) { - return { value: v, text: "whatever", individualDifference: false }; - } - - f({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) }, 15, "ec should be worth dpTier"); - f({ lamp: "CLEAR" }, { dpTier: mkTier(15) }, 15, "nc should be worth dpTier"); - f({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) }, 15, "hc should be worth dpTier"); - f({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) }, 15, "exhc should be worth dpTier"); - - t.end(); - }); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - for (const impl of [IIDX_SP_IMPL, IIDX_DP_IMPL]) { - t.test("Criteria", (t) => { - t.equal(impl.goalCriteriaFormatters.percent(94.42123), "Get 94.42% on"); - t.equal(impl.goalCriteriaFormatters.percent(94.426), "Get 94.43% on"); - - t.equal(impl.goalCriteriaFormatters.score(1234), "Get a score of 1234 on"); - t.equal(impl.goalCriteriaFormatters.score(0), "Get a score of 0 on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof impl.goalProgressFormatters, - modifant: Partial, - goalValue: any, - expected: any, - ) => - t.equal( - impl.goalProgressFormatters[k]( - mkFakePBIIDXSP({ - // @ts-expect-error lol deepmerge types - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("score", { score: 1234 }, 1000, "1234"); - f("score", { score: 0 }, 1000, "0"); - - f("percent", { percent: 92.17472 }, 100, "92.17%"); - f("percent", { percent: 92.17572 }, 100, "92.18%"); - - f( - "grade", - { score: 1333, percent: scoreToPercent(1333), grade: "AA" }, - IIDX_GRADES.AAA, - "AAA-64", - ); - f( - "grade", - { score: 1233, percent: scoreToPercent(1233), grade: "AA" }, - IIDX_GRADES.AAA, - "AAA-164", - ); - f( - "grade", - { score: 1400, percent: scoreToPercent(1400), grade: "AAA" }, - IIDX_GRADES.AAA, - "AAA+3", - ); - - f( - "lamp", - { lamp: "HARD CLEAR", optional: { bp: 2 } as any }, - IIDX_LAMPS.HARD_CLEAR, - "HARD CLEAR (BP: 2)", - ); - - f( - "lamp", - { lamp: "HARD CLEAR", optional: { bp: null } as any }, - IIDX_LAMPS.HARD_CLEAR, - "HARD CLEAR", - ); - f( - "grade", - { score: 1899, percent: (100 * 1899) / 1918, grade: "MAX-" }, - IIDX_GRADES.MAX, - "MAX-19", - ); - - f( - "grade", - { score: 2831, percent: (100 * 2831) / 3546, grade: "AA" }, - IIDX_GRADES.AA, - "AA+73", - ); - - t.end(); - }); - - t.test("OutOf", (t) => { - t.equal(impl.goalOutOfFormatters.percent(94.42123), "94.42%"); - t.equal(impl.goalOutOfFormatters.score(1234), "1234"); - - t.end(); - }); - } - - t.end(); - }); - - t.test("PB Mergers", (t) => { - t.beforeEach(ResetDBState); - - for (const gptStr of ["iidx:SP", "iidx:DP"] as const) { - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert( - mkFakeScoreIIDXSP({ - scoreID: "bestLamp", - scoreData: { - score: 0, - lamp: "FULL COMBO", - enumIndexes: { lamp: IIDX_LAMPS.FULL_COMBO }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc(gptStr, 1, Testing511SPA, log), { - composedFrom: [ - { name: "Best Score" }, - { name: "Best Lamp", scoreID: "bestLamp" }, - ], - scoreData: { - score: 786, - lamp: "FULL COMBO", - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - }, - }, - }); - - t.end(); - }); - - t.test("Should join best BP", async (t) => { - await MONGODB_KILL.scores.insert([ - mkFakeScoreIIDXSP({ - scoreID: "whateverBP", - scoreData: { optional: { bp: 100 } }, - }), - mkFakeScoreIIDXSP({ - scoreID: "lowestBP", - scoreData: { optional: { bp: 1 } }, - }), - mkFakeScoreIIDXSP({ - scoreID: "nullBP", - scoreData: { optional: { bp: null } }, - }), - ]); - - t.hasStrict(await CreatePBDoc(gptStr, 1, Testing511SPA, log), { - composedFrom: [ - { name: "Best Score" }, - { name: "Lowest BP", scoreID: "lowestBP" }, - ], - scoreData: { - score: 786, - optional: { bp: 1 }, - }, - }); - - t.end(); - }); - } - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/iidx.ts b/typescript/server/src/game-implementations/games/iidx.ts index c9a811d00..9c9d872c6 100644 --- a/typescript/server/src/game-implementations/games/iidx.ts +++ b/typescript/server/src/game-implementations/games/iidx.ts @@ -1,19 +1,17 @@ import type { + GameImplementation, GPTGoalFormatters, GPTGoalProgressFormatters, GPTProfileCalcs, - GPTServerImplementation, GPTSessionCalcs, PBMergeFunction, - ScoreValidator, } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "#game-implementations/utils/session-calc"; -import { IsNullish } from "#utils/misc"; import { PoyashiBPI } from "rg-stats"; -import { type GPTStrings, IIDXLIKE_GBOUNDARIES } from "tachi-common"; +import { IIDXLIKE_GBOUNDARIES } from "tachi-common"; import { GoalFmtPercent, @@ -25,25 +23,24 @@ import { IIDXLIKE_VALIDATORS, } from "./_common"; -const IIDX_SESSION_CALCS: GPTSessionCalcs<"iidx:DP" | "iidx:SP"> = (arr) => ({ +/** `iidx:SP` / `iidx:DP` as v3 games. */ +type IIDXGames = "iidx-dp" | "iidx-sp"; + +const IIDX_SESSION_CALCS: GPTSessionCalcs = (arr) => ({ BPI: SessionAvgBest10For("BPI")(arr), ktLampRating: SessionAvgBest10For("ktLampRating")(arr), }); -const IIDX_PROFILE_CALCS: GPTProfileCalcs<"iidx:DP" | "iidx:SP"> = async ( - game, - playtype, - userID, -) => { +const IIDX_PROFILE_CALCS: GPTProfileCalcs = async (game, userID) => { const [BPI, ktLampRating] = await Promise.all([ - ProfileAvgBestN("BPI", 20, true)(game, playtype, userID), - ProfileAvgBestN("ktLampRating", 20)(game, playtype, userID), + ProfileAvgBestN("BPI", 20, true)(game, userID), + ProfileAvgBestN("ktLampRating", 20)(game, userID), ]); return { BPI, ktLampRating }; }; -const IIDX_MERGERS: Array> = [ +const IIDX_MERGERS: Array> = [ CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, lamp) => { base.scoreData.lamp = lamp.scoreData.lamp; @@ -63,20 +60,20 @@ const IIDX_MERGERS: Array> = [ }), ]; -const IIDX_GOAL_FMT: GPTGoalFormatters<"iidx:DP" | "iidx:SP"> = { +const IIDX_GOAL_FMT: GPTGoalFormatters = { percent: GoalFmtPercent, // don't want commas score: (v) => `Get a score of ${v} on`, }; -const IIDX_GOAL_OO_FMT: GPTGoalFormatters<"iidx:DP" | "iidx:SP"> = { +const IIDX_GOAL_OO_FMT: GPTGoalFormatters = { percent: GoalOutOfFmtPercent, // don't insert commas or anything. score: (m) => m.toString(), }; -const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = { +const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters = { percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`, // 4519 -> "4519". Don't add commas or anything. @@ -108,7 +105,7 @@ const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = { ), }; -export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = { +export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = { scoreDeriver: IIDXLIKE_SCORE_DERIVER, chartSpecificValidators: IIDXLIKE_VALIDATORS, pbRankingValues: IIDXLIKE_PB_RANKING_VALUES, @@ -162,7 +159,7 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = { pbMergeFunctions: IIDX_MERGERS, defaultMergeRefName: "Best Score", scoreValidators: IIDXLIKE_SCORE_VALIDATORS, - derivationRelevantFields: [ + chartDataRelevantFields: [ "levelNum", "data.notecount", "data.kaidenAverage", @@ -174,7 +171,7 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = { ], }; -export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = { +export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = { scoreDeriver: IIDXLIKE_SCORE_DERIVER, chartSpecificValidators: IIDXLIKE_VALIDATORS, pbRankingValues: IIDXLIKE_PB_RANKING_VALUES, @@ -219,7 +216,7 @@ export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = { pbMergeFunctions: IIDX_MERGERS, defaultMergeRefName: "Best Score", scoreValidators: IIDXLIKE_SCORE_VALIDATORS, - derivationRelevantFields: [ + chartDataRelevantFields: [ "levelNum", "data.notecount", "data.kaidenAverage", diff --git a/typescript/server/src/game-implementations/games/itg.ts b/typescript/server/src/game-implementations/games/itg.ts index e609ed516..70fcb196a 100644 --- a/typescript/server/src/game-implementations/games/itg.ts +++ b/typescript/server/src/game-implementations/games/itg.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "#game-implementations/utils/profile-calc"; @@ -9,7 +9,7 @@ import { GetGrade, ITG_GBOUNDARIES } from "tachi-common"; import { GoalFmtPercent, GradeGoalFormatter } from "./_common"; -export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = { +export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = { chartSpecificValidators: {}, scoreDeriver: (scoreData, _chart) => ({ // *important*: don't check survivedPercent === 100 — floating point can @@ -49,10 +49,10 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = { sessionCalcs: (arr) => ({ blockRating: SessionAvgBestNFor("blockRating", 5)(arr), }), - profileCalcs: async (game, playtype, userID) => { + profileCalcs: async (game, userID) => { const [highestBlock, fastest32] = await Promise.all([ - ProfileSumBestN("blockRating", 1, true)(game, playtype, userID), - ProfileSumBestN("fastest32", 1, true)(game, playtype, userID), + ProfileSumBestN("blockRating", 1, true)(game, userID), + ProfileSumBestN("fastest32", 1, true)(game, userID), ]); return { highestBlock, fastest32 }; @@ -125,7 +125,7 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = { // this name sucks, what should we do instead? TODO. defaultMergeRefName: "Best Result", - derivationRelevantFields: ["data.rankedLevel", "data.notesPerMeasure", "data.npsPerMeasure"], + chartDataRelevantFields: ["data.rankedLevel", "data.notesPerMeasure", "data.npsPerMeasure"], scoreValidators: [ (s) => { diff --git a/typescript/server/src/game-implementations/games/jubeat.oldtest.ts b/typescript/server/src/game-implementations/games/jubeat.oldtest.ts deleted file mode 100644 index 7da659496..000000000 --- a/typescript/server/src/game-implementations/games/jubeat.oldtest.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { ProvidedMetrics, ScoreData } from "tachi-common"; - -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestSnapshot } from "#test-utils/single-process-snapshot"; -import { TestingJubeatChart } from "#test-utils/test-data"; -import { JUBEAT_SINGLE_CONF } from "tachi-common/config/game-support/jubeat"; -import t from "tap"; - -import { JUBEAT_IMPL } from "./jubeat"; - -const baseMetrics: ProvidedMetrics["jubeat:Single"] = { - lamp: "CLEAR", - musicRate: 84.21, - score: 970_000, -}; - -const GRADES = JUBEAT_SINGLE_CONF.derivedMetrics.grade.values; -const LAMPS = JUBEAT_SINGLE_CONF.providedMetrics.lamp.values; - -const scoreData: ScoreData<"jubeat:Single"> = { - lamp: "CLEAR", - musicRate: 84.21, - score: 970_000, - grade: "SS", - judgements: {}, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: GRADES.indexOf("SS"), - lamp: LAMPS.indexOf("CLEAR"), - }, -}; - -const mockScore = mkMockScore("jubeat", "Single", TestingJubeatChart, scoreData); -const mockPB = mkMockPB("jubeat", "Single", TestingJubeatChart, scoreData); - -t.test("Jubeat Implementation", (t) => { - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: any) => - t.equal( - JUBEAT_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingJubeatChart) - .grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "E"); - f(500_000, "D"); - f(700_000, "C"); - f(800_000, "B"); - f(850_000, "A"); - f(900_000, "S"); - f(950_000, "SS"); - f(980_000, "SSS"); - f(1_000_000, "EXC"); - - t.end(); - }); - - t.test("Music Rate Validator", (t) => { - t.equal( - JUBEAT_IMPL.chartSpecificValidators.musicRate(80, TestingJubeatChart), - true, - "normal music rate", - ); - t.equal( - JUBEAT_IMPL.chartSpecificValidators.musicRate(0, TestingJubeatChart), - true, - "0 music rate", - ); - t.equal( - JUBEAT_IMPL.chartSpecificValidators.musicRate(100, TestingJubeatChart), - true, - "perfect music rate", - ); - t.equal( - JUBEAT_IMPL.chartSpecificValidators.musicRate( - 120, - dmf(TestingJubeatChart, { - difficulty: "HARD ADV", - }), - ), - true, - "perfect music rate (hard)", - ); - - TestSnapshot( - t, - JUBEAT_IMPL.chartSpecificValidators.musicRate(-1, TestingJubeatChart), - `Jubeat Music Rate Validator: negative`, - ); - TestSnapshot( - t, - JUBEAT_IMPL.chartSpecificValidators.musicRate( - 100.1, - dmf(TestingJubeatChart, { - difficulty: "ADV", - }), - ), - `Jubeat Music Rate Validator: too big`, - ); - TestSnapshot( - t, - JUBEAT_IMPL.chartSpecificValidators.musicRate( - 120.1, - dmf(TestingJubeatChart, { - difficulty: "HARD ADV", - }), - ), - `Jubeat Music Rate Validator: too big (hard)`, - ); - t.end(); - }); - - t.test("Jubility Calc", (t) => { - t.equal( - JUBEAT_IMPL.scoreCalcs( - scoreData, - JUBEAT_IMPL.scoreDeriver(scoreData, TestingJubeatChart), - TestingJubeatChart, - ).jubility, - 63.7, - "Basic Jubility Test", - ); - - t.end(); - }); - - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Colour Deriver", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - JUBEAT_IMPL.classDerivers({ jubility: v }).colour, - expected, - `A jubility of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "BLACK"); - - f(9500, "GOLD"); - f(8500, "ORANGE"); - f(7000, "PINK"); - f(5500, "PURPLE"); - f(4000, "VIOLET"); - f(2500, "BLUE"); - f(1500, "LIGHT_BLUE"); - f(750, "GREEN"); - f(250, "YELLOW_GREEN"); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal( - JUBEAT_IMPL.goalCriteriaFormatters.score(1_008_182), - "Get a score of 1,008,182 on", - ); - t.equal( - JUBEAT_IMPL.goalCriteriaFormatters.musicRate(93.1), - "Get a music rate of 93.1% on", - ); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof JUBEAT_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - JUBEAT_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "S", score: 927_342 }, GRADES.indexOf("S"), "SS-23K"); - f("score", { score: 982_123 }, 1_000_000, "982,123"); - f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); - f("musicRate", { musicRate: 93.2 }, 94.4, "93.2%"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(JUBEAT_IMPL.goalOutOfFormatters.score(983_132), "983,132"); - t.equal(JUBEAT_IMPL.goalOutOfFormatters.musicRate(99.1123), "99.1%"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - musicRate: 0, - score: 0, - lamp: "FULL COMBO", - enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("jubeat:Single", 1, TestingJubeatChart, log), { - composedFrom: [ - { name: "Best Music Rate" }, - { name: "Best Lamp", scoreID: "bestLamp" }, - ], - scoreData: { - musicRate: mockScore.scoreData.musicRate, - score: mockScore.scoreData.score, - lamp: "FULL COMBO", - enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, - }, - }); - - t.end(); - }); - - t.test("Should join best score", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestScore", - scoreData: { - musicRate: 0, - score: 1_000_000, - lamp: "FAILED", - enumIndexes: { lamp: LAMPS.indexOf("FAILED") }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("jubeat:Single", 1, TestingJubeatChart, log), { - composedFrom: [ - { name: "Best Music Rate" }, - { name: "Best Score", scoreID: "bestScore" }, - ], - scoreData: { - musicRate: mockScore.scoreData.musicRate, - lamp: mockScore.scoreData.lamp, - score: 1_000_000, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/jubeat.profile.test.ts b/typescript/server/src/game-implementations/games/jubeat.profile.test.ts index 3a64d7aa7..00ab23635 100644 --- a/typescript/server/src/game-implementations/games/jubeat.profile.test.ts +++ b/typescript/server/src/game-implementations/games/jubeat.profile.test.ts @@ -12,17 +12,12 @@ const JUBEAT_HOT_DISPLAY_VERSION = "ave"; let jubeatSeedCounter = 0; -async function seedJubeatSong(args: { - displayVersion: string; - legacySongId: number; -}): Promise<{ songId: string }> { +async function seedJubeatSong(args: { displayVersion: string; songID: string }): Promise { const n = ++jubeatSeedCounter; - const songId = `jubeat-prof-song-${n}`; - await DB.insertInto("song") .values({ - id: songId, - legacy_id: args.legacySongId, + id: args.songID, + legacy_id: n, game_group: "jubeat", title: "T", artist: "A", @@ -32,13 +27,11 @@ async function seedJubeatSong(args: { fts_document: "", }) .execute(); - - return { songId }; } async function seedJubeatChartPbOnSong( userId: number, - songId: string, + songID: string, args: { calculatedDataOverride?: Record; difficulty: string; @@ -55,7 +48,7 @@ async function seedJubeatChartPbOnSong( id: chartId, legacy_id: chartId, game: "jubeat", - song_id: songId, + song_id: songID, level: "10", level_num: 10, is_primary: isPrimary, @@ -96,43 +89,43 @@ async function seedJubeatSongChartPb( displayVersion: string; isPrimary?: boolean; jubility: number; - legacySongId: number; + songID: string; }, ): Promise { - const { songId } = await seedJubeatSong({ - legacySongId: args.legacySongId, + await seedJubeatSong({ + songID: args.songID, displayVersion: args.displayVersion, }); - await seedJubeatChartPbOnSong(userId, songId, args); + await seedJubeatChartPbOnSong(userId, args.songID, args); } describe("GetBestJubilityOnSongs (Postgres)", () => { it("returns [] when songIDs is empty", async () => { const { id: userId } = await seedUser(); - const rows = await GetBestJubilityOnSongs([], userId, "jubeat", "Single", 30); + const rows = await GetBestJubilityOnSongs([], userId, "jubeat", 30); expect(rows).toEqual([]); }); it("keeps the highest jubility per (song, bucket); HARD BSC and BSC share the BSC bucket", async () => { const { id: userId } = await seedUser(); - const legacySongId = 400_001; + const songID = "jubeat-prof-song-400001"; - const { songId } = await seedJubeatSong({ - legacySongId, + await seedJubeatSong({ + songID, displayVersion: JUBEAT_HOT_DISPLAY_VERSION, }); - await seedJubeatChartPbOnSong(userId, songId, { + await seedJubeatChartPbOnSong(userId, songID, { difficulty: "HARD BSC", jubility: 100, }); - await seedJubeatChartPbOnSong(userId, songId, { + await seedJubeatChartPbOnSong(userId, songID, { difficulty: "BSC", jubility: 250, }); - const rows = await GetBestJubilityOnSongs([legacySongId], userId, "jubeat", "Single", 30); + const rows = await GetBestJubilityOnSongs([songID], userId, "jubeat", 30); expect(rows).toHaveLength(1); expect(rows[0]!.calculatedData.jubility).toBe(250); @@ -140,22 +133,22 @@ describe("GetBestJubilityOnSongs (Postgres)", () => { it("counts separate buckets on the same song as separate rows (BSC + ADV)", async () => { const { id: userId } = await seedUser(); - const legacySongId = 400_002; + const songID = "jubeat-prof-song-400002"; - const { songId } = await seedJubeatSong({ - legacySongId, + await seedJubeatSong({ + songID, displayVersion: JUBEAT_HOT_DISPLAY_VERSION, }); - await seedJubeatChartPbOnSong(userId, songId, { + await seedJubeatChartPbOnSong(userId, songID, { difficulty: "BSC", jubility: 10, }); - await seedJubeatChartPbOnSong(userId, songId, { + await seedJubeatChartPbOnSong(userId, songID, { difficulty: "ADV", jubility: 500, }); - const rows = await GetBestJubilityOnSongs([legacySongId], userId, "jubeat", "Single", 30); + const rows = await GetBestJubilityOnSongs([songID], userId, "jubeat", 30); expect(rows).toHaveLength(2); const jubs = rows.map((r) => r.calculatedData.jubility).sort((a, b) => b! - a!); @@ -165,12 +158,12 @@ describe("GetBestJubilityOnSongs (Postgres)", () => { it("applies a global jubility limit after bucketing", async () => { const { id: userId } = await seedUser(); const limit = 5; - const songIds = Array.from({ length: 7 }, (_, i) => 400_100 + i); + const songIDs = Array.from({ length: 7 }, (_, i) => `jubeat-prof-song-400100${i}`); await Promise.all( - songIds.map((legacySongId, i) => + songIDs.map((songID, i) => seedJubeatSongChartPb(userId, { - legacySongId, + songID, displayVersion: JUBEAT_HOT_DISPLAY_VERSION, difficulty: "EXT", jubility: 100 + i, @@ -178,7 +171,7 @@ describe("GetBestJubilityOnSongs (Postgres)", () => { ), ); - const rows = await GetBestJubilityOnSongs(songIds, userId, "jubeat", "Single", limit); + const rows = await GetBestJubilityOnSongs(songIDs, userId, "jubeat", limit); expect(rows).toHaveLength(limit); const jubs = rows.map((r) => r.calculatedData.jubility!).sort((a, b) => b - a); @@ -187,16 +180,16 @@ describe("GetBestJubilityOnSongs (Postgres)", () => { it("drops charts whose difficulty does not map to a jubility bucket", async () => { const { id: userId } = await seedUser(); - const legacySongId = 400_003; + const songID = "jubeat-prof-song-400003"; await seedJubeatSongChartPb(userId, { - legacySongId, + songID, displayVersion: JUBEAT_HOT_DISPLAY_VERSION, difficulty: "NOT_A_REAL_BUCKET", jubility: 9999, }); - const rows = await GetBestJubilityOnSongs([legacySongId], userId, "jubeat", "Single", 30); + const rows = await GetBestJubilityOnSongs([songID], userId, "jubeat", 30); expect(rows).toEqual([]); }); @@ -216,13 +209,13 @@ describe("GetPBsForJubility (Postgres)", () => { const { id: userId } = await seedUser(); await seedJubeatSongChartPb(userId, { - legacySongId: 400_200, + songID: "jubeat-prof-song-400200", displayVersion: JUBEAT_HOT_DISPLAY_VERSION, difficulty: "EXT", jubility: 50, }); await seedJubeatSongChartPb(userId, { - legacySongId: 400_201, + songID: "jubeat-prof-song-400201", displayVersion: "old", difficulty: "EXT", jubility: 80, @@ -231,25 +224,24 @@ describe("GetPBsForJubility (Postgres)", () => { const { bestHotScores, bestScores } = await GetPBsForJubility(userId); expect(bestHotScores).toHaveLength(1); - expect(bestHotScores[0]!.songID).toBe(400_200); + expect(bestHotScores[0]!.songID).toBe("jubeat-prof-song-400200"); expect(bestHotScores[0]!.calculatedData.jubility).toBe(50); expect(bestScores).toHaveLength(1); - expect(bestScores[0]!.songID).toBe(400_201); + expect(bestScores[0]!.songID).toBe("jubeat-prof-song-400201"); expect(bestScores[0]!.calculatedData.jubility).toBe(80); }); it("treats missing displayVersion as cold (IS DISTINCT FROM ave)", async () => { const { id: userId } = await seedUser(); const n = ++jubeatSeedCounter; - const songId = `jubeat-prof-song-${n}`; + const songID = `jubeat-prof-song-${n}`; const chartId = `jubeat-prof-chart-${n}`; - const legacySongId = 400_202; await DB.insertInto("song") .values({ - id: songId, - legacy_id: legacySongId, + id: songID, + legacy_id: n, game_group: "jubeat", title: "T", artist: "A", @@ -265,7 +257,7 @@ describe("GetPBsForJubility (Postgres)", () => { id: chartId, legacy_id: chartId, game: "jubeat", - song_id: songId, + song_id: songID, level: "10", level_num: 10, is_primary: true, @@ -308,19 +300,19 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => { const { id: userId } = await seedUser(); await seedJubeatSongChartPb(userId, { - legacySongId: 400_300, + songID: "jubeat-prof-song-400300", displayVersion: JUBEAT_HOT_DISPLAY_VERSION, difficulty: "EXT", jubility: 100, }); await seedJubeatSongChartPb(userId, { - legacySongId: 400_301, + songID: "jubeat-prof-song-400301", displayVersion: "cold", difficulty: "EXT", jubility: 200, }); - const result = await JUBEAT_IMPL.profileCalcs("jubeat", "Single", userId); + const result = await JUBEAT_IMPL.profileCalcs("jubeat", userId); expect(result.jubility).toBe(300); expect(result.naiveJubility).toBe(300); @@ -332,7 +324,7 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => { await Promise.all( Array.from({ length: 3 }, (_, i) => seedJubeatSongChartPb(userId, { - legacySongId: 400_400 + i, + songID: `jubeat-prof-song-400400${i}`, displayVersion: JUBEAT_HOT_DISPLAY_VERSION, difficulty: "EXT", jubility: 10 * (i + 1), @@ -340,7 +332,7 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => { ), ); - const result = await JUBEAT_IMPL.profileCalcs("jubeat", "Single", userId); + const result = await JUBEAT_IMPL.profileCalcs("jubeat", userId); expect(result.naiveJubility).toBe(10 + 20 + 30); }); @@ -349,14 +341,14 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => { const { id: userId } = await seedUser(); await seedJubeatSongChartPb(userId, { - legacySongId: 400_500, + songID: "jubeat-prof-song-400500", displayVersion: JUBEAT_HOT_DISPLAY_VERSION, difficulty: "EXT", isPrimary: false, jubility: 5000, }); - const result = await JUBEAT_IMPL.profileCalcs("jubeat", "Single", userId); + const result = await JUBEAT_IMPL.profileCalcs("jubeat", userId); expect(result.naiveJubility).toBeNull(); expect(result.jubility).toBe(5000); @@ -364,12 +356,12 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => { it("matches weighted jubility when hot pool contributes capped rows", async () => { const { id: userId } = await seedUser(); - const songIds = Array.from({ length: 31 }, (_, i) => 400_600 + i); + const songIDs = Array.from({ length: 31 }, (_, i) => `jubeat-prof-song-400600${i}`); await Promise.all( - songIds.map((legacySongId, i) => + songIDs.map((songID, i) => seedJubeatSongChartPb(userId, { - legacySongId, + songID, displayVersion: JUBEAT_HOT_DISPLAY_VERSION, difficulty: "EXT", jubility: 1000 + i, @@ -383,7 +375,7 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => { 0, ); - const result = await JUBEAT_IMPL.profileCalcs("jubeat", "Single", userId); + const result = await JUBEAT_IMPL.profileCalcs("jubeat", userId); expect(bestHotScores).toHaveLength(30); expect(expectedHotSum).toBe( diff --git a/typescript/server/src/game-implementations/games/jubeat.test.ts b/typescript/server/src/game-implementations/games/jubeat.test.ts new file mode 100644 index 000000000..7cdfea872 --- /dev/null +++ b/typescript/server/src/game-implementations/games/jubeat.test.ts @@ -0,0 +1,316 @@ +import { JUBEAT_IMPL } from "#game-implementations/games/jubeat"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingJubeatChart, TestingJubeatSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + GetGameConfig, + type integer, + type MongoProvidedMetrics, + type ScoreData, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +function enumMetricValues(m: { + type: string; + values?: ReadonlyArray; +}): ReadonlyArray { + if (m.type !== "ENUM" || m.values === undefined) { + throw new Error("expected ENUM metric with values"); + } + return m.values; +} + +const chart = TestingJubeatChart; + +const JUBEAT_CONF = GetGameConfig("jubeat"); +const GRADES = enumMetricValues(JUBEAT_CONF.derivedMetrics.grade); +const LAMPS = enumMetricValues(JUBEAT_CONF.providedMetrics.lamp); + +const baseMetrics: MongoProvidedMetrics["jubeat"] = { + lamp: "CLEAR", + musicRate: 84.21, + score: 970_000, +}; + +const scoreData: ScoreData<"jubeat"> = { + lamp: "CLEAR", + musicRate: 84.21, + score: 970_000, + grade: "SS", + judgements: {}, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: GRADES.indexOf("SS"), + lamp: LAMPS.indexOf("CLEAR"), + }, +}; + +async function seedJubeatChart() { + await DB.insertInto("song") + .values({ + id: TestingJubeatSong.id, + legacy_id: 1, + game_group: "jubeat", + title: TestingJubeatSong.title, + artist: TestingJubeatSong.artist, + search_terms: TestingJubeatSong.searchTerms, + alt_titles: TestingJubeatSong.altTitles, + data: TestingJubeatSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "jubeat", + song_id: TestingJubeatSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertJubeatScore(opts: { + scoreId: string; + sd: ScoreData<"jubeat">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("jubeat", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "jubeat", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("JUBEAT_IMPL", () => { + describe("scoreDeriver (grade)", () => { + const g = (score: number, expected: string) => + expect( + JUBEAT_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never) + .grade, + ).toBe(expected); + + it("maps score thresholds to letter grades", () => { + g(0, "E"); + g(500_000, "D"); + g(700_000, "C"); + g(800_000, "B"); + g(850_000, "A"); + g(900_000, "S"); + g(950_000, "SS"); + g(980_000, "SSS"); + g(1_000_000, "EXC"); + }); + }); + + describe("chartSpecificValidators.musicRate", () => { + it("accepts in-range music rate", () => { + expect(JUBEAT_IMPL.chartSpecificValidators.musicRate(80, chart)).toBe(true); + expect(JUBEAT_IMPL.chartSpecificValidators.musicRate(0, chart)).toBe(true); + expect(JUBEAT_IMPL.chartSpecificValidators.musicRate(100, chart)).toBe(true); + expect( + JUBEAT_IMPL.chartSpecificValidators.musicRate( + 120, + dmf(TestingJubeatChart, { difficulty: "HARD ADV" }) as never, + ), + ).toBe(true); + }); + + it("rejects out-of-range music rate", () => { + expect(JUBEAT_IMPL.chartSpecificValidators.musicRate(-1, chart)).toBe( + "Expected a number between 0 and 100.", + ); + expect( + JUBEAT_IMPL.chartSpecificValidators.musicRate( + 100.1, + dmf(TestingJubeatChart, { difficulty: "ADV" }) as never, + ), + ).toBe("Expected a number between 0 and 100."); + expect( + JUBEAT_IMPL.chartSpecificValidators.musicRate( + 120.1, + dmf(TestingJubeatChart, { difficulty: "HARD ADV" }) as never, + ), + ).toBe("Expected a number between 0 and 120."); + }); + }); + + it("scoreCalcs jubility", () => { + expect( + JUBEAT_IMPL.scoreCalcs(scoreData, JUBEAT_IMPL.scoreDeriver(scoreData, chart), chart) + .jubility, + ).toBe(63.7); + }); + + it.skip("Session Calcs", () => { + // not ported from tap todo + }); + + it.skip("Profile Calcs", () => { + // not ported from tap todo + }); + + describe("classDerivers", () => { + const c = ( + v: number | null, + expected: ReturnType["colour"], + ) => expect(JUBEAT_IMPL.classDerivers({ jubility: v }).colour).toBe(expected); + + it("maps jubility to colour", () => { + c(null, null); + c(0, "BLACK"); + c(9500, "GOLD"); + c(8500, "ORANGE"); + c(7000, "PINK"); + c(5500, "PURPLE"); + c(4000, "VIOLET"); + c(2500, "BLUE"); + c(1500, "LIGHT_BLUE"); + c(750, "GREEN"); + c(250, "YELLOW_GREEN"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("jubeat", chart, scoreData); + + it("criteria", () => { + expect(JUBEAT_IMPL.goalCriteriaFormatters.score(1_008_182)).toBe( + "Get a score of 1,008,182 on", + ); + expect(JUBEAT_IMPL.goalCriteriaFormatters.musicRate(93.1)).toBe( + "Get a music rate of 93.1% on", + ); + }); + + it("progress", () => { + const f = ( + k: keyof typeof JUBEAT_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: integer, + expected: string, + ) => + expect( + JUBEAT_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "S", score: 927_342 }, GRADES.indexOf("S"), "SS-23K"); + f("score", { score: 982_123 }, 1_000_000, "982,123"); + f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); + f("musicRate", { musicRate: 93.2 }, 94.4, "93.2%"); + }); + + it("outOf", () => { + expect(JUBEAT_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132"); + expect(JUBEAT_IMPL.goalOutOfFormatters.musicRate(99.1123)).toBe("99.1%"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedJubeatChart); + + it("joins best lamp", async () => { + const { id: userId } = await seedUser({ username: "jubeat_pb_lamp" }); + const main = mkMockScore("jubeat", chart, scoreData); + + const lampSd: ScoreData<"jubeat"> = { + ...scoreData, + musicRate: 0, + score: 0, + lamp: "FULL COMBO", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: LAMPS.indexOf("FULL COMBO"), + }, + }; + + await insertJubeatScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertJubeatScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 }); + + const pb = await CreatePBDoc("jubeat", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Music Rate" }, + { name: "Best Lamp", scoreID: "bestLamp" }, + ], + scoreData: { + musicRate: scoreData.musicRate, + score: scoreData.score, + lamp: "FULL COMBO", + enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, + }, + }); + }); + + it("joins best score", async () => { + const { id: userId } = await seedUser({ username: "jubeat_pb_score" }); + const main = mkMockScore("jubeat", chart, scoreData); + + const bestScoreSd: ScoreData<"jubeat"> = { + ...scoreData, + musicRate: 0, + score: 1_000_000, + lamp: "FAILED", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: LAMPS.indexOf("FAILED"), + }, + }; + + await insertJubeatScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertJubeatScore({ + userId, + scoreId: "bestScore", + sd: bestScoreSd, + timeMs: 2000, + }); + + const pb = await CreatePBDoc("jubeat", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Music Rate" }, + { name: "Best Score", scoreID: "bestScore" }, + ], + scoreData: { + musicRate: scoreData.musicRate, + lamp: scoreData.lamp, + score: 1_000_000, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/jubeat.ts b/typescript/server/src/game-implementations/games/jubeat.ts index 8bccd81dc..8e5727d62 100644 --- a/typescript/server/src/game-implementations/games/jubeat.ts +++ b/typescript/server/src/game-implementations/games/jubeat.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "#game-implementations/utils/profile-calc"; @@ -11,13 +11,11 @@ import { p } from "prudence"; import { Jubility } from "rg-stats"; import { FmtNum, - type GameGroup, - GamePTToV3, GetGrade, type integer, JUBEAT_GBOUNDARIES, - type MONGO_PBScoreDocument, - type Playtype, + type PBScoreDocument, + type V3Game, type Versions, } from "tachi-common"; @@ -28,37 +26,34 @@ import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; * difficulties together, then globally sorts by jubility and takes the top `limit` rows. */ export async function GetBestJubilityOnSongs( - songIDs: Array, + songIDs: Array, userID: integer, - game: GameGroup, - playtype: Playtype, + game: V3Game, limit: integer, -): Promise> { +): Promise> { if (songIDs.length === 0) { return []; } - const v3Game = GamePTToV3(game, playtype); - const rows = await sql` WITH base AS ( SELECT pb.row_id, (pb.calculated_data::jsonb->>'jubility')::double precision AS jubility_val, - song.legacy_id AS song_legacy_id, + song.id AS song_id, chart.difficulty FROM pb INNER JOIN chart ON chart.id = pb.chart_id INNER JOIN song ON song.id = chart.song_id WHERE pb.user_id = ${userID} - AND chart.game = ${v3Game} - AND song.legacy_id in (${sql.join(songIDs)}) + AND chart.game = ${game} + AND song.id in (${sql.join(songIDs)}) ), bucketed AS ( SELECT base.row_id, base.jubility_val, - base.song_legacy_id, + base.song_id, CASE WHEN base.difficulty IN ('HARD BSC', 'BSC') THEN 'BSC' WHEN base.difficulty IN ('HARD ADV', 'ADV') THEN 'ADV' @@ -71,7 +66,7 @@ export async function GetBestJubilityOnSongs( SELECT bucketed.row_id, ROW_NUMBER() OVER ( - PARTITION BY bucketed.song_legacy_id, bucketed.jubility_bucket + PARTITION BY bucketed.song_id, bucketed.jubility_bucket ORDER BY bucketed.jubility_val DESC NULLS LAST ) AS rn FROM bucketed @@ -93,7 +88,7 @@ export async function GetBestJubilityOnSongs( pb.ranking_value_tb5, pb.highlight, pb.time_achieved, - song.legacy_id as song_legacy_id, + song.id as song_id, chart.game as chart_game, chart.is_primary as is_primary, chart_leaderboard.rank AS leaderboard_rank, @@ -110,42 +105,38 @@ export async function GetBestJubilityOnSongs( return Promise.all(rows.rows.map((row) => ToPbScoreDocument(row))); } -const CURRENT_JUBEAT_HOT_VERSION: Versions["jubeat:Single"] = "ave"; +const CURRENT_JUBEAT_HOT_VERSION: Versions["jubeat"] = "ave"; export async function GetPBsForJubility(userID: integer) { const hotSongRows = await DB.selectFrom("song") - .select("legacy_id") + .select("song.id") .where("game_group", "=", "jubeat") .where( sql`(song.data::jsonb->>'displayVersion') = ${sql.lit(CURRENT_JUBEAT_HOT_VERSION)}`, ) .execute(); - const hotSongIDs = hotSongRows.map((r) => r.legacy_id); + const hotSongIDs = hotSongRows.map((r) => r.id); const coldSongRows = await DB.selectFrom("song") - .select("legacy_id") + .select("song.id") .where("game_group", "=", "jubeat") .where( sql`(song.data::jsonb->>'displayVersion') IS DISTINCT FROM ${sql.lit(CURRENT_JUBEAT_HOT_VERSION)}`, ) .execute(); - const coldSongIDs = coldSongRows.map((r) => r.legacy_id); + const coldSongIDs = coldSongRows.map((r) => r.id); const [bestHotScores, bestScores] = await Promise.all([ - GetBestJubilityOnSongs(hotSongIDs, userID, "jubeat", "Single", 30), - GetBestJubilityOnSongs(coldSongIDs, userID, "jubeat", "Single", 30), + GetBestJubilityOnSongs(hotSongIDs, userID, "jubeat", 30), + GetBestJubilityOnSongs(coldSongIDs, userID, "jubeat", 30), ]); return { bestHotScores, bestScores }; } -async function CalculateJubility( - _game: GameGroup, - _playtype: Playtype, - userID: integer, -): Promise { +async function CalculateJubility(userID: integer): Promise { const { bestHotScores, bestScores } = await GetPBsForJubility(userID); let jubility = 0; @@ -156,7 +147,7 @@ async function CalculateJubility( return jubility; } -export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = { +export const JUBEAT_IMPL: GameImplementation<"jubeat"> = { chartSpecificValidators: { musicRate: (rate, chart) => { switch (chart.difficulty) { @@ -189,10 +180,10 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = { sessionCalcs: (arr) => ({ jubility: SessionAvgBest10For("jubility")(arr), }), - profileCalcs: async (game, playtype, userID) => { + profileCalcs: async (game, userID) => { const [jubility, naiveJubility] = await Promise.all([ - CalculateJubility(game, playtype, userID), - ProfileSumBestN("jubility", 60)(game, playtype, userID), + CalculateJubility(userID), + ProfileSumBestN("jubility", 60)(game, userID), ]); return { jubility, naiveJubility }; @@ -269,7 +260,7 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = { ), ], defaultMergeRefName: "Best Music Rate", - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if (s.scoreData.lamp === "EXCELLENT" && s.scoreData.score !== 1_000_000) { diff --git a/typescript/server/src/game-implementations/games/maimai.oldtest.ts b/typescript/server/src/game-implementations/games/maimai.oldtest.ts deleted file mode 100644 index b3f050cde..000000000 --- a/typescript/server/src/game-implementations/games/maimai.oldtest.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { integer, ProvidedMetrics, ScoreData } from "tachi-common"; - -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestSnapshot } from "#test-utils/single-process-snapshot"; -import { TestingMaimaiChart } from "#test-utils/test-data"; -import { MAIMAI_SINGLE_CONF } from "tachi-common/config/game-support/maimai"; -import t from "tap"; - -import { MAIMAI_IMPL } from "./maimai"; - -const baseMetrics: ProvidedMetrics["maimai:Single"] = { - lamp: "CLEAR", - percent: 97.01, -}; - -const GRADES = MAIMAI_SINGLE_CONF.derivedMetrics.grade.values; -const LAMPS = MAIMAI_SINGLE_CONF.providedMetrics.lamp.values; - -const scoreData: ScoreData<"maimai:Single"> = { - lamp: "CLEAR", - percent: 97.01, - grade: "S", - judgements: {}, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: GRADES.indexOf("S"), - lamp: LAMPS.indexOf("CLEAR"), - }, -}; - -const mockScore = mkMockScore("maimai", "Single", TestingMaimaiChart, scoreData); -const mockPB = mkMockPB("maimai", "Single", TestingMaimaiChart, scoreData); - -t.test("maimai Implementation", (t) => { - t.test("Percent Validator", (t) => { - t.equal(MAIMAI_IMPL.chartSpecificValidators.percent(100.78, TestingMaimaiChart), true); - t.equal(MAIMAI_IMPL.chartSpecificValidators.percent(0, TestingMaimaiChart), true); - - TestSnapshot( - t, - MAIMAI_IMPL.chartSpecificValidators.percent(-1, TestingMaimaiChart), - `Maimai Percent Validator: negative`, - ); - - TestSnapshot( - t, - MAIMAI_IMPL.chartSpecificValidators.percent(101, TestingMaimaiChart), - `Maimai Percent Validator: excessive`, - ); - - t.end(); - }); - - t.test("Grade Deriver", (t) => { - const f = (percent: number, expected: string) => - t.equal( - MAIMAI_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as any, TestingMaimaiChart) - .grade, - expected, - `A percent of ${percent} should result in grade=${expected}.`, - ); - - f(0, "F"); - f(10, "E"); - f(20, "D"); - f(40, "C"); - f(60, "B"); - f(80, "A"); - f(90, "AA"); - f(94, "AAA"); - f(97, "S"); - f(98, "S+"); - f(99, "SS"); - f(99.5, "SS+"); - f(100, "SSS"); - f(100.78, "SSS+"); - - t.end(); - }); - - t.test("Score Calcs", (t) => { - t.equal( - MAIMAI_IMPL.scoreCalcs( - scoreData, - MAIMAI_IMPL.scoreDeriver(scoreData, TestingMaimaiChart), - TestingMaimaiChart, - ).rate, - 14.85, - "Basic rate check", - ); - - t.end(); - }); - - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Colour Deriver", (t) => { - const f = (v: number | null, expected: string | null) => - t.equal( - MAIMAI_IMPL.classDerivers({ naiveRate: v }).colour, - expected, - `A rate of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "WHITE"); - - f(15, "RAINBOW"); - f(14.5, "GOLD"); - f(14, "SILVER"); - f(13, "BRONZE"); - f(12, "PURPLE"); - f(10, "RED"); - f(7, "YELLOW"); - f(4, "GREEN"); - f(2, "BLUE"); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal(MAIMAI_IMPL.goalCriteriaFormatters.percent(93.14), "Get 93.14% on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof MAIMAI_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: integer, - expected: string, - ) => - t.equal( - MAIMAI_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.50%"); - - f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SSS+"), "(S+)-0.50%"); - f("grade", { grade: "SSS", percent: 100.2 }, GRADES.indexOf("SSS+"), "SSS+0.20%"); - f("grade", { grade: "SSS+", percent: 100.78 }, GRADES.indexOf("SSS+"), "SSS+"); - - f("percent", { percent: 98.23 }, 1_000_000, "98.23%"); - f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(MAIMAI_IMPL.goalOutOfFormatters.percent(99.11), "99.11%"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - percent: 0, - lamp: "FULL COMBO", - enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("maimai:Single", 1, TestingMaimaiChart, log), { - composedFrom: [ - { name: "Best Percent" }, - { name: "Best Lamp", scoreID: "bestLamp" }, - ], - scoreData: { - percent: mockScore.scoreData.percent, - lamp: "FULL COMBO", - enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/maimai.test.ts b/typescript/server/src/game-implementations/games/maimai.test.ts new file mode 100644 index 000000000..77ad966be --- /dev/null +++ b/typescript/server/src/game-implementations/games/maimai.test.ts @@ -0,0 +1,251 @@ +import { MAIMAI_IMPL } from "#game-implementations/games/maimai"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingMaimaiChart, TestingMaimaiSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + GetGameConfig, + type integer, + type MongoProvidedMetrics, + type ScoreData, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +function enumMetricValues(m: { + type: string; + values?: ReadonlyArray; +}): ReadonlyArray { + if (m.type !== "ENUM" || m.values === undefined) { + throw new Error("expected ENUM metric with values"); + } + return m.values; +} + +const chart = TestingMaimaiChart; + +const MAIMAI_CONF = GetGameConfig("maimai"); +const GRADES = enumMetricValues(MAIMAI_CONF.derivedMetrics.grade); +const LAMPS = enumMetricValues(MAIMAI_CONF.providedMetrics.lamp); + +const baseMetrics: MongoProvidedMetrics["maimai"] = { + lamp: "CLEAR", + percent: 97.01, +}; + +const scoreData: ScoreData<"maimai"> = { + lamp: "CLEAR", + percent: 97.01, + grade: "S", + judgements: {}, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: GRADES.indexOf("S"), + lamp: LAMPS.indexOf("CLEAR"), + }, +}; + +async function seedMaimaiChart() { + await DB.insertInto("song") + .values({ + id: TestingMaimaiSong.id, + legacy_id: 834, + game_group: "maimai", + title: TestingMaimaiSong.title, + artist: TestingMaimaiSong.artist, + search_terms: TestingMaimaiSong.searchTerms, + alt_titles: TestingMaimaiSong.altTitles, + data: TestingMaimaiSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "maimai", + song_id: TestingMaimaiSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertMaimaiScore(opts: { + scoreId: string; + sd: ScoreData<"maimai">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("maimai", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "maimai", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("MAIMAI_IMPL", () => { + describe("chartSpecificValidators.percent", () => { + it("accepts in-range percent", () => { + expect(MAIMAI_IMPL.chartSpecificValidators.percent(100.78, chart)).toBe(true); + expect(MAIMAI_IMPL.chartSpecificValidators.percent(0, chart)).toBe(true); + }); + + it("rejects out-of-range percent", () => { + expect(MAIMAI_IMPL.chartSpecificValidators.percent(-1, chart)).toBe( + "Percent cannot be negative.", + ); + expect(MAIMAI_IMPL.chartSpecificValidators.percent(101, chart)).toBe( + `Percent cannot be greater than ${chart.data.maxPercent} for this chart.`, + ); + }); + }); + + describe("scoreDeriver (grade)", () => { + const g = (percent: number, expected: string) => + expect( + MAIMAI_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as never, chart as never) + .grade, + ).toBe(expected); + + it("maps percent to grade", () => { + g(0, "F"); + g(10, "E"); + g(20, "D"); + g(40, "C"); + g(60, "B"); + g(80, "A"); + g(90, "AA"); + g(94, "AAA"); + g(97, "S"); + g(98, "S+"); + g(99, "SS"); + g(99.5, "SS+"); + g(100, "SSS"); + g(100.78, "SSS+"); + }); + }); + + it("scoreCalcs rate", () => { + expect( + MAIMAI_IMPL.scoreCalcs(scoreData, MAIMAI_IMPL.scoreDeriver(scoreData, chart), chart) + .rate, + ).toBe(14.85); + }); + + describe("classDerivers", () => { + const c = ( + v: number | null, + expected: ReturnType["colour"], + ) => expect(MAIMAI_IMPL.classDerivers({ naiveRate: v }).colour).toBe(expected); + + it("maps rate to colour", () => { + c(null, null); + c(0, "WHITE"); + c(15, "RAINBOW"); + c(14.5, "GOLD"); + c(14, "SILVER"); + c(13, "BRONZE"); + c(12, "PURPLE"); + c(10, "RED"); + c(7, "YELLOW"); + c(4, "GREEN"); + c(2, "BLUE"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("maimai", chart, scoreData); + + it("criteria", () => { + expect(MAIMAI_IMPL.goalCriteriaFormatters.percent(93.14)).toBe("Get 93.14% on"); + }); + + it("progress", () => { + const f = ( + k: keyof typeof MAIMAI_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: integer, + expected: string, + ) => + expect( + MAIMAI_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.50%"); + f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SSS+"), "(S+)-0.50%"); + f("grade", { grade: "SSS", percent: 100.2 }, GRADES.indexOf("SSS+"), "SSS+0.20%"); + f("grade", { grade: "SSS+", percent: 100.78 }, GRADES.indexOf("SSS+"), "SSS+"); + f("percent", { percent: 98.23 }, 1_000_000, "98.23%"); + f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); + }); + + it("outOf", () => { + expect(MAIMAI_IMPL.goalOutOfFormatters.percent(99.11)).toBe("99.11%"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedMaimaiChart); + + it("joins best lamp", async () => { + const { id: userId } = await seedUser({ username: "maimai_pb" }); + const main = mkMockScore("maimai", chart, scoreData); + + const lampSd: ScoreData<"maimai"> = { + ...scoreData, + percent: 0, + lamp: "FULL COMBO", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: LAMPS.indexOf("FULL COMBO"), + }, + }; + + await insertMaimaiScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertMaimaiScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 }); + + const pb = await CreatePBDoc("maimai", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Percent" }, + { name: "Best Lamp", scoreID: "bestLamp" }, + ], + scoreData: { + percent: scoreData.percent, + lamp: "FULL COMBO", + enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/maimai.ts b/typescript/server/src/game-implementations/games/maimai.ts index 07efd2bd1..d5aa727fb 100644 --- a/typescript/server/src/game-implementations/games/maimai.ts +++ b/typescript/server/src/game-implementations/games/maimai.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc"; @@ -9,7 +9,7 @@ import { GetGrade, MAIMAI_GBOUNDARIES } from "tachi-common"; import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common"; -export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = { +export const MAIMAI_IMPL: GameImplementation<"maimai"> = { chartSpecificValidators: { percent: (percent, chart) => { if (percent < 0) { @@ -43,8 +43,8 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = { sessionCalcs: (arr) => ({ rate: SessionAvgBest10For("rate")(arr), }), - profileCalcs: async (game, playtype, userID) => ({ - naiveRate: await ProfileAvgBestN("rate", 30)(game, playtype, userID), + profileCalcs: async (game, userID) => ({ + naiveRate: await ProfileAvgBestN("rate", 30)(game, userID), }), classDerivers: (ratings) => { const rate = ratings.naiveRate; @@ -125,7 +125,7 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = { ), ], defaultMergeRefName: "Best Percent", - derivationRelevantFields: ["levelNum", "data.maxPercent"], + chartDataRelevantFields: ["levelNum", "data.maxPercent"], scoreValidators: [ (s) => { if (s.scoreData.percent > 104) { diff --git a/typescript/server/src/game-implementations/games/maimaidx.oldtest.ts b/typescript/server/src/game-implementations/games/maimaidx.oldtest.ts deleted file mode 100644 index 635b74364..000000000 --- a/typescript/server/src/game-implementations/games/maimaidx.oldtest.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { ProvidedMetrics, ScoreData } from "tachi-common"; - -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingMaimaiDXChart } from "#test-utils/test-data"; -import { MAIMAI_DX_SINGLE_CONF } from "tachi-common/config/game-support/maimai-dx"; -import t from "tap"; - -import { MAIMAIDX_IMPL } from "./maimaidx"; - -const baseMetrics: ProvidedMetrics["maimaidx:Single"] = { - lamp: "CLEAR", - percent: 97.012, -}; - -const GRADES = MAIMAI_DX_SINGLE_CONF.derivedMetrics.grade.values; -const LAMPS = MAIMAI_DX_SINGLE_CONF.providedMetrics.lamp.values; - -const scoreData: ScoreData<"maimaidx:Single"> = { - lamp: "CLEAR", - percent: 97.012, - grade: "SS", - judgements: {}, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: GRADES.indexOf("SS"), - lamp: LAMPS.indexOf("CLEAR"), - }, -}; - -const mockScore = mkMockScore("maimaidx", "Single", TestingMaimaiDXChart, scoreData); -const mockPB = mkMockPB("maimaidx", "Single", TestingMaimaiDXChart, scoreData); - -t.test("Maimai DX Implementation", (t) => { - t.test("Grade Deriver", (t) => { - const f = (percent: number, expected: any) => - t.equal( - MAIMAIDX_IMPL.scoreDeriver( - dmf(baseMetrics, { percent }) as any, - TestingMaimaiDXChart, - ).grade, - expected, - `A percent of ${percent} should result in grade=${expected}.`, - ); - - f(0, "D"); - f(50, "C"); - f(60, "B"); - f(70, "BB"); - f(75, "BBB"); - f(80, "A"); - f(90, "AA"); - f(94, "AAA"); - f(97, "S"); - f(98, "S+"); - f(99, "SS"); - f(99.5, "SS+"); - f(100, "SSS"); - f(100.5, "SSS+"); - - t.end(); - }); - - t.todo("Score Calcs"); - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Colour Deriver", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - MAIMAIDX_IMPL.classDerivers({ naiveRate: v }).colour, - expected, - `A rate of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "WHITE"); - - f(15000, "RAINBOW"); - f(14500, "PLATINUM"); - f(14000, "GOLD"); - f(13000, "SILVER"); - f(12000, "BRONZE"); - f(10000, "PURPLE"); - f(7000, "RED"); - f(4000, "YELLOW"); - f(2000, "GREEN"); - f(1000, "BLUE"); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal(MAIMAIDX_IMPL.goalCriteriaFormatters.percent(93.1415), "Get 93.1415% on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof MAIMAIDX_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - MAIMAIDX_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.5000%"); - f("percent", { percent: 98.23 }, 1_000_000, "98.2300%"); - f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(MAIMAIDX_IMPL.goalOutOfFormatters.percent(99.1123), "99.1123%"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - percent: 0, - lamp: "FULL COMBO", - enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("maimaidx:Single", 1, TestingMaimaiDXChart, log), { - composedFrom: [ - { name: "Best Percent" }, - { name: "Best Lamp", scoreID: "bestLamp" }, - ], - scoreData: { - percent: mockScore.scoreData.percent, - lamp: "FULL COMBO", - enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/maimaidx.test.ts b/typescript/server/src/game-implementations/games/maimaidx.test.ts new file mode 100644 index 000000000..a8be47eb2 --- /dev/null +++ b/typescript/server/src/game-implementations/games/maimaidx.test.ts @@ -0,0 +1,226 @@ +import { MAIMAIDX_IMPL } from "#game-implementations/games/maimaidx"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingMaimaiDXChart, TestingMaimaiDXSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { GetGameConfig, type MongoProvidedMetrics, type ScoreData } from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +function enumMetricValues(m: { + type: string; + values?: ReadonlyArray; +}): ReadonlyArray { + if (m.type !== "ENUM" || m.values === undefined) { + throw new Error("expected ENUM metric with values"); + } + return m.values; +} + +const chart = TestingMaimaiDXChart; + +const MAIMAIDX_CONF = GetGameConfig("maimaidx"); +const GRADES = enumMetricValues(MAIMAIDX_CONF.derivedMetrics.grade); +const LAMPS = enumMetricValues(MAIMAIDX_CONF.providedMetrics.lamp); + +const baseMetrics: MongoProvidedMetrics["maimaidx"] = { + lamp: "CLEAR", + percent: 97.012, +}; + +const scoreData: ScoreData<"maimaidx"> = { + lamp: "CLEAR", + percent: 97.012, + grade: "SS", + judgements: {}, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: GRADES.indexOf("SS"), + lamp: LAMPS.indexOf("CLEAR"), + }, +}; + +async function seedMaimaiDxChart() { + await DB.insertInto("song") + .values({ + id: TestingMaimaiDXSong.id, + legacy_id: 1, + game_group: "maimaidx", + title: TestingMaimaiDXSong.title, + artist: TestingMaimaiDXSong.artist, + search_terms: TestingMaimaiDXSong.searchTerms, + alt_titles: TestingMaimaiDXSong.altTitles, + data: TestingMaimaiDXSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "maimaidx", + song_id: TestingMaimaiDXSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertMaimaiDxScore(opts: { + scoreId: string; + sd: ScoreData<"maimaidx">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("maimaidx", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "maimaidx", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("MAIMAIDX_IMPL", () => { + describe("scoreDeriver (grade)", () => { + const g = (percent: number, expected: string) => + expect( + MAIMAIDX_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as never, chart as never) + .grade, + ).toBe(expected); + + it("maps percent to grade", () => { + g(0, "D"); + g(50, "C"); + g(60, "B"); + g(70, "BB"); + g(75, "BBB"); + g(80, "A"); + g(90, "AA"); + g(94, "AAA"); + g(97, "S"); + g(98, "S+"); + g(99, "SS"); + g(99.5, "SS+"); + g(100, "SSS"); + g(100.5, "SSS+"); + }); + }); + + describe("classDerivers", () => { + const c = ( + v: number | null, + expected: ReturnType["colour"], + ) => expect(MAIMAIDX_IMPL.classDerivers({ naiveRate: v }).colour).toBe(expected); + + it("maps rate to colour", () => { + c(null, null); + c(0, "WHITE"); + c(15000, "RAINBOW"); + c(14500, "PLATINUM"); + c(14000, "GOLD"); + c(13000, "SILVER"); + c(12000, "BRONZE"); + c(10000, "PURPLE"); + c(7000, "RED"); + c(4000, "YELLOW"); + c(2000, "GREEN"); + c(1000, "BLUE"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("maimaidx", chart, scoreData); + + it("criteria", () => { + expect(MAIMAIDX_IMPL.goalCriteriaFormatters.percent(93.1415)).toBe("Get 93.1415% on"); + }); + + it("progress", () => { + const f = ( + k: keyof typeof MAIMAIDX_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + MAIMAIDX_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.5000%"); + f("percent", { percent: 98.23 }, 1_000_000, "98.2300%"); + f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); + }); + + it("outOf", () => { + expect(MAIMAIDX_IMPL.goalOutOfFormatters.percent(99.1123)).toBe("99.1123%"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedMaimaiDxChart); + + it("joins best lamp", async () => { + const { id: userId } = await seedUser({ username: "maimaidx_pb" }); + const main = mkMockScore("maimaidx", chart, scoreData); + + const lampSd: ScoreData<"maimaidx"> = { + ...scoreData, + percent: 0, + lamp: "FULL COMBO", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: LAMPS.indexOf("FULL COMBO"), + }, + }; + + await insertMaimaiDxScore({ + userId, + scoreId: main.scoreID, + sd: scoreData, + timeMs: 1000, + }); + await insertMaimaiDxScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 }); + + const pb = await CreatePBDoc("maimaidx", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Percent" }, + { name: "Best Lamp", scoreID: "bestLamp" }, + ], + scoreData: { + percent: scoreData.percent, + lamp: "FULL COMBO", + enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/maimaidx.ts b/typescript/server/src/game-implementations/games/maimaidx.ts index 3b84465dc..87735c82b 100644 --- a/typescript/server/src/game-implementations/games/maimaidx.ts +++ b/typescript/server/src/game-implementations/games/maimaidx.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "#game-implementations/utils/profile-calc"; @@ -9,7 +9,7 @@ import { GetGrade, MAIMAIDX_GBOUNDARIES } from "tachi-common"; import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common"; -export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = { +export const MAIMAIDX_IMPL: GameImplementation<"maimaidx"> = { chartSpecificValidators: {}, scoreDeriver: (scoreData, _chart) => ({ grade: GetGrade(MAIMAIDX_GBOUNDARIES, scoreData.percent), @@ -28,8 +28,8 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = { sessionCalcs: (arr) => ({ rate: SessionAvgBest10For("rate")(arr), }), - profileCalcs: async (game, playtype, userID) => ({ - naiveRate: await ProfileSumBestN("rate", 50)(game, playtype, userID), + profileCalcs: async (game, userID) => ({ + naiveRate: await ProfileSumBestN("rate", 50)(game, userID), }), classDerivers: (ratings) => { const rate = ratings.naiveRate; @@ -91,7 +91,7 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = { ), ], defaultMergeRefName: "Best Percent", - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if (s.scoreData.lamp === "ALL PERFECT+" && s.scoreData.percent !== 101) { diff --git a/typescript/server/src/game-implementations/games/museca.oldtest.ts b/typescript/server/src/game-implementations/games/museca.oldtest.ts deleted file mode 100644 index 3c2a5d9ac..000000000 --- a/typescript/server/src/game-implementations/games/museca.oldtest.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingMusecaChart } from "#test-utils/test-data"; -import { MUSECA_GRADES, MUSECA_LAMPS, type ProvidedMetrics, type ScoreData } from "tachi-common"; -import t from "tap"; - -import { MUSECA_IMPL } from "./museca"; - -const baseMetrics: ProvidedMetrics["museca:Single"] = { - lamp: "CLEAR", - score: 903_000, -}; - -const scoreData: ScoreData<"museca:Single"> = { - lamp: "CLEAR", - score: 970_000, - grade: "傑", - judgements: {}, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: MUSECA_GRADES.傑, - lamp: MUSECA_LAMPS.CLEAR, - }, -}; - -const mockScore = mkMockScore("museca", "Single", TestingMusecaChart, scoreData); -const mockPB = mkMockPB("museca", "Single", TestingMusecaChart, scoreData); - -t.test("MUSECA Implementation", (t) => { - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: any) => - t.equal( - MUSECA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingMusecaChart) - .grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "没"); - f(600_000, "拙"); - f(700_000, "凡"); - f(800_000, "佳"); - f(850_000, "良"); - f(900_000, "優"); - f(950_000, "秀"); - f(975_000, "傑"); - f(1_000_000, "傑G"); - - t.end(); - }); - - t.todo("Score Calcs"); - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal(MUSECA_IMPL.goalCriteriaFormatters.score(908_182), "Get a score of 908,182 on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof MUSECA_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - MUSECA_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "傑", score: 997_342 }, MUSECA_GRADES.傑, "傑G-2.7K"); - f("score", { score: 982_123 }, 1_000_000, "982,123"); - f("lamp", { lamp: "CLEAR" }, MUSECA_LAMPS.CLEAR, "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(MUSECA_IMPL.goalOutOfFormatters.score(901_003), "901,003"); - t.equal(MUSECA_IMPL.goalOutOfFormatters.score(983_132), "983,132"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - score: 0, - lamp: "CONNECT ALL", - enumIndexes: { lamp: MUSECA_LAMPS.CONNECT_ALL }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("museca:Single", 1, TestingMusecaChart, log), { - composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], - scoreData: { - score: mockScore.scoreData.score, - lamp: "CONNECT ALL", - enumIndexes: { lamp: MUSECA_LAMPS.CONNECT_ALL }, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/museca.test.ts b/typescript/server/src/game-implementations/games/museca.test.ts new file mode 100644 index 000000000..d6074c3e3 --- /dev/null +++ b/typescript/server/src/game-implementations/games/museca.test.ts @@ -0,0 +1,185 @@ +import { MUSECA_IMPL } from "#game-implementations/games/museca"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingMusecaChart, TestingMusecaSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + type MongoProvidedMetrics, + MUSECA_GRADES, + MUSECA_LAMPS, + type ScoreData, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const chart = TestingMusecaChart; + +const baseMetrics: MongoProvidedMetrics["museca"] = { + lamp: "CLEAR", + score: 903_000, +}; + +const scoreData: ScoreData<"museca"> = { + lamp: "CLEAR", + score: 970_000, + grade: "傑", + judgements: {}, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: MUSECA_GRADES.傑, + lamp: MUSECA_LAMPS.CLEAR, + }, +}; + +async function seedMusecaChart() { + await DB.insertInto("song") + .values({ + id: TestingMusecaSong.id, + legacy_id: 1, + game_group: "museca", + title: TestingMusecaSong.title, + artist: TestingMusecaSong.artist, + search_terms: TestingMusecaSong.searchTerms, + alt_titles: TestingMusecaSong.altTitles, + data: TestingMusecaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "museca", + song_id: TestingMusecaSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertMusecaScore(opts: { + scoreId: string; + sd: ScoreData<"museca">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("museca", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "museca", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("MUSECA_IMPL", () => { + describe("scoreDeriver (grade)", () => { + const g = (score: number, expected: string) => + expect( + MUSECA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never) + .grade, + ).toBe(expected); + + it("maps score thresholds to kanji grades", () => { + g(0, "没"); + g(600_000, "拙"); + g(700_000, "凡"); + g(800_000, "佳"); + g(850_000, "良"); + g(900_000, "優"); + g(950_000, "秀"); + g(975_000, "傑"); + g(1_000_000, "傑G"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("museca", chart, scoreData); + + it("formats score criteria", () => { + expect(MUSECA_IMPL.goalCriteriaFormatters.score(908_182)).toBe( + "Get a score of 908,182 on", + ); + }); + + it("formats progress", () => { + const f = ( + k: keyof typeof MUSECA_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + MUSECA_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "傑", score: 997_342 }, MUSECA_GRADES.傑, "傑G-2.7K"); + f("score", { score: 982_123 }, 1_000_000, "982,123"); + f("lamp", { lamp: "CLEAR" }, MUSECA_LAMPS.CLEAR, "CLEAR"); + }); + + it("formats out-of score", () => { + expect(MUSECA_IMPL.goalOutOfFormatters.score(901_003)).toBe("901,003"); + expect(MUSECA_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedMusecaChart); + + it("joins best lamp", async () => { + const { id: userId } = await seedUser({ username: "museca_pb" }); + const main = mkMockScore("museca", chart, scoreData); + + const lampSd: ScoreData<"museca"> = { + ...scoreData, + score: 0, + lamp: "CONNECT ALL", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: MUSECA_LAMPS.CONNECT_ALL, + }, + }; + + await insertMusecaScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertMusecaScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 }); + + const pb = await CreatePBDoc("museca", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], + scoreData: { + score: scoreData.score, + lamp: "CONNECT ALL", + enumIndexes: { lamp: MUSECA_LAMPS.CONNECT_ALL }, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/museca.ts b/typescript/server/src/game-implementations/games/museca.ts index 3819f1701..d71ea3033 100644 --- a/typescript/server/src/game-implementations/games/museca.ts +++ b/typescript/server/src/game-implementations/games/museca.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "#game-implementations/utils/profile-calc"; @@ -8,7 +8,7 @@ import { FmtNum, GetGrade, MUSECA_GBOUNDARIES } from "tachi-common"; import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; -export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = { +export const MUSECA_IMPL: GameImplementation<"museca"> = { chartSpecificValidators: {}, scoreDeriver: (scoreData, _chart) => ({ grade: GetGrade(MUSECA_GBOUNDARIES, scoreData.score), @@ -27,8 +27,8 @@ export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = { sessionCalcs: (arr) => ({ curatorSkill: SessionAvgBest10For("curatorSkill")(arr), }), - profileCalcs: async (game, playtype, userID) => ({ - curatorSkill: await ProfileSumBestN("curatorSkill", 20)(game, playtype, userID), + profileCalcs: async (game, userID) => ({ + curatorSkill: await ProfileSumBestN("curatorSkill", 20)(game, userID), }), classDerivers: (_ratings) => ({}), goalCriteriaFormatters: { @@ -59,7 +59,7 @@ export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = { ), ], defaultMergeRefName: "Best Score", - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if (s.scoreData.lamp === "PERFECT CONNECT ALL" && s.scoreData.score !== 1_000_000) { diff --git a/typescript/server/src/game-implementations/games/ongeki.oldtest.ts b/typescript/server/src/game-implementations/games/ongeki.oldtest.ts deleted file mode 100644 index f7c9a8ad6..000000000 --- a/typescript/server/src/game-implementations/games/ongeki.oldtest.ts +++ /dev/null @@ -1,449 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { log } from "#lib/log/log.js"; -import { CreatePBDoc, UpdateChartRanking } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingOngekiChart, TestingOngekiScorePB } from "#test-utils/test-data"; -import { - ONGEKI_BELL_LAMPS, - ONGEKI_GRADES, - ONGEKI_NOTE_LAMPS, - type PBScoreDocument, - type ProvidedMetrics, - type ScoreData, -} from "tachi-common"; -import t from "tap"; - -import { ONGEKI_IMPL } from "./ongeki"; - -const baseMetrics: ProvidedMetrics["ongeki:Single"] = { - noteLamp: "CLEAR", - bellLamp: "FULL BELL", - score: 1_001_500, - platinumScore: 970, -}; - -const scoreData: ScoreData<"ongeki:Single"> = { - noteLamp: "CLEAR", - bellLamp: "FULL BELL", - score: 1_001_500, - platinumScore: 970, - platinumStars: 4, - grade: "SSS", - judgements: {}, - optional: { enumIndexes: {}, bellCount: 100 }, - enumIndexes: { - grade: ONGEKI_GRADES.SSS, - noteLamp: ONGEKI_NOTE_LAMPS.CLEAR, - bellLamp: ONGEKI_BELL_LAMPS.FULL_BELL, - }, -}; - -const mockScore = mkMockScore("ongeki", "Single", TestingOngekiChart, scoreData); -const mockPB = mkMockPB("ongeki", "Single", TestingOngekiChart, scoreData); - -t.test("ONGEKI Implementation", (t: any) => { - t.test("Grade Deriver", (t: any) => { - const f = (score: number, expected: string) => - t.equal( - ONGEKI_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingOngekiChart) - .grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "D"); - f(500_000, "C"); - f(700_000, "B"); - f(750_000, "BB"); - f(800_000, "BBB"); - f(850_000, "A"); - f(900_000, "AA"); - f(940_000, "AAA"); - f(970_000, "S"); - f(990_000, "SS"); - f(1_000_000, "SSS"); - f(1_007_500, "SSS+"); - f(1_010_000, "SSS+"); - - t.end(); - }); - - t.test("Star Deriver", (t: any) => { - const f = (platinumScore: number, expected: number) => - t.equal( - ONGEKI_IMPL.scoreDeriver( - dmf(baseMetrics, { platinumScore }) as any, - TestingOngekiChart, - ).platinumStars, - expected, - `A score of ${platinumScore.toLocaleString()} should result in stars=${expected}.`, - ); - - f(939, 0); - f(940, 1); - f(950, 2); - f(960, 3); - f(970, 4); - f(980, 5); - f(990, 6); - f(1000, 6); - - t.end(); - }); - - t.test("Rating Calc", (t: any) => { - const derived = ONGEKI_IMPL.scoreDeriver(scoreData, TestingOngekiChart); - const calcs = ONGEKI_IMPL.scoreCalcs(scoreData, derived, TestingOngekiChart); - - t.equal(calcs.rating, 12.1, "Basic classic rating check"); - - t.equal(calcs.scoreRating, 12.1, "Basic score rating check"); - - t.equal( - calcs.starRating, - Math.floor(scoreData.platinumStars * (TestingOngekiChart.levelNum * 10) ** 2) / - 100000.0, - "Basic star rating check", - ); - - t.end(); - }); - - t.todo("Session Calcs"); - - t.test("Profile Calcs", (t: any) => { - t.beforeEach(ResetDBState); - - const mockPBs = async ( - ratings: Array, - field: "rating" | "scoreRating" | "starRating", - ) => { - await Promise.all( - ratings.map((rating, idx) => { - const ratings = - // Leave me and my ternaries alone - - field === "rating" - ? { rating, scoreRating: 0, starRating: 0 } - : field === "scoreRating" - ? { rating: 0, scoreRating: rating, starRating: 0 } - : { rating: 0, scoreRating: 0, starRating: rating }; - - return MONGODB_KILL["personal-bests"].insert({ - ...TestingOngekiScorePB, - chartID: `TEST${field}${idx}`, - calculatedData: { - ...TestingOngekiScorePB.calculatedData, - ...ratings, - }, - }); - }), - ); - }; - - t.test("Floating-point edge case", async (t: any) => { - await mockPBs(Array(45).fill(16.27), "rating"); - await mockPBs(Array(60).fill(16.271), "scoreRating"); - - t.equal((await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRating, 16.27); - t.equal( - (await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh, - 19.525, - ); - - await mockPBs([1, 1, 0, 0], "starRating"); - - t.equal( - (await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh, - 19.565, - ); - - t.end(); - }); - - t.test("Profile with few scores #1", async (t: any) => { - await mockPBs([16, 16, 16, 16], "rating"); - await mockPBs([16, 16, 16, 16], "scoreRating"); - - t.equal((await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRating, 1.42); - t.equal( - (await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh, - 1.279, - ); - - await mockPBs([1, 1, 0, 0], "starRating"); - - t.equal( - (await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh, - 1.319, - ); - - t.end(); - }); - - t.test("Profile with few scores #2", async (t: any) => { - await mockPBs([1, 1, 0, 0], "starRating"); - - t.equal( - (await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh, - 0.04, - ); - - t.end(); - }); - - t.end(); - }); - - t.test("Colour Deriver", (t: any) => { - const f = (v: number | null, expected: string | null) => - t.equal( - ONGEKI_IMPL.classDerivers({ naiveRatingRefresh: v }).colour, - expected, - `A naiveRatingRefresh of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "BLUE"); - f(4, "GREEN"); - f(7, "ORANGE"); - f(9, "RED"); - f(11, "PURPLE"); - f(13, "COPPER"); - f(15, "SILVER"); - f(17, "GOLD"); - f(18, "PLATINUM"); - f(19, "RAINBOW"); - f(20, "RAINBOW_SHINY"); - f(21, "RAINBOW_EX"); - - t.end(); - }); - - t.test("Goal Formatters", (t: any) => { - t.test("Criteria", (t: any) => { - t.equal( - ONGEKI_IMPL.goalCriteriaFormatters.score(1_008_182), - "Get a score of 1,008,182 on", - ); - - t.equal( - ONGEKI_IMPL.goalCriteriaFormatters.platinumScore(1500), - "Get 1,500 Platinum Score on", - ); - - t.equal(ONGEKI_IMPL.goalCriteriaFormatters.platinumStars(3), "Get ★★★☆☆ on"); - - t.end(); - }); - - t.test("Progress", (t: any) => { - const f = ( - k: keyof typeof ONGEKI_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - ONGEKI_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "S", score: 987_342 }, ONGEKI_GRADES.S, "SS-2.7K"); - f("noteLamp", { noteLamp: "CLEAR" }, ONGEKI_NOTE_LAMPS.CLEAR, "CLEAR"); - f("bellLamp", { bellLamp: "FULL BELL" }, ONGEKI_BELL_LAMPS.FULL_BELL, "FULL BELL"); - f("score", { score: 982_123 }, 1_000_000, "982,123"); - f("platinumScore", { platinumScore: 1234 }, 2345, "1,234"); - - t.end(); - }); - - t.test("Out Of", (t: any) => { - t.equal(ONGEKI_IMPL.goalOutOfFormatters.score(1_001_003), "1,001,003"); - t.equal(ONGEKI_IMPL.goalOutOfFormatters.score(983_132), "983,132"); - t.equal(ONGEKI_IMPL.goalOutOfFormatters.platinumScore(1234), "1,234"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t: any) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t: any) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - score: 0, - noteLamp: "FULL COMBO", - enumIndexes: { noteLamp: ONGEKI_NOTE_LAMPS.FULL_COMBO }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("ongeki:Single", 1, TestingOngekiChart, log), { - composedFrom: [ - { name: "Best Score" }, - { name: "Best Note Lamp", scoreID: "bestLamp" }, - ], - scoreData: { - score: mockScore.scoreData.score, - noteLamp: "FULL COMBO", - bellLamp: "FULL BELL", - enumIndexes: { - noteLamp: ONGEKI_NOTE_LAMPS.FULL_COMBO, - bellLamp: ONGEKI_BELL_LAMPS.FULL_BELL, - }, - }, - }); - - t.end(); - }); - - t.test("Should join platinum score", async (t: any) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestPlatinum", - scoreData: { - score: 0, - platinumScore: 990, - platinumStars: 6, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("ongeki:Single", 1, TestingOngekiChart, log), { - composedFrom: [ - { name: "Best Score" }, - { name: "Best Platinum Score", scoreID: "bestPlatinum" }, - ], - scoreData: { - score: mockScore.scoreData.score, - platinumScore: 990, - platinumStars: 6, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.test("Ranking", (t: any) => { - t.beforeEach(ResetDBState); - - t.test("Should tiebreak according to platinum score", async (t: any) => { - await MONGODB_KILL["personal-bests"].insert({ - ...TestingOngekiScorePB, - scoreData: { - ...TestingOngekiScorePB.scoreData, - platinumScore: 1000, - }, - timeAchieved: 3, - userID: 1, - }); - - await MONGODB_KILL["personal-bests"].insert({ - ...TestingOngekiScorePB, - scoreData: { - ...TestingOngekiScorePB.scoreData, - platinumScore: 1001, - }, - timeAchieved: 2, - userID: 2, - }); - - await MONGODB_KILL["personal-bests"].insert({ - ...TestingOngekiScorePB, - scoreData: { - ...TestingOngekiScorePB.scoreData, - platinumScore: 999, - }, - timeAchieved: 1, - userID: 3, - }); - - await UpdateChartRanking("ongeki", "Single", TestingOngekiChart.chartID); - - const pbs = (await MONGODB_KILL["personal-bests"].find({ - chartID: TestingOngekiChart.chartID, - })) as Array>; - - t.strictSame(pbs.length, 3); - - for (const pb of pbs) { - t.strictSame(pb.rankingData.outOf, 3); - if (pb.scoreData.platinumScore === 999) { - t.strictSame(pb.rankingData.rank, 3); - } else if (pb.scoreData.platinumScore === 1000) { - t.strictSame(pb.rankingData.rank, 2); - } else { - t.strictSame(pb.rankingData.rank, 1); - } - } - - t.end(); - }); - - t.test("Should not tiebreak if tech scores differ", async (t: any) => { - await MONGODB_KILL["personal-bests"].insert({ - ...TestingOngekiScorePB, - scoreData: { - ...TestingOngekiScorePB.scoreData, - score: 1010000, - platinumScore: 0, - }, - userID: 1, - }); - - await MONGODB_KILL["personal-bests"].insert({ - ...TestingOngekiScorePB, - scoreData: { - ...TestingOngekiScorePB.scoreData, - score: 1009999, - platinumScore: 1001, - }, - userID: 2, - }); - - await UpdateChartRanking("ongeki", "Single", TestingOngekiChart.chartID); - - const [pb1, pb2] = (await MONGODB_KILL["personal-bests"].find( - { - chartID: TestingOngekiChart.chartID, - }, - { - sort: { - [`rankingData.rank`]: 1, - }, - }, - )) as Array>; - - t.strictSame(pb1?.rankingData.outOf, 2); - t.strictSame(pb1?.rankingData.rank, 1); - t.strictSame(pb2?.rankingData.rank, 2); - t.strictSame(pb1?.scoreData.platinumScore, 0); - t.strictSame(pb2?.scoreData.platinumScore, 1001); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/ongeki.test.ts b/typescript/server/src/game-implementations/games/ongeki.test.ts new file mode 100644 index 000000000..cfa2f5caf --- /dev/null +++ b/typescript/server/src/game-implementations/games/ongeki.test.ts @@ -0,0 +1,408 @@ +import { ONGEKI_IMPL } from "#game-implementations/games/ongeki"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingOngekiChart, TestingOngekiScorePB, TestingOngekiSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + type MongoProvidedMetrics, + ONGEKI_BELL_LAMPS, + ONGEKI_GRADES, + ONGEKI_NOTE_LAMPS, + type ScoreData, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const chart = TestingOngekiChart; + +const baseMetrics: MongoProvidedMetrics["ongeki"] = { + noteLamp: "CLEAR", + bellLamp: "FULL BELL", + score: 1_001_500, + platinumScore: 970, +}; + +const scoreData: ScoreData<"ongeki"> = { + noteLamp: "CLEAR", + bellLamp: "FULL BELL", + score: 1_001_500, + platinumScore: 970, + platinumStars: 4, + grade: "SSS", + judgements: {}, + optional: { enumIndexes: {}, bellCount: 100 }, + enumIndexes: { + grade: ONGEKI_GRADES.SSS, + noteLamp: ONGEKI_NOTE_LAMPS.CLEAR, + bellLamp: ONGEKI_BELL_LAMPS.FULL_BELL, + }, +}; + +let ongekiProfileCounter = 0; + +async function seedOngekiChartFixture() { + await DB.insertInto("song") + .values({ + id: TestingOngekiSong.id, + legacy_id: 1, + game_group: "ongeki", + title: TestingOngekiSong.title, + artist: TestingOngekiSong.artist, + search_terms: TestingOngekiSong.searchTerms, + alt_titles: TestingOngekiSong.altTitles, + data: TestingOngekiSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "ongeki", + song_id: TestingOngekiSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertOngekiScore(opts: { + scoreId: string; + sd: ScoreData<"ongeki">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("ongeki", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "ongeki", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +async function seedOngekiProfilePbs( + userId: number, + ratings: Array, + field: "rating" | "scoreRating" | "starRating", +): Promise { + await Promise.all( + ratings.map(async (rating, idx) => { + const n = ++ongekiProfileCounter; + const songId = `ongeki-pc-song-${field}-${n}`; + const chartId = `ongeki-pc-chart-${field}-${n}`; + + const mergedCalcs = { + ...TestingOngekiScorePB.calculatedData, + ...(field === "rating" + ? { rating, scoreRating: 0, starRating: 0 } + : field === "scoreRating" + ? { rating: 0, scoreRating: rating, starRating: 0 } + : { rating: 0, scoreRating: 0, starRating: rating }), + }; + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: n, + game_group: "ongeki", + title: "T", + artist: "A", + search_terms: [], + alt_titles: [], + data: JSON.stringify({}), + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "ongeki", + song_id: songId, + level: chart.level, + level_num: chart.levelNum, + is_primary: true, + difficulty: chart.difficulty, + versions: chart.versions, + data: chart.data, + }) + .execute(); + + await DB.insertInto("pb") + .values({ + user_id: userId, + chart_id: chartId, + lens: null, + data: JSON.stringify({}), + derived_data: JSON.stringify({}), + calculated_data: JSON.stringify(mergedCalcs), + judgements: JSON.stringify({}), + ranking_value: rating, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: false, + time_achieved: null, + }) + .execute(); + }), + ); +} + +describe("ONGEKI_IMPL", () => { + describe("scoreDeriver", () => { + it("maps score to grade", () => { + const g = (score: number, expected: string) => + expect( + ONGEKI_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never) + .grade, + ).toBe(expected); + + g(0, "D"); + g(500_000, "C"); + g(700_000, "B"); + g(750_000, "BB"); + g(800_000, "BBB"); + g(850_000, "A"); + g(900_000, "AA"); + g(940_000, "AAA"); + g(970_000, "S"); + g(990_000, "SS"); + g(1_000_000, "SSS"); + g(1_007_500, "SSS+"); + g(1_010_000, "SSS+"); + }); + + it("maps platinum score to stars", () => { + const f = (platinumScore: number, expected: number) => + expect( + ONGEKI_IMPL.scoreDeriver( + dmf(baseMetrics, { platinumScore }) as never, + chart as never, + ).platinumStars, + ).toBe(expected); + + f(939, 0); + f(940, 1); + f(950, 2); + f(960, 3); + f(970, 4); + f(980, 5); + f(990, 6); + f(1000, 6); + }); + }); + + it("scoreCalcs rating, scoreRating, starRating", () => { + const derived = ONGEKI_IMPL.scoreDeriver(scoreData, chart); + const calcs = ONGEKI_IMPL.scoreCalcs(scoreData, derived, chart); + + expect(calcs.rating).toBe(12.1); + expect(calcs.scoreRating).toBe(12.1); + expect(calcs.starRating).toBe( + Math.floor(scoreData.platinumStars * (chart.levelNum * 10) ** 2) / 100000.0, + ); + }); + + it.skip("Session Calcs", () => { + // not ported from tap todo + }); + + describe("profileCalcs (Postgres pb.calculated_data)", () => { + it("floating-point edge case", async () => { + const { id: userId } = await seedUser({ username: "ongeki_prof_fp" }); + await seedOngekiProfilePbs(userId, Array(45).fill(16.27), "rating"); + await seedOngekiProfilePbs(userId, Array(60).fill(16.271), "scoreRating"); + + const a = await ONGEKI_IMPL.profileCalcs("ongeki", userId); + expect(a.naiveRating).toBe(16.27); + expect(a.naiveRatingRefresh).toBe(19.525); + + await seedOngekiProfilePbs(userId, [1, 1, 0, 0], "starRating"); + + const b = await ONGEKI_IMPL.profileCalcs("ongeki", userId); + expect(b.naiveRatingRefresh).toBe(19.565); + }); + + it("profile with few scores #1", async () => { + const { id: userId } = await seedUser({ username: "ongeki_prof_few1" }); + await seedOngekiProfilePbs(userId, [16, 16, 16, 16], "rating"); + await seedOngekiProfilePbs(userId, [16, 16, 16, 16], "scoreRating"); + + const a = await ONGEKI_IMPL.profileCalcs("ongeki", userId); + expect(a.naiveRating).toBe(1.42); + expect(a.naiveRatingRefresh).toBe(1.279); + + await seedOngekiProfilePbs(userId, [1, 1, 0, 0], "starRating"); + + const b = await ONGEKI_IMPL.profileCalcs("ongeki", userId); + expect(b.naiveRatingRefresh).toBe(1.319); + }); + + it("profile with few scores #2", async () => { + const { id: userId } = await seedUser({ username: "ongeki_prof_few2" }); + await seedOngekiProfilePbs(userId, [1, 1, 0, 0], "starRating"); + + const a = await ONGEKI_IMPL.profileCalcs("ongeki", userId); + expect(a.naiveRatingRefresh).toBe(0.04); + }); + }); + + describe("classDerivers", () => { + const c = (v: number | null, expected: string | null) => + expect(ONGEKI_IMPL.classDerivers({ naiveRatingRefresh: v }).colour).toBe(expected); + + it("maps naiveRatingRefresh to colour", () => { + c(null, null); + c(0, "BLUE"); + c(4, "GREEN"); + c(7, "ORANGE"); + c(9, "RED"); + c(11, "PURPLE"); + c(13, "COPPER"); + c(15, "SILVER"); + c(17, "GOLD"); + c(18, "PLATINUM"); + c(19, "RAINBOW"); + c(20, "RAINBOW_SHINY"); + c(21, "RAINBOW_EX"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("ongeki", chart, scoreData); + + it("criteria", () => { + expect(ONGEKI_IMPL.goalCriteriaFormatters.score(1_008_182)).toBe( + "Get a score of 1,008,182 on", + ); + expect(ONGEKI_IMPL.goalCriteriaFormatters.platinumScore(1500)).toBe( + "Get 1,500 Platinum Score on", + ); + expect(ONGEKI_IMPL.goalCriteriaFormatters.platinumStars(3)).toBe("Get ★★★☆☆ on"); + }); + + it("progress", () => { + const f = ( + k: keyof typeof ONGEKI_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + ONGEKI_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "S", score: 987_342 }, ONGEKI_GRADES.S, "SS-2.7K"); + f("noteLamp", { noteLamp: "CLEAR" }, ONGEKI_NOTE_LAMPS.CLEAR, "CLEAR"); + f("bellLamp", { bellLamp: "FULL BELL" }, ONGEKI_BELL_LAMPS.FULL_BELL, "FULL BELL"); + f("score", { score: 982_123 }, 1_000_000, "982,123"); + f("platinumScore", { platinumScore: 1234 }, 2345, "1,234"); + }); + + it("outOf", () => { + expect(ONGEKI_IMPL.goalOutOfFormatters.score(1_001_003)).toBe("1,001,003"); + expect(ONGEKI_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132"); + expect(ONGEKI_IMPL.goalOutOfFormatters.platinumScore(1234)).toBe("1,234"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedOngekiChartFixture); + + it("joins best note lamp", async () => { + const { id: userId } = await seedUser({ username: "ongeki_pb_lamp" }); + const main = mkMockScore("ongeki", chart, scoreData); + + const lampSd: ScoreData<"ongeki"> = { + ...scoreData, + score: 0, + noteLamp: "FULL COMBO", + enumIndexes: { + ...scoreData.enumIndexes, + noteLamp: ONGEKI_NOTE_LAMPS.FULL_COMBO, + }, + }; + + await insertOngekiScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertOngekiScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 }); + + const pb = await CreatePBDoc("ongeki", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Score" }, + { name: "Best Note Lamp", scoreID: "bestLamp" }, + ], + scoreData: { + score: scoreData.score, + noteLamp: "FULL COMBO", + bellLamp: "FULL BELL", + enumIndexes: { + noteLamp: ONGEKI_NOTE_LAMPS.FULL_COMBO, + bellLamp: ONGEKI_BELL_LAMPS.FULL_BELL, + }, + }, + }); + }); + + it("joins best platinum score", async () => { + const { id: userId } = await seedUser({ username: "ongeki_pb_plat" }); + const main = mkMockScore("ongeki", chart, scoreData); + + const platSd: ScoreData<"ongeki"> = { + ...scoreData, + score: 0, + platinumScore: 990, + platinumStars: 6, + }; + + await insertOngekiScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertOngekiScore({ userId, scoreId: "bestPlatinum", sd: platSd, timeMs: 2000 }); + + const pb = await CreatePBDoc("ongeki", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Score" }, + { name: "Best Platinum Score", scoreID: "bestPlatinum" }, + ], + scoreData: { + score: scoreData.score, + platinumScore: 990, + platinumStars: 6, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/ongeki.ts b/typescript/server/src/game-implementations/games/ongeki.ts index 886b897e6..f488205a2 100644 --- a/typescript/server/src/game-implementations/games/ongeki.ts +++ b/typescript/server/src/game-implementations/games/ongeki.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc"; @@ -6,17 +6,17 @@ import { SessionAvgBest10For } from "#game-implementations/utils/session-calc"; import { IsNullish } from "#utils/misc"; import { ONGEKIRating } from "rg-stats"; import { + type ChartDocument, FmtNum, FmtStars, FmtStarsCompact, GetGrade, - type MONGO_ChartDocument, ONGEKI_GBOUNDARIES, } from "tachi-common"; import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; -const isUnranked = (chart: MONGO_ChartDocument<"ongeki:Single">) => +const isUnranked = (chart: ChartDocument<"ongeki">) => (chart.data.inGameID >= 7000 && chart.data.inGameID < 8000) || chart.levelNum === 0.0; const starCount = (platinumScore: number, maxPlatinumScore: number) => { @@ -25,7 +25,7 @@ const starCount = (platinumScore: number, maxPlatinumScore: number) => { return Math.max(0, Math.min(pct, 99) - 93); }; -export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { +export const ONGEKI_IMPL: GameImplementation<"ongeki"> = { chartSpecificValidators: { bellCount: (bellCount) => { if (bellCount < 0) { @@ -93,11 +93,11 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { naiveScoreRating: SessionAvgBest10For("scoreRating")(arr), starRating: SessionAvgBest10For("starRating")(arr), }), - profileCalcs: async (game, playtype, userID) => { + profileCalcs: async (game, userID) => { const [naiveRating, score, star] = await Promise.all([ - ProfileAvgBestN("rating", 45, false, 100)(game, playtype, userID), - ProfileAvgBestN("scoreRating", 60, false, 1000)(game, playtype, userID), - ProfileAvgBestN("starRating", 50, false, 1000)(game, playtype, userID), + ProfileAvgBestN("rating", 45, false, 100)(game, userID), + ProfileAvgBestN("scoreRating", 60, false, 1000)(game, userID), + ProfileAvgBestN("starRating", 50, false, 1000)(game, userID), ]); const score1k = Math.round((score ?? 0) * 1000); @@ -191,7 +191,7 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { ), ], defaultMergeRefName: "Best Score", - derivationRelevantFields: ["levelNum", "data.maxPlatScore", "data.inGameID"], + chartDataRelevantFields: ["levelNum", "data.maxPlatScore", "data.inGameID"], scoreValidators: [ (s, chart) => { let { hit, miss } = s.scoreData.judgements; diff --git a/typescript/server/src/game-implementations/games/popn.oldtest.ts b/typescript/server/src/game-implementations/games/popn.oldtest.ts deleted file mode 100644 index 0c73c6c35..000000000 --- a/typescript/server/src/game-implementations/games/popn.oldtest.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { ProvidedMetrics, ScoreData } from "tachi-common"; - -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingPopnChart } from "#test-utils/test-data"; -import { POPN_9B_CONF } from "tachi-common/config/game-support/popn"; -import t from "tap"; - -import { POPN_9B_IMPL } from "./popn"; - -const baseMetrics: ProvidedMetrics["popn:9B"] = { - clearMedal: "clearCircle", - score: 93_001, -}; - -const GRADES = POPN_9B_CONF.derivedMetrics.grade.values; -const LAMPS = POPN_9B_CONF.derivedMetrics.lamp.values; -const CLEAR_MEDALS = POPN_9B_CONF.providedMetrics.clearMedal.values; - -const scoreData: ScoreData<"popn:9B"> = { - lamp: "CLEAR", - clearMedal: "clearCircle", - score: 84_020, - grade: "A", - judgements: {}, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: GRADES.indexOf("S"), - lamp: LAMPS.indexOf("CLEAR"), - clearMedal: CLEAR_MEDALS.indexOf("clearCircle"), - }, -}; - -const mockScore = mkMockScore("popn", "9B", TestingPopnChart, scoreData); -const mockPB = mkMockPB("popn", "9B", TestingPopnChart, scoreData); - -t.test("Pop'n Implementation", (t) => { - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: any) => - t.equal( - POPN_9B_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingPopnChart) - .grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "E"); - f(50_000, "D"); - f(62_000, "C"); - f(72_000, "B"); - f(82_000, "A"); - f(90_000, "AA"); - f(95_000, "AAA"); - f(98_000, "S"); - - t.end(); - }); - - t.test("Lamp Deriver", (t) => { - const f = (clearMedal: ProvidedMetrics["popn:9B"]["clearMedal"], expected: any) => - t.equal( - POPN_9B_IMPL.scoreDeriver(dmf(baseMetrics, { clearMedal }) as any, TestingPopnChart) - .lamp, - expected, - `A clear medal of ${clearMedal} should result in lamp=${expected}.`, - ); - - f("failedCircle", "FAILED"); - f("failedStar", "FAILED"); - f("failedDiamond", "FAILED"); - - f("easyClear", "EASY CLEAR"); - - f("clearCircle", "CLEAR"); - f("clearStar", "CLEAR"); - f("clearDiamond", "CLEAR"); - - f("fullComboCircle", "FULL COMBO"); - f("fullComboStar", "FULL COMBO"); - f("fullComboDiamond", "FULL COMBO"); - - f("perfect", "PERFECT"); - - t.end(); - }); - - t.todo("Score Calcs"); - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Class Deriver", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - POPN_9B_IMPL.classDerivers({ naiveClassPoints: v }).class, - expected, - `Naive Class Points of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "KITTY"); - f(21, "STUDENT"); - f(34, "DELINQUENT"); - f(46, "DETECTIVE"); - f(59, "IDOL"); - f(68, "GENERAL"); - f(79, "HERMIT"); - f(91, "GOD"); - f(100, "GOD"); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal( - POPN_9B_IMPL.goalCriteriaFormatters.score(908_182), - "Get a score of 908,182 on", - ); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof POPN_9B_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - POPN_9B_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "AAA", score: 95_342 }, GRADES.indexOf("S"), "S-2.7K"); - f("score", { score: 98_123 }, 100_000, "98,123"); - f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(POPN_9B_IMPL.goalOutOfFormatters.score(901_003), "901,003"); - t.equal(POPN_9B_IMPL.goalOutOfFormatters.score(983_132), "983,132"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best clear medal", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestClearMedal", - scoreData: { - clearMedal: "perfect", - lamp: "PERFECT", - enumIndexes: { - lamp: LAMPS.indexOf("PERFECT"), - clearMedal: CLEAR_MEDALS.indexOf("perfect"), - }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("popn:9B", 1, TestingPopnChart, log), { - composedFrom: [ - { name: "Best Score" }, - { name: "Best Clear", scoreID: "bestClearMedal" }, - ], - scoreData: { - score: mockScore.scoreData.score, - lamp: "PERFECT", - clearMedal: "perfect", - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/popn.test.ts b/typescript/server/src/game-implementations/games/popn.test.ts new file mode 100644 index 000000000..0f8b8f955 --- /dev/null +++ b/typescript/server/src/game-implementations/games/popn.test.ts @@ -0,0 +1,245 @@ +import { POPN_IMPL } from "#game-implementations/games/popn"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingPopnChart, TestingPopnSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { GetGameConfig, type MongoProvidedMetrics, type ScoreData } from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +function enumMetricValues(m: { + type: string; + values?: ReadonlyArray; +}): ReadonlyArray { + if (m.type !== "ENUM" || m.values === undefined) { + throw new Error("expected ENUM metric with values"); + } + return m.values; +} + +const chart = TestingPopnChart; + +const POPN_CONF = GetGameConfig("popn"); +const GRADES = enumMetricValues(POPN_CONF.derivedMetrics.grade); +const LAMPS = enumMetricValues(POPN_CONF.derivedMetrics.lamp); +const CLEAR_MEDALS = enumMetricValues(POPN_CONF.providedMetrics.clearMedal); + +const baseMetrics: MongoProvidedMetrics["popn"] = { + clearMedal: "clearCircle", + score: 93_001, +}; + +const scoreData: ScoreData<"popn"> = { + lamp: "CLEAR", + clearMedal: "clearCircle", + score: 84_020, + grade: "A", + judgements: {}, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: GRADES.indexOf("S"), + lamp: LAMPS.indexOf("CLEAR"), + clearMedal: CLEAR_MEDALS.indexOf("clearCircle"), + }, +}; + +async function seedPopnChart() { + await DB.insertInto("song") + .values({ + id: TestingPopnSong.id, + legacy_id: 1, + game_group: "popn", + title: TestingPopnSong.title, + artist: TestingPopnSong.artist, + search_terms: TestingPopnSong.searchTerms, + alt_titles: TestingPopnSong.altTitles, + data: TestingPopnSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "popn", + song_id: TestingPopnSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertPopnScore(opts: { + scoreId: string; + sd: ScoreData<"popn">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("popn", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "popn", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("POPN_IMPL", () => { + describe("scoreDeriver", () => { + it("maps score to grade", () => { + const g = (score: number, expected: string) => + expect( + POPN_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never) + .grade, + ).toBe(expected); + + g(0, "E"); + g(50_000, "D"); + g(62_000, "C"); + g(72_000, "B"); + g(82_000, "A"); + g(90_000, "AA"); + g(95_000, "AAA"); + g(98_000, "S"); + }); + + it("derives lamp from clear medal", () => { + const lamp = ( + clearMedal: MongoProvidedMetrics["popn"]["clearMedal"], + expected: string, + ) => + expect( + POPN_IMPL.scoreDeriver( + dmf(baseMetrics, { clearMedal }) as never, + chart as never, + ).lamp, + ).toBe(expected); + + lamp("failedCircle", "FAILED"); + lamp("failedStar", "FAILED"); + lamp("failedDiamond", "FAILED"); + lamp("easyClear", "EASY CLEAR"); + lamp("clearCircle", "CLEAR"); + lamp("clearStar", "CLEAR"); + lamp("clearDiamond", "CLEAR"); + lamp("fullComboCircle", "FULL COMBO"); + lamp("fullComboStar", "FULL COMBO"); + lamp("fullComboDiamond", "FULL COMBO"); + lamp("perfect", "PERFECT"); + }); + }); + + describe("classDerivers", () => { + const cl = ( + v: number | null, + expected: ReturnType["class"], + ) => expect(POPN_IMPL.classDerivers({ naiveClassPoints: v }).class).toBe(expected); + + it("maps class points", () => { + cl(null, null); + cl(0, "KITTY"); + cl(21, "STUDENT"); + cl(34, "DELINQUENT"); + cl(46, "DETECTIVE"); + cl(59, "IDOL"); + cl(68, "GENERAL"); + cl(79, "HERMIT"); + cl(91, "GOD"); + cl(100, "GOD"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("popn", chart, scoreData); + + it("criteria", () => { + expect(POPN_IMPL.goalCriteriaFormatters.score(908_182)).toBe( + "Get a score of 908,182 on", + ); + }); + + it("progress", () => { + const f = ( + k: keyof typeof POPN_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + POPN_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "AAA", score: 95_342 }, GRADES.indexOf("S"), "S-2.7K"); + f("score", { score: 98_123 }, 100_000, "98,123"); + f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); + }); + + it("outOf", () => { + expect(POPN_IMPL.goalOutOfFormatters.score(901_003)).toBe("901,003"); + expect(POPN_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedPopnChart); + + it("joins best clear medal", async () => { + const { id: userId } = await seedUser({ username: "popn_pb" }); + const main = mkMockScore("popn", chart, scoreData); + + const medalSd: ScoreData<"popn"> = { + ...scoreData, + clearMedal: "perfect", + lamp: "PERFECT", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: LAMPS.indexOf("PERFECT"), + clearMedal: CLEAR_MEDALS.indexOf("perfect"), + }, + }; + + await insertPopnScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertPopnScore({ userId, scoreId: "bestClearMedal", sd: medalSd, timeMs: 2000 }); + + const pb = await CreatePBDoc("popn", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [ + { name: "Best Score" }, + { name: "Best Clear", scoreID: "bestClearMedal" }, + ], + scoreData: { + score: scoreData.score, + lamp: "PERFECT", + clearMedal: "perfect", + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/popn.ts b/typescript/server/src/game-implementations/games/popn.ts index 91bf6befb..2ae0e3d72 100644 --- a/typescript/server/src/game-implementations/games/popn.ts +++ b/typescript/server/src/game-implementations/games/popn.ts @@ -1,18 +1,18 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import type { GetEnumValue } from "tachi-common/types/metrics"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; -import { ProfileAvgBestN, ProfileSumBestN } from "#game-implementations/utils/profile-calc"; +import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "#game-implementations/utils/session-calc"; import { IsNullish } from "#utils/misc"; import { PopnClassPoints } from "rg-stats"; -import { FmtNum, FmtNumCompact, GetGrade, POPN_GBOUNDARIES } from "tachi-common"; +import { FmtNum, GetGrade, POPN_GBOUNDARIES } from "tachi-common"; import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; export function PopnClearMedalToLamp( - clearMedal: GetEnumValue<"popn:9B", "clearMedal">, -): GetEnumValue<"popn:9B", "lamp"> { + clearMedal: GetEnumValue<"popn", "clearMedal">, +): GetEnumValue<"popn", "lamp"> { switch (clearMedal) { case "perfect": return "PERFECT"; @@ -33,7 +33,7 @@ export function PopnClearMedalToLamp( } } -export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = { +export const POPN_IMPL: GameImplementation<"popn"> = { chartSpecificValidators: {}, scoreDeriver: (scoreData, _chart) => { const lamp = PopnClearMedalToLamp(scoreData.clearMedal); @@ -60,8 +60,8 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = { sessionCalcs: (arr) => ({ classPoints: SessionAvgBest10For("classPoints")(arr), }), - profileCalcs: async (game, playtype, userID) => ({ - naiveClassPoints: await ProfileAvgBestN("classPoints", 20)(game, playtype, userID), + profileCalcs: async (game, userID) => ({ + naiveClassPoints: await ProfileAvgBestN("classPoints", 20)(game, userID), }), classDerivers: (ratings) => { const points = ratings.naiveClassPoints; @@ -119,7 +119,7 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = { ), ], defaultMergeRefName: "Best Score", - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], scoreValidators: [ (s) => { const { bad, good } = s.scoreData.judgements; diff --git a/typescript/server/src/game-implementations/games/sdvx.oldtest.ts b/typescript/server/src/game-implementations/games/sdvx.oldtest.ts deleted file mode 100644 index e8049c5d1..000000000 --- a/typescript/server/src/game-implementations/games/sdvx.oldtest.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestSnapshot } from "#test-utils/single-process-snapshot"; -import { TestingSDVXAlbidaChart } from "#test-utils/test-data"; -import { type ProvidedMetrics, type ScoreData, SDVX_GRADES, SDVX_LAMPS } from "tachi-common"; -import t from "tap"; - -import { SDVX_IMPL } from "./sdvx"; - -const baseMetrics: ProvidedMetrics["sdvx:Single"] = { - lamp: "CLEAR", - score: 9_003_000, -}; - -const scoreData: ScoreData<"sdvx:Single"> = { - lamp: "CLEAR", - score: 9_700_000, - grade: "AAA", - judgements: {}, - optional: { enumIndexes: {}, exScore: 10 }, - enumIndexes: { - grade: SDVX_GRADES.AAA, - lamp: SDVX_LAMPS.CLEAR, - }, -}; - -const mockScore = mkMockScore("sdvx", "Single", TestingSDVXAlbidaChart, scoreData); -const mockPB = mkMockPB("sdvx", "Single", TestingSDVXAlbidaChart, scoreData); - -t.test("SDVX Implementation", (t) => { - t.test("EX Score Validator", (t) => { - t.equal(SDVX_IMPL.chartSpecificValidators.exScore(1000, TestingSDVXAlbidaChart), true); - t.equal(SDVX_IMPL.chartSpecificValidators.exScore(0, TestingSDVXAlbidaChart), true); - - TestSnapshot( - t, - SDVX_IMPL.chartSpecificValidators.exScore(-1, TestingSDVXAlbidaChart), - `EX Score Validator SDVX: negative`, - ); - - // not supported yet - t.skip("SDVX EXScore should be upperbounded on maximum EX Score"); - - t.end(); - }); - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: any) => - t.equal( - SDVX_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingSDVXAlbidaChart) - .grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "D"); - f(7_000_000, "C"); - f(8_000_000, "B"); - f(8_700_000, "A"); - f(9_000_000, "A+"); - f(9_300_000, "AA"); - f(9_500_000, "AA+"); - f(9_700_000, "AAA"); - f(9_800_000, "AAA+"); - f(9_900_000, "S"); - f(10_000_000, "PUC"); - - t.end(); - }); - - t.test("VF Class Deriver", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - SDVX_IMPL.classDerivers({ VF6: v }).vfClass, - expected, - `A profile VF6 of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "SIENNA_I"); - - f(23, "IMPERIAL_IV"); - f(22, "IMPERIAL_III"); - f(21, "IMPERIAL_II"); - f(20, "IMPERIAL_I"); - f(19.75, "CRIMSON_IV"); - f(19.5, "CRIMSON_III"); - f(19.25, "CRIMSON_II"); - f(19, "CRIMSON_I"); - f(18.75, "ELDORA_IV"); - f(18.5, "ELDORA_III"); - f(18.25, "ELDORA_II"); - f(18, "ELDORA_I"); - f(17.75, "ARGENTO_IV"); - f(17.5, "ARGENTO_III"); - f(17.25, "ARGENTO_II"); - f(17, "ARGENTO_I"); - f(16.75, "CORAL_IV"); - f(16.5, "CORAL_III"); - f(16.25, "CORAL_II"); - f(16, "CORAL_I"); - f(15.75, "SCARLET_IV"); - f(15.5, "SCARLET_III"); - f(15.25, "SCARLET_II"); - f(15, "SCARLET_I"); - f(14.75, "CYAN_IV"); - f(14.5, "CYAN_III"); - f(14.25, "CYAN_II"); - f(14, "CYAN_I"); - f(13.5, "DANDELION_IV"); - f(13, "DANDELION_III"); - f(12.5, "DANDELION_II"); - f(12, "DANDELION_I"); - f(11.5, "COBALT_IV"); - f(11, "COBALT_III"); - f(10.5, "COBALT_II"); - f(10, "COBALT_I"); - f(7.5, "SIENNA_IV"); - f(5, "SIENNA_III"); - f(2.5, "SIENNA_II"); - - t.end(); - }); - - t.todo("Score Calcs"); - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal(SDVX_IMPL.goalCriteriaFormatters.score(908_182), "Get a score of 908,182 on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof SDVX_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - SDVX_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "AAA+", score: 9_817_342 }, SDVX_GRADES.S, "S-83K"); - f("score", { score: 9_820_123 }, 1_000_000, "9,820,123"); - f("lamp", { lamp: "CLEAR" }, SDVX_LAMPS.CLEAR, "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(SDVX_IMPL.goalOutOfFormatters.score(901_003), "901,003"); - t.equal(SDVX_IMPL.goalOutOfFormatters.score(983_132), "983,132"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - score: 0, - lamp: "ULTIMATE CHAIN", - enumIndexes: { lamp: SDVX_LAMPS.ULTIMATE_CHAIN }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("sdvx:Single", 1, TestingSDVXAlbidaChart, log), { - composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], - scoreData: { - score: mockScore.scoreData.score, - lamp: "ULTIMATE CHAIN", - enumIndexes: { lamp: SDVX_LAMPS.ULTIMATE_CHAIN }, - }, - }); - - t.end(); - }); - - t.test("Should join best EX SCore", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - score: 0, - lamp: "ULTIMATE CHAIN", - enumIndexes: { lamp: SDVX_LAMPS.ULTIMATE_CHAIN }, - optional: { - exScore: 9000, - }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("sdvx:Single", 1, TestingSDVXAlbidaChart, log), { - composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], - scoreData: { - score: mockScore.scoreData.score, - lamp: "ULTIMATE CHAIN", - enumIndexes: { lamp: SDVX_LAMPS.ULTIMATE_CHAIN }, - optional: { - exScore: 9000, - }, - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/sdvx.test.ts b/typescript/server/src/game-implementations/games/sdvx.test.ts new file mode 100644 index 000000000..f51675648 --- /dev/null +++ b/typescript/server/src/game-implementations/games/sdvx.test.ts @@ -0,0 +1,311 @@ +import { SDVX_IMPL } from "#game-implementations/games/sdvx"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingSDVXAlbidaChart, TestingSDVXAlbidaSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { type MongoProvidedMetrics, type ScoreData, SDVX_GRADES, SDVX_LAMPS } from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const chart = TestingSDVXAlbidaChart; + +const baseMetrics: MongoProvidedMetrics["sdvx"] = { + lamp: "CLEAR", + score: 9_003_000, +}; + +const scoreData: ScoreData<"sdvx"> = { + lamp: "CLEAR", + score: 9_700_000, + grade: "AAA", + judgements: {}, + optional: { enumIndexes: {}, exScore: 10 }, + enumIndexes: { + grade: SDVX_GRADES.AAA, + lamp: SDVX_LAMPS.CLEAR, + }, +}; + +async function seedAlbidaChart() { + await DB.insertInto("song") + .values({ + id: TestingSDVXAlbidaSong.id, + legacy_id: 1, + game_group: "sdvx", + title: TestingSDVXAlbidaSong.title, + artist: TestingSDVXAlbidaSong.artist, + search_terms: TestingSDVXAlbidaSong.searchTerms, + alt_titles: TestingSDVXAlbidaSong.altTitles, + data: TestingSDVXAlbidaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "sdvx", + song_id: TestingSDVXAlbidaSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertSdvxScoreRow(opts: { + scoreData: ScoreData<"sdvx">; + scoreId: string; + timeAchievedMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("sdvx", opts.scoreData); + const t = UnixMillisecondsToISO8601(opts.timeAchievedMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "sdvx", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: t, + time_added: t, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("SDVX_IMPL", () => { + describe("chartSpecificValidators.exScore", () => { + it("accepts non-negative values", () => { + expect(SDVX_IMPL.chartSpecificValidators.exScore(1000, chart)).toBe(true); + expect(SDVX_IMPL.chartSpecificValidators.exScore(0, chart)).toBe(true); + }); + + it("rejects negative EX score", () => { + expect(SDVX_IMPL.chartSpecificValidators.exScore(-1, chart)).toBe( + "EX Score must be non-negative. Got -1", + ); + }); + + it.skip("SDVX EXScore should be upper bounded on maximum EX Score", () => { + // not supported yet (see sdvx.ts TODO) + }); + }); + + describe("scoreDeriver (grade)", () => { + const g = (score: number, expected: string) => + expect( + SDVX_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never).grade, + ).toBe(expected); + + it("maps score thresholds to letter grades", () => { + g(0, "D"); + g(7_000_000, "C"); + g(8_000_000, "B"); + g(8_700_000, "A"); + g(9_000_000, "A+"); + g(9_300_000, "AA"); + g(9_500_000, "AA+"); + g(9_700_000, "AAA"); + g(9_800_000, "AAA+"); + g(9_900_000, "S"); + g(10_000_000, "PUC"); + }); + }); + + describe("classDerivers (VF6 → vfClass)", () => { + const vf = ( + v: number | null, + expected: ReturnType["vfClass"], + ) => expect(SDVX_IMPL.classDerivers({ VF6: v }).vfClass).toBe(expected); + + it("maps VOLFORCE to VF class tiers", () => { + vf(null, null); + vf(0, "SIENNA_I"); + + vf(23, "IMPERIAL_IV"); + vf(22, "IMPERIAL_III"); + vf(21, "IMPERIAL_II"); + vf(20, "IMPERIAL_I"); + vf(19.75, "CRIMSON_IV"); + vf(19.5, "CRIMSON_III"); + vf(19.25, "CRIMSON_II"); + vf(19, "CRIMSON_I"); + vf(18.75, "ELDORA_IV"); + vf(18.5, "ELDORA_III"); + vf(18.25, "ELDORA_II"); + vf(18, "ELDORA_I"); + vf(17.75, "ARGENTO_IV"); + vf(17.5, "ARGENTO_III"); + vf(17.25, "ARGENTO_II"); + vf(17, "ARGENTO_I"); + vf(16.75, "CORAL_IV"); + vf(16.5, "CORAL_III"); + vf(16.25, "CORAL_II"); + vf(16, "CORAL_I"); + vf(15.75, "SCARLET_IV"); + vf(15.5, "SCARLET_III"); + vf(15.25, "SCARLET_II"); + vf(15, "SCARLET_I"); + vf(14.75, "CYAN_IV"); + vf(14.5, "CYAN_III"); + vf(14.25, "CYAN_II"); + vf(14, "CYAN_I"); + vf(13.5, "DANDELION_IV"); + vf(13, "DANDELION_III"); + vf(12.5, "DANDELION_II"); + vf(12, "DANDELION_I"); + vf(11.5, "COBALT_IV"); + vf(11, "COBALT_III"); + vf(10.5, "COBALT_II"); + vf(10, "COBALT_I"); + vf(7.5, "SIENNA_IV"); + vf(5, "SIENNA_III"); + vf(2.5, "SIENNA_II"); + }); + }); + + describe("goal formatters", () => { + const mockScore = mkMockScore("sdvx", chart, scoreData); + const mockPB = mkMockPB("sdvx", chart, scoreData); + + it("formats score criteria", () => { + expect(SDVX_IMPL.goalCriteriaFormatters.score(908_182)).toBe( + "Get a score of 908,182 on", + ); + }); + + it("formats progress for grade, score, and lamp", () => { + const f = ( + k: keyof typeof SDVX_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + SDVX_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { + scoreData: modifant, + }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "AAA+", score: 9_817_342 }, SDVX_GRADES.S, "S-83K"); + f("score", { score: 9_820_123 }, 1_000_000, "9,820,123"); + f("lamp", { lamp: "CLEAR" }, SDVX_LAMPS.CLEAR, "CLEAR"); + }); + + it("formats out-of score", () => { + expect(SDVX_IMPL.goalOutOfFormatters.score(901_003)).toBe("901,003"); + expect(SDVX_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedAlbidaChart); + + it("joins best lamp into the default-metric PB", async () => { + const { id: userId } = await seedUser({ username: "sdvx_pb_lamp" }); + const main = mkMockScore("sdvx", chart, scoreData); + + const lampScoreData: ScoreData<"sdvx"> = { + ...scoreData, + score: 0, + lamp: "ULTIMATE CHAIN", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: SDVX_LAMPS.ULTIMATE_CHAIN, + }, + }; + + await insertSdvxScoreRow({ + userId, + scoreId: main.scoreID, + scoreData, + timeAchievedMs: 1_000, + }); + + await insertSdvxScoreRow({ + userId, + scoreId: "bestLamp", + scoreData: lampScoreData, + timeAchievedMs: 2_000, + }); + + const pb = await CreatePBDoc("sdvx", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], + scoreData: { + score: scoreData.score, + lamp: "ULTIMATE CHAIN", + enumIndexes: { lamp: SDVX_LAMPS.ULTIMATE_CHAIN }, + }, + }); + }); + + it("joins best optional exScore from the lamp PB source", async () => { + const { id: userId } = await seedUser({ username: "sdvx_pb_ex" }); + const main = mkMockScore("sdvx", chart, scoreData); + + const lampScoreData: ScoreData<"sdvx"> = { + ...scoreData, + score: 0, + lamp: "ULTIMATE CHAIN", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: SDVX_LAMPS.ULTIMATE_CHAIN, + }, + optional: { + ...scoreData.optional, + exScore: 9000, + }, + }; + + await insertSdvxScoreRow({ + userId, + scoreId: main.scoreID, + scoreData, + timeAchievedMs: 1_000, + }); + + await insertSdvxScoreRow({ + userId, + scoreId: "bestLamp", + scoreData: lampScoreData, + timeAchievedMs: 2_000, + }); + + const pb = await CreatePBDoc("sdvx", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], + scoreData: { + score: scoreData.score, + lamp: "ULTIMATE CHAIN", + enumIndexes: { lamp: SDVX_LAMPS.ULTIMATE_CHAIN }, + optional: { + exScore: 9000, + }, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/sdvx.ts b/typescript/server/src/game-implementations/games/sdvx.ts index 5e93a85e6..8223cabd9 100644 --- a/typescript/server/src/game-implementations/games/sdvx.ts +++ b/typescript/server/src/game-implementations/games/sdvx.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; @@ -15,7 +15,7 @@ import { SDVXLIKE_SESSION_CALCS, } from "./_common"; -export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = { +export const SDVX_IMPL: GameImplementation<"sdvx"> = { scoreDeriver: SDVXLIKE_SCORE_DERIVER, scoreCalcs: SDVXLIKE_SCORE_CALCS, sessionCalcs: SDVXLIKE_SESSION_CALCS, @@ -66,5 +66,5 @@ export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = { ], defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME, scoreValidators: SDVXLIKE_SCORE_VALIDATORS, - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], }; diff --git a/typescript/server/src/game-implementations/games/usc.oldtest.ts b/typescript/server/src/game-implementations/games/usc.oldtest.ts deleted file mode 100644 index 9fb821923..000000000 --- a/typescript/server/src/game-implementations/games/usc.oldtest.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingUSCChart } from "#test-utils/test-data"; -import { - GetGPTString, - type GPTStrings, - type ProvidedMetrics, - type ScoreData, - SDVX_GRADES, - USC_LAMPS, -} from "tachi-common"; -import t from "tap"; - -import { USC_CONTROLLER_IMPL, USC_KEYBOARD_IMPL } from "./usc"; - -const baseMetrics: ProvidedMetrics[GPTStrings["usc"]] = { - lamp: "CLEAR", - score: 9_003_000, -}; - -const scoreData: ScoreData = { - lamp: "CLEAR", - score: 9_700_000, - grade: "AAA", - judgements: {}, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: SDVX_GRADES.AAA, - lamp: USC_LAMPS.CLEAR, - }, -}; - -for (const [playtype, impl] of [ - ["Controller", USC_CONTROLLER_IMPL], - ["Keyboard", USC_KEYBOARD_IMPL], -] as const) { - const mockScore = mkMockScore("usc", playtype, TestingUSCChart, scoreData); - const mockPB = mkMockPB("usc", playtype, TestingUSCChart, scoreData); - - t.test(`USC ${playtype} Implementation`, (t) => { - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: any) => - t.equal( - impl.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingUSCChart as any) - .grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "D"); - f(7_000_000, "C"); - f(8_000_000, "B"); - f(8_700_000, "A"); - f(9_000_000, "A+"); - f(9_300_000, "AA"); - f(9_500_000, "AA+"); - f(9_700_000, "AAA"); - f(9_800_000, "AAA+"); - f(9_900_000, "S"); - f(10_000_000, "PUC"); - - t.end(); - }); - t.test("VF Class Deriver", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - impl.classDerivers({ VF6: v }).vfClass, - expected, - `A profile VF6 of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "SIENNA_I"); - - f(23, "IMPERIAL_IV"); - f(22, "IMPERIAL_III"); - f(21, "IMPERIAL_II"); - f(20, "IMPERIAL_I"); - f(19.75, "CRIMSON_IV"); - f(19.5, "CRIMSON_III"); - f(19.25, "CRIMSON_II"); - f(19, "CRIMSON_I"); - f(18.75, "ELDORA_IV"); - f(18.5, "ELDORA_III"); - f(18.25, "ELDORA_II"); - f(18, "ELDORA_I"); - f(17.75, "ARGENTO_IV"); - f(17.5, "ARGENTO_III"); - f(17.25, "ARGENTO_II"); - f(17, "ARGENTO_I"); - f(16.75, "CORAL_IV"); - f(16.5, "CORAL_III"); - f(16.25, "CORAL_II"); - f(16, "CORAL_I"); - f(15.75, "SCARLET_IV"); - f(15.5, "SCARLET_III"); - f(15.25, "SCARLET_II"); - f(15, "SCARLET_I"); - f(14.75, "CYAN_IV"); - f(14.5, "CYAN_III"); - f(14.25, "CYAN_II"); - f(14, "CYAN_I"); - f(13.5, "DANDELION_IV"); - f(13, "DANDELION_III"); - f(12.5, "DANDELION_II"); - f(12, "DANDELION_I"); - f(11.5, "COBALT_IV"); - f(11, "COBALT_III"); - f(10.5, "COBALT_II"); - f(10, "COBALT_I"); - f(7.5, "SIENNA_IV"); - f(5, "SIENNA_III"); - f(2.5, "SIENNA_II"); - - t.end(); - }); - - t.todo("Score Calcs"); - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal(impl.goalCriteriaFormatters.score(908_182), "Get a score of 908,182 on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof impl.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - impl.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "AAA+", score: 9_817_342 }, SDVX_GRADES.S, "S-83K"); - f("score", { score: 9_820_123 }, 1_000_000, "9,820,123"); - f("lamp", { lamp: "CLEAR" }, USC_LAMPS.CLEAR, "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(impl.goalOutOfFormatters.score(901_003), "901,003"); - t.equal(impl.goalOutOfFormatters.score(983_132), "983,132"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - score: 0, - lamp: "ULTIMATE CHAIN", - enumIndexes: { lamp: USC_LAMPS.ULTIMATE_CHAIN }, - }, - }), - ); - - t.hasStrict( - await CreatePBDoc(GetGPTString("usc", playtype), 1, TestingUSCChart, log), - { - composedFrom: [ - { name: "Best Score" }, - { name: "Best Lamp", scoreID: "bestLamp" }, - ], - scoreData: { - score: mockScore.scoreData.score, - lamp: "ULTIMATE CHAIN", - enumIndexes: { lamp: USC_LAMPS.ULTIMATE_CHAIN }, - }, - }, - ); - - t.end(); - }); - - t.end(); - }); - - t.end(); - }); -} diff --git a/typescript/server/src/game-implementations/games/usc.test.ts b/typescript/server/src/game-implementations/games/usc.test.ts new file mode 100644 index 000000000..0cc98856a --- /dev/null +++ b/typescript/server/src/game-implementations/games/usc.test.ts @@ -0,0 +1,258 @@ +import { USC_CONTROLLER_IMPL, USC_KEYBOARD_IMPL } from "#game-implementations/games/usc"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingUSCChart, TestingUSCSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { type MongoProvidedMetrics, type ScoreData, SDVX_GRADES, USC_LAMPS } from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +const baseMetrics: MongoProvidedMetrics["usc-controller" | "usc-keyboard"] = { + lamp: "CLEAR", + score: 9_003_000, +}; + +const scoreData: ScoreData<"usc-controller" | "usc-keyboard"> = { + lamp: "CLEAR", + score: 9_700_000, + grade: "AAA", + judgements: {}, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: SDVX_GRADES.AAA, + lamp: USC_LAMPS.CLEAR, + }, +}; + +async function seedUscSongAndChart(game: "usc-controller" | "usc-keyboard") { + const chart = dmf(TestingUSCChart, { game } as never); + + await DB.insertInto("song") + .values({ + id: TestingUSCSong.id, + legacy_id: 99_001, + game_group: "usc", + title: TestingUSCSong.title, + artist: TestingUSCSong.artist, + search_terms: TestingUSCSong.searchTerms, + alt_titles: TestingUSCSong.altTitles, + data: TestingUSCSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game, + song_id: TestingUSCSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); + + return chart; +} + +async function insertUscScoreRow(opts: { + chartId: string; + game: "usc-controller" | "usc-keyboard"; + scoreData: ScoreData<"usc-controller" | "usc-keyboard">; + scoreId: string; + timeAchievedMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg(opts.game, opts.scoreData); + const t = UnixMillisecondsToISO8601(opts.timeAchievedMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: opts.chartId, + game: opts.game, + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: t, + time_added: t, + highlight: false, + comment: null, + }) + .execute(); +} + +describe.each([ + ["Controller", USC_CONTROLLER_IMPL, "usc-controller"] as const, + ["Keyboard", USC_KEYBOARD_IMPL, "usc-keyboard"] as const, +])("USC %s implementation", (_playtypeName, impl, game) => { + const chart = dmf(TestingUSCChart, { game } as never); + + describe("scoreDeriver (grade)", () => { + const g = (score: number, expected: string) => + expect( + impl.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never).grade, + ).toBe(expected); + + it("maps score thresholds to letter grades", () => { + g(0, "D"); + g(7_000_000, "C"); + g(8_000_000, "B"); + g(8_700_000, "A"); + g(9_000_000, "A+"); + g(9_300_000, "AA"); + g(9_500_000, "AA+"); + g(9_700_000, "AAA"); + g(9_800_000, "AAA+"); + g(9_900_000, "S"); + g(10_000_000, "PUC"); + }); + }); + + describe("classDerivers (VF6 → vfClass)", () => { + const vf = (v: number | null, expected: ReturnType["vfClass"]) => + expect(impl.classDerivers({ VF6: v }).vfClass).toBe(expected); + + it("maps VOLFORCE to VF class tiers", () => { + vf(null, null); + vf(0, "SIENNA_I"); + + vf(23, "IMPERIAL_IV"); + vf(22, "IMPERIAL_III"); + vf(21, "IMPERIAL_II"); + vf(20, "IMPERIAL_I"); + vf(19.75, "CRIMSON_IV"); + vf(19.5, "CRIMSON_III"); + vf(19.25, "CRIMSON_II"); + vf(19, "CRIMSON_I"); + vf(18.75, "ELDORA_IV"); + vf(18.5, "ELDORA_III"); + vf(18.25, "ELDORA_II"); + vf(18, "ELDORA_I"); + vf(17.75, "ARGENTO_IV"); + vf(17.5, "ARGENTO_III"); + vf(17.25, "ARGENTO_II"); + vf(17, "ARGENTO_I"); + vf(16.75, "CORAL_IV"); + vf(16.5, "CORAL_III"); + vf(16.25, "CORAL_II"); + vf(16, "CORAL_I"); + vf(15.75, "SCARLET_IV"); + vf(15.5, "SCARLET_III"); + vf(15.25, "SCARLET_II"); + vf(15, "SCARLET_I"); + vf(14.75, "CYAN_IV"); + vf(14.5, "CYAN_III"); + vf(14.25, "CYAN_II"); + vf(14, "CYAN_I"); + vf(13.5, "DANDELION_IV"); + vf(13, "DANDELION_III"); + vf(12.5, "DANDELION_II"); + vf(12, "DANDELION_I"); + vf(11.5, "COBALT_IV"); + vf(11, "COBALT_III"); + vf(10.5, "COBALT_II"); + vf(10, "COBALT_I"); + vf(7.5, "SIENNA_IV"); + vf(5, "SIENNA_III"); + vf(2.5, "SIENNA_II"); + }); + }); + + describe("goal formatters", () => { + const mockScore = mkMockScore(game, chart, scoreData); + const mockPB = mkMockPB(game, chart, scoreData); + + it("formats score criteria", () => { + expect(impl.goalCriteriaFormatters.score(908_182)).toBe("Get a score of 908,182 on"); + }); + + it("formats progress for grade, score, and lamp", () => { + const f = ( + k: keyof typeof impl.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + impl.goalProgressFormatters[k]( + dmf(mockPB, { + scoreData: modifant, + }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "AAA+", score: 9_817_342 }, SDVX_GRADES.S, "S-83K"); + f("score", { score: 9_820_123 }, 1_000_000, "9,820,123"); + f("lamp", { lamp: "CLEAR" }, USC_LAMPS.CLEAR, "CLEAR"); + }); + + it("formats out-of score", () => { + expect(impl.goalOutOfFormatters.score(901_003)).toBe("901,003"); + expect(impl.goalOutOfFormatters.score(983_132)).toBe("983,132"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(async () => { + await seedUscSongAndChart(game); + }); + + it("joins best lamp into the default-metric PB", async () => { + const { id: userId } = await seedUser({ username: `usc_pb_${game}` }); + + const main = mkMockScore(game, chart, scoreData); + const lampScoreData: ScoreData = { + ...scoreData, + score: 0, + lamp: "ULTIMATE CHAIN", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: USC_LAMPS.ULTIMATE_CHAIN, + }, + }; + + await insertUscScoreRow({ + userId, + game, + chartId: chart.chartID, + scoreId: main.scoreID, + scoreData, + timeAchievedMs: 1_000, + }); + + await insertUscScoreRow({ + userId, + game, + chartId: chart.chartID, + scoreId: "bestLamp", + scoreData: lampScoreData, + timeAchievedMs: 2_000, + }); + + const pb = await CreatePBDoc(game, userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], + scoreData: { + score: scoreData.score, + lamp: "ULTIMATE CHAIN", + enumIndexes: { lamp: USC_LAMPS.ULTIMATE_CHAIN }, + }, + }); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/usc.ts b/typescript/server/src/game-implementations/games/usc.ts index 60f9538f4..8566ace40 100644 --- a/typescript/server/src/game-implementations/games/usc.ts +++ b/typescript/server/src/game-implementations/games/usc.ts @@ -1,5 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; -import type { GPTStrings } from "tachi-common"; +import type { GameImplementation } from "#game-implementations/types"; import { SDVXLIKE_CLASS_DERIVERS, @@ -15,7 +14,7 @@ import { SDVXLIKE_SESSION_CALCS, } from "./_common"; -const USC_IMPL: GPTServerImplementation = { +const USC_IMPL: GameImplementation<"usc-controller" | "usc-keyboard"> = { scoreDeriver: SDVXLIKE_SCORE_DERIVER, scoreCalcs: SDVXLIKE_SCORE_CALCS, sessionCalcs: SDVXLIKE_SESSION_CALCS, @@ -36,9 +35,9 @@ const USC_IMPL: GPTServerImplementation = { pbMergeFunctions: SDVXLIKE_PB_MERGERS, defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME, scoreValidators: SDVXLIKE_SCORE_VALIDATORS, - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], }; -export const USC_KEYBOARD_IMPL: GPTServerImplementation<"usc:Keyboard"> = USC_IMPL; +export const USC_KEYBOARD_IMPL: GameImplementation<"usc-keyboard"> = USC_IMPL; -export const USC_CONTROLLER_IMPL: GPTServerImplementation<"usc:Controller"> = USC_IMPL; +export const USC_CONTROLLER_IMPL: GameImplementation<"usc-controller"> = USC_IMPL; diff --git a/typescript/server/src/game-implementations/games/wacca.oldtest.ts b/typescript/server/src/game-implementations/games/wacca.oldtest.ts deleted file mode 100644 index ebf50574e..000000000 --- a/typescript/server/src/game-implementations/games/wacca.oldtest.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { DeepPartial } from "#utils/types"; -import type { ProvidedMetrics, ScoreData, ScoreDocument } from "tachi-common"; - -import { log } from "#lib/log/log.js"; -import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestSnapshot } from "#test-utils/single-process-snapshot"; -import { TestingWaccaPupaExp } from "#test-utils/test-data"; -import { WACCA_SINGLE_CONF } from "tachi-common/config/game-support/wacca"; -import t from "tap"; - -import { RunValidators } from "./_common"; -import { WACCA_IMPL } from "./wacca"; - -const GRADES = WACCA_SINGLE_CONF.derivedMetrics.grade.values; -const LAMPS = WACCA_SINGLE_CONF.providedMetrics.lamp.values; - -const baseMetrics: ProvidedMetrics["wacca:Single"] = { - lamp: "CLEAR", - score: 953_000, -}; - -const scoreData: ScoreData<"wacca:Single"> = { - lamp: "CLEAR", - score: 953_000, - grade: "SS", - judgements: {}, - optional: { enumIndexes: {} }, - enumIndexes: { - grade: GRADES.indexOf("SS"), - lamp: LAMPS.indexOf("CLEAR"), - }, -}; - -const mockScore = mkMockScore("wacca", "Single", TestingWaccaPupaExp, scoreData); -const mockPB = mkMockPB("wacca", "Single", TestingWaccaPupaExp, scoreData); - -t.test("WACCA Implementation", (t) => { - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: any) => - t.equal( - WACCA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingWaccaPupaExp) - .grade, - expected, - `A score of ${score.toLocaleString()} should result in grade=${expected}.`, - ); - - f(0, "D"); - f(1, "C"); // LOL - f(300_100, "B"); - f(700_000, "A"); - f(800_000, "AA"); - f(850_000, "AAA"); - f(900_000, "S"); - f(930_000, "S+"); - f(950_000, "SS"); - f(970_000, "SS+"); - f(980_000, "SSS"); - f(990_000, "SSS+"); - f(1_000_000, "MASTER"); - - t.end(); - }); - - t.test("Rating Calc", (t) => { - t.equal( - WACCA_IMPL.scoreCalcs( - scoreData, - WACCA_IMPL.scoreDeriver(scoreData, TestingWaccaPupaExp), - TestingWaccaPupaExp, - ).rate, - 41.1, - "Basic rating check", - ); - - t.end(); - }); - - t.todo("Session Calcs"); - t.todo("Profile Calcs"); - - t.test("Colour Deriver", (t) => { - const f = (v: number | null, expected: any) => - t.equal( - WACCA_IMPL.classDerivers({ naiveRate: v }).colour, - expected, - `A rating of ${v} should result in ${expected}.`, - ); - - f(null, null); - f(0, "ASH"); - - f(2500, "RAINBOW"); - f(2200, "GOLD"); - f(1900, "SILVER"); - f(1600, "BLUE"); - f(1300, "PURPLE"); - f(1000, "RED"); - f(600, "YELLOW"); - f(300, "NAVY"); - - t.end(); - }); - - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { - t.equal(WACCA_IMPL.goalCriteriaFormatters.score(908_182), "Get a score of 908,182 on"); - - t.end(); - }); - - t.test("Progress", (t) => { - const f = ( - k: keyof typeof WACCA_IMPL.goalProgressFormatters, - modifant: Partial>, - goalValue: any, - expected: any, - ) => - t.equal( - WACCA_IMPL.goalProgressFormatters[k]( - dmf(mockPB, { - scoreData: modifant, - }), - goalValue, - ), - expected, - ); - - f("grade", { grade: "S", score: 917_342 }, GRADES.indexOf("SS"), "(S+)-13K"); - f("score", { score: 982_123 }, 1_000_000, "982,123"); - f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); - - t.end(); - }); - - t.test("Out Of", (t) => { - t.equal(WACCA_IMPL.goalOutOfFormatters.score(901_003), "901,003"); - t.equal(WACCA_IMPL.goalOutOfFormatters.score(983_132), "983,132"); - - t.end(); - }); - - t.end(); - }); - - t.test("PB Merging", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should join best lamp", async (t) => { - await MONGODB_KILL.scores.insert(mockScore); - await MONGODB_KILL.scores.insert( - dmf(mockScore, { - scoreID: "bestLamp", - scoreData: { - score: 0, - lamp: "FULL COMBO", - enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") }, - }, - }), - ); - - t.hasStrict(await CreatePBDoc("wacca:Single", 1, TestingWaccaPupaExp, log), { - composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], - scoreData: { - score: mockScore.scoreData.score, - lamp: "FULL COMBO", - }, - }); - - t.end(); - }); - - t.end(); - }); - - t.test("Score Validations", (t) => { - const f = (s: DeepPartial>) => - RunValidators(WACCA_IMPL.scoreValidators, dmf(mockScore, s), TestingWaccaPupaExp); - - t.strictSame(f({ scoreData: { lamp: "ALL MARVELOUS", score: 1_000_000 } }), undefined); - t.strictSame(f({ scoreData: { lamp: "FULL COMBO", judgements: { miss: 0 } } }), undefined); - - TestSnapshot( - t, - f({ scoreData: { lamp: "ALL MARVELOUS", score: 999_999 } }), - "WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect score.", - ); - TestSnapshot( - t, - f({ scoreData: { lamp: "FULL COMBO", judgements: { miss: 1 } } }), - `WACCA IMPL Validators: Should disallow FC with miss`, - ); - - TestSnapshot( - t, - f({ scoreData: { lamp: "ALL MARVELOUS", judgements: { good: 1 } } }), - "WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect good judgements.", - ); - TestSnapshot( - t, - f({ scoreData: { lamp: "ALL MARVELOUS", judgements: { great: 1 } } }), - "WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect good judgements.", - ); - TestSnapshot( - t, - f({ scoreData: { lamp: "ALL MARVELOUS", judgements: { miss: 1 } } }), - "WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect good judgements.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/game-implementations/games/wacca.test.ts b/typescript/server/src/game-implementations/games/wacca.test.ts new file mode 100644 index 000000000..49f31c6a9 --- /dev/null +++ b/typescript/server/src/game-implementations/games/wacca.test.ts @@ -0,0 +1,289 @@ +import type { DeepPartial } from "#utils/types"; + +import { RunValidators } from "#game-implementations/games/_common"; +import { WACCA_IMPL } from "#game-implementations/games/wacca"; +import { log } from "#lib/log/log"; +import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingWaccaPupaExp, TestingWaccaPupaSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { + GetGameConfig, + type MongoProvidedMetrics, + type ScoreData, + type ScoreDocument, +} from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +function enumMetricValues(m: { + type: string; + values?: ReadonlyArray; +}): ReadonlyArray { + if (m.type !== "ENUM" || m.values === undefined) { + throw new Error("expected ENUM metric with values"); + } + return m.values; +} + +const chart = TestingWaccaPupaExp; + +const WACCA_CONF = GetGameConfig("wacca"); +const GRADES = enumMetricValues(WACCA_CONF.derivedMetrics.grade); +const LAMPS = enumMetricValues(WACCA_CONF.providedMetrics.lamp); + +const baseMetrics: MongoProvidedMetrics["wacca"] = { + lamp: "CLEAR", + score: 953_000, +}; + +const scoreData: ScoreData<"wacca"> = { + lamp: "CLEAR", + score: 953_000, + grade: "SS", + judgements: {}, + optional: { enumIndexes: {} }, + enumIndexes: { + grade: GRADES.indexOf("SS"), + lamp: LAMPS.indexOf("CLEAR"), + }, +}; + +async function seedWaccaChart() { + await DB.insertInto("song") + .values({ + id: TestingWaccaPupaSong.id, + legacy_id: 77, + game_group: "wacca", + title: TestingWaccaPupaSong.title, + artist: TestingWaccaPupaSong.artist, + search_terms: TestingWaccaPupaSong.searchTerms, + alt_titles: TestingWaccaPupaSong.altTitles, + data: TestingWaccaPupaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "wacca", + song_id: TestingWaccaPupaSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertWaccaScore(opts: { + scoreId: string; + sd: ScoreData<"wacca">; + timeMs: number; + userId: number; +}) { + const { data, derived, judgements } = mongoScoreDataToPg("wacca", opts.sd); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: chart.chartID, + game: "wacca", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("WACCA_IMPL", () => { + describe("scoreDeriver (grade)", () => { + const g = (score: number, expected: string) => + expect( + WACCA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never).grade, + ).toBe(expected); + + it("maps score to grade", () => { + g(0, "D"); + g(1, "C"); + g(300_100, "B"); + g(700_000, "A"); + g(800_000, "AA"); + g(850_000, "AAA"); + g(900_000, "S"); + g(930_000, "S+"); + g(950_000, "SS"); + g(970_000, "SS+"); + g(980_000, "SSS"); + g(990_000, "SSS+"); + g(1_000_000, "MASTER"); + }); + }); + + it("scoreCalcs rate", () => { + expect( + WACCA_IMPL.scoreCalcs(scoreData, WACCA_IMPL.scoreDeriver(scoreData, chart), chart).rate, + ).toBe(41.1); + }); + + describe("classDerivers", () => { + const c = ( + v: number | null, + expected: ReturnType["colour"], + ) => expect(WACCA_IMPL.classDerivers({ naiveRate: v }).colour).toBe(expected); + + it("maps rate to colour", () => { + c(null, null); + c(0, "ASH"); + c(2500, "RAINBOW"); + c(2200, "GOLD"); + c(1900, "SILVER"); + c(1600, "BLUE"); + c(1300, "PURPLE"); + c(1000, "RED"); + c(600, "YELLOW"); + c(300, "NAVY"); + }); + }); + + describe("goal formatters", () => { + const mockPB = mkMockPB("wacca", chart, scoreData); + + it("criteria", () => { + expect(WACCA_IMPL.goalCriteriaFormatters.score(908_182)).toBe( + "Get a score of 908,182 on", + ); + }); + + it("progress", () => { + const f = ( + k: keyof typeof WACCA_IMPL.goalProgressFormatters, + modifant: Partial>, + goalValue: number, + expected: string, + ) => + expect( + WACCA_IMPL.goalProgressFormatters[k]( + dmf(mockPB, { scoreData: modifant }) as never, + goalValue, + ), + ).toBe(expected); + + f("grade", { grade: "S", score: 917_342 }, GRADES.indexOf("SS"), "(S+)-13K"); + f("score", { score: 982_123 }, 1_000_000, "982,123"); + f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR"); + }); + + it("outOf", () => { + expect(WACCA_IMPL.goalOutOfFormatters.score(901_003)).toBe("901,003"); + expect(WACCA_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132"); + }); + }); + + describe("PB merging (CreatePBDoc)", () => { + beforeEach(seedWaccaChart); + + it("joins best lamp", async () => { + const { id: userId } = await seedUser({ username: "wacca_pb" }); + const main = mkMockScore("wacca", chart, scoreData); + + const lampSd: ScoreData<"wacca"> = { + ...scoreData, + score: 0, + lamp: "FULL COMBO", + enumIndexes: { + ...scoreData.enumIndexes, + lamp: LAMPS.indexOf("FULL COMBO"), + }, + }; + + await insertWaccaScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 }); + await insertWaccaScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 }); + + const pb = await CreatePBDoc("wacca", userId, chart, log); + + expect(pb).toMatchObject({ + composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }], + scoreData: { + score: scoreData.score, + lamp: "FULL COMBO", + }, + }); + }); + }); + + describe("scoreValidators", () => { + const mockScore = mkMockScore("wacca", chart, scoreData); + + const run = (s: DeepPartial>) => + RunValidators(WACCA_IMPL.scoreValidators, dmf(mockScore, s) as never, chart as never); + + it("accepts valid scores", () => { + expect(run({ scoreData: { lamp: "ALL MARVELOUS", score: 1_000_000 } })).toBeUndefined(); + expect( + run({ scoreData: { lamp: "FULL COMBO", judgements: { miss: 0 } } }), + ).toBeUndefined(); + }); + + it("rejects inconsistent lamps and judgements", () => { + expect(run({ scoreData: { lamp: "ALL MARVELOUS", score: 999_999 } })).toEqual([ + "ALL MARVELOUS scores must have a perfect score. Got 999999 instead.", + ]); + + expect( + run({ + scoreData: { + lamp: "FULL COMBO", + judgements: { miss: 1 }, + }, + }), + ).toEqual(["Cannot have a FULL COMBO with misses."]); + + expect( + run({ + scoreData: { + lamp: "ALL MARVELOUS", + score: 1_000_000, + judgements: { good: 1 }, + }, + }), + ).toEqual(["Cannot have an ALL MARVELOUS if all judgements were not marvelous."]); + + expect( + run({ + scoreData: { + lamp: "ALL MARVELOUS", + score: 1_000_000, + judgements: { great: 1 }, + }, + }), + ).toEqual(["Cannot have an ALL MARVELOUS if all judgements were not marvelous."]); + + expect( + run({ + scoreData: { + lamp: "ALL MARVELOUS", + score: 1_000_000, + judgements: { miss: 1 }, + }, + }), + ).toEqual(["Cannot have an ALL MARVELOUS if all judgements were not marvelous."]); + }); + }); +}); diff --git a/typescript/server/src/game-implementations/games/wacca.ts b/typescript/server/src/game-implementations/games/wacca.ts index f526da113..693779919 100644 --- a/typescript/server/src/game-implementations/games/wacca.ts +++ b/typescript/server/src/game-implementations/games/wacca.ts @@ -1,4 +1,4 @@ -import type { GPTServerImplementation } from "#game-implementations/types"; +import type { GameImplementation } from "#game-implementations/types"; import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "#game-implementations/utils/profile-calc"; @@ -9,7 +9,7 @@ import { FmtNum, GetGrade, WACCA_GBOUNDARIES } from "tachi-common"; import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; -export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = { +export const WACCA_IMPL: GameImplementation<"wacca"> = { chartSpecificValidators: {}, scoreDeriver: (scoreData, _chart) => ({ grade: GetGrade(WACCA_GBOUNDARIES, scoreData.score), @@ -28,8 +28,8 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = { sessionCalcs: (arr) => ({ rate: SessionAvgBest10For("rate")(arr), }), - profileCalcs: async (game, playtype, userID) => ({ - naiveRate: await ProfileSumBestN("rate", 50)(game, playtype, userID), + profileCalcs: async (game, userID) => ({ + naiveRate: await ProfileSumBestN("rate", 50)(game, userID), }), classDerivers: (ratings) => { const rate = ratings.naiveRate; @@ -86,7 +86,7 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = { ), ], defaultMergeRefName: "Best Score", - derivationRelevantFields: ["levelNum"], + chartDataRelevantFields: ["levelNum"], scoreValidators: [ (s) => { diff --git a/typescript/server/src/game-implementations/types.ts b/typescript/server/src/game-implementations/types.ts index 64f9e1ebb..459f6500e 100644 --- a/typescript/server/src/game-implementations/types.ts +++ b/typescript/server/src/game-implementations/types.ts @@ -1,21 +1,19 @@ -import type { MONGO_PBScoreDocumentNoRank } from "#lib/score-import/framework/pb/create-pb-doc"; +import type { PBScoreDocumentNoRank } from "#lib/score-import/framework/pb/create-pb-doc"; import type { + ChartDocument, ClassConfigs, ConfScoreMetrics, - GPTString, - GPTStringToGame, - GPTStringToPlaytype, integer, - MONGO_ChartDocument, - MONGO_PBScoreDocument, - MONGO_ScoreData, - MONGO_ScoreDocument, - MONGO_SpecificUserGameStats, MongoDerivedMetrics, PBReference, + PBScoreDocument, ProfileRatingAlgorithms, + ScoreData, + ScoreDocument, ScoreRatingAlgorithms, SessionRatingAlgorithms, + SpecificUserGameStats, + V3Game, } from "tachi-common"; import type { DerivedClassConfig } from "tachi-common/types/game-config-utils"; import type { AllConfMetrics, ConfEnumScoreMetric } from "tachi-common/types/metrics"; @@ -24,21 +22,21 @@ import type { AllConfMetrics, ConfEnumScoreMetric } from "tachi-common/types/met * Validate this chart-specific metric. This should return a string representing an * error message on failure, and null on success. */ -export type ChartSpecificMetricValidator = ( +export type ChartSpecificMetricValidator = ( metric: number, - chart: MONGO_ChartDocument, + chart: ChartDocument, ) => string | true; interface ChartDependentMax { chartDependentMax: true; } -export type SessionCalculator = ( - scoreCalcData: Array["calculatedData"]>, +export type SessionCalculator = ( + scoreCalcData: Array["calculatedData"]>, ) => number | null; -export type ClassDeriver = ( - profileRatings: MONGO_SpecificUserGameStats["ratings"], +export type ClassDeriver = ( + profileRatings: SpecificUserGameStats["ratings"], ) => V | null | undefined; // absolutely stupid magic. @@ -50,11 +48,11 @@ export type ClassDeriver = ( // { // colour: ClassDeriver<"YELLOW" | "asdf" ...> // } -export type GPTClassDeriverFuncs = { - [C in keyof ClassConfigs[GPT] as ClassConfigs[GPT][C] extends DerivedClassConfig +export type GPTClassDeriverFuncs = { + [C in keyof ClassConfigs[TGame] as ClassConfigs[TGame][C] extends DerivedClassConfig ? C - : never]: ClassConfigs[GPT][C] extends DerivedClassConfig - ? ClassDeriver + : never]: ClassConfigs[TGame][C] extends DerivedClassConfig + ? ClassDeriver : never; }; @@ -68,51 +66,50 @@ export type GPTClassDeriverFuncs = { * They should then return some information (a name and a scoreID) to indicate * what this PB is composed of. */ -export type PBMergeFunction = ( +export type PBMergeFunction = ( userID: integer, chartID: string, asOfTimestamp: number | null, - existingPB: MONGO_PBScoreDocumentNoRank, + existingPB: PBScoreDocumentNoRank, ) => Promise; /** * The only metrics that need validators are those that have `chartDependentMax` set. * Otherwise, a validator is built into the ConfScoreMetric. */ -export type GPTChartSpecificMetricValidators = { - [M in keyof AllConfMetrics[GPT] as AllConfMetrics[GPT][M] extends ChartDependentMax +export type GPTChartSpecificMetricValidators = { + [M in keyof AllConfMetrics[TGame] as AllConfMetrics[TGame][M] extends ChartDependentMax ? M - : never]: ChartSpecificMetricValidator; + : never]: ChartSpecificMetricValidator; }; /** Derives chart-dependent score metrics (grade, percent, …) from provided score data. */ -export type GPTScoreDeriver = ( - scoreData: MONGO_ScoreData, - chart: MONGO_ChartDocument, -) => MongoDerivedMetrics[GPT]; +export type GPTScoreDeriver = ( + scoreData: ScoreData, + chart: ChartDocument, +) => MongoDerivedMetrics[TGame]; -export type GPTScoreCalcs = ( - scoreData: MONGO_ScoreData, - derivedData: MongoDerivedMetrics[GPT], - chart: MONGO_ChartDocument, -) => Record; +export type GPTScoreCalcs = ( + scoreData: ScoreData, + derivedData: MongoDerivedMetrics[TGame], + chart: ChartDocument, +) => Record; /** Session ratings from the session's score calculated-data: f(scoreCalcData) -> sessionCalcData. */ -export type GPTSessionCalcs = ( - scoreCalcData: Array["calculatedData"]>, -) => Record; +export type GPTSessionCalcs = ( + scoreCalcData: Array["calculatedData"]>, +) => Record; -/** Profile ratings from UGPT: async f(game, playtype, userID) -> profile ratings record. */ -export type GPTProfileCalcs = ( - game: GPTStringToGame[GPT], - playtype: GPTStringToPlaytype[GPT], +/** Profile ratings for a v3 `game`: async f(game, userID) -> profile ratings record. */ +export type GPTProfileCalcs = ( + game: TGame, userID: integer, -) => Promise>; +) => Promise>; // Class deriver: f(profileRatings) -> derivedClasses (one object with all derived class values). -export type GPTClassDerivers = ( - profileRatings: MONGO_SpecificUserGameStats["ratings"], -) => { [C in keyof GPTClassDeriverFuncs]: ReturnType[C]> }; +export type GPTClassDerivers = ( + profileRatings: SpecificUserGameStats["ratings"], +) => { [C in keyof GPTClassDeriverFuncs]: ReturnType[C]> }; /** * The float values used to rank this PB against others on the same chart. @@ -134,8 +131,8 @@ export interface RankingValues { * Given a fully-merged (but not yet stored) PB, return the ranking values * that determine how this PB is ordered against other PBs on the same chart. */ -export type PBRankingValuesFunction = ( - pb: MONGO_PBScoreDocumentNoRank, +export type PBRankingValuesFunction = ( + pb: PBScoreDocumentNoRank, ) => RankingValues; /** @@ -149,8 +146,8 @@ export type GoalCriteriaFormatter = (num: number) => string; * A record of all non-enum metrics that need formatters. Enums *always* get formatted * into their string formats. */ -export type GPTGoalFormatters = { - [K in keyof ConfScoreMetrics[GPT] as ConfScoreMetrics[GPT][K] extends ConfEnumScoreMetric< +export type GPTGoalFormatters = { + [K in keyof ConfScoreMetrics[TGame] as ConfScoreMetrics[TGame][K] extends ConfEnumScoreMetric< infer _ > ? never @@ -163,56 +160,56 @@ export type GPTGoalFormatters = { * * This only applies to "single" goals, i.e. goals on a single chart. */ -export type GoalProgressFormatter = ( - pb: MONGO_PBScoreDocument, +export type GoalProgressFormatter = ( + pb: PBScoreDocument, goalValue: integer, ) => string; -export type GPTGoalProgressFormatters = { - [K in keyof ConfScoreMetrics[GPT]]: GoalProgressFormatter; +export type GPTGoalProgressFormatters = { + [K in keyof ConfScoreMetrics[TGame]]: GoalProgressFormatter; }; /** * Return nothing on success, and a string * indicating what the error was on failure. */ -export type ScoreValidator = ( - score: MONGO_ScoreDocument, - chart: MONGO_ChartDocument, +export type ScoreValidator = ( + score: ScoreDocument, + chart: ChartDocument, ) => string | undefined; -export interface GPTServerImplementation { +export interface GameImplementation { /** * For any chart-dependent metrics, such as EX Score for IIDX, how should we * validate they're correct? */ - chartSpecificValidators: GPTChartSpecificMetricValidators; + chartSpecificValidators: GPTChartSpecificMetricValidators; /** * How should we derive the derived metrics for this game? */ - scoreDeriver: GPTScoreDeriver; + scoreDeriver: GPTScoreDeriver; /** * How should we compute the score rating algorithms for this game? */ - scoreCalcs: GPTScoreCalcs; + scoreCalcs: GPTScoreCalcs; /** * How should we compute session ratings for this game? */ - sessionCalcs: GPTSessionCalcs; + sessionCalcs: GPTSessionCalcs; /** * How should we compute profile ratings for this game? */ - profileCalcs: GPTProfileCalcs; + profileCalcs: GPTProfileCalcs; /** * For any "derived" classes for this game (i.e. classes that are the function * of the user's state), how should they work? */ - classDerivers: GPTClassDerivers; + classDerivers: GPTClassDerivers; /** * When creating a goal, how should we format the title? @@ -221,7 +218,7 @@ export interface GPTServerImplementation { * ^^^^^^^^^^^^^^^^^^^^^^ * this bit */ - goalCriteriaFormatters: GPTGoalFormatters; + goalCriteriaFormatters: GPTGoalFormatters; /** * How should we format the "outOf" part of a goal? @@ -230,7 +227,7 @@ export interface GPTServerImplementation { * ^^^^^^^^ * this bit */ - goalOutOfFormatters: GPTGoalFormatters; + goalOutOfFormatters: GPTGoalFormatters; /** * How should we format the progress on a goal? @@ -239,12 +236,12 @@ export interface GPTServerImplementation { * ^^^^^^^^ * this bit */ - goalProgressFormatters: GPTGoalProgressFormatters; + goalProgressFormatters: GPTGoalProgressFormatters; /** * How should we mutate PBs (to join best lamps, lowest BPs, etc.) for this GPT? */ - pbMergeFunctions: Array>; + pbMergeFunctions: Array>; /** * A PB is always initialised with the best score for this game's default @@ -258,7 +255,7 @@ export interface GPTServerImplementation { * * These map to ranking_value + ranking_value_tb1..tb5 in postgres. */ - pbRankingValues: PBRankingValuesFunction; + pbRankingValues: PBRankingValuesFunction; /** * There are various things that should be true about scores for each game @@ -269,18 +266,20 @@ export interface GPTServerImplementation { * incorrectnesses, like working out what the minimum possible full combo score * could be (which might be chart dependent, etc), don't bother. */ - scoreValidators: Array>; + scoreValidators: Array>; /** - * Dot-path keys into MONGO_ChartDocument whose values feed into - * `scoreDeriver` or `scoreCalcs`. When any of these change on a chart, + * Chart field paths whose values feed into + * `scoreDeriver` or `scoreCalcs`. + * + * When any of these change on a chart, * all scores on that chart must be re-derived. * - * Used to build a stable derivation checksum stored on `chart.derivation_checksum`. + * Used to build a stable checksum stored on `chart.derivation_checksum`. */ - derivationRelevantFields: Array; + chartDataRelevantFields: Array; } -export type GPTImplementations = { - [GPT in GPTString]: GPTServerImplementation; +export type GameImplementations = { + [TGame in V3Game]: GameImplementation; }; diff --git a/typescript/server/src/game-implementations/utils/derivation-checksum.ts b/typescript/server/src/game-implementations/utils/derivation-checksum.ts index 6ec0334db..fa68a3f6c 100644 --- a/typescript/server/src/game-implementations/utils/derivation-checksum.ts +++ b/typescript/server/src/game-implementations/utils/derivation-checksum.ts @@ -1,83 +1,31 @@ -import type { GPTString, MONGO_ChartDocument } from "tachi-common"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import fjsh from "fast-json-stable-hash"; +import get from "lodash/get"; +import { type ChartDocument, type V3Game } from "tachi-common"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; -import crypto from "crypto"; +function ComputeChartStabilityChecksumInner(chart: ChartDocument, fields: Array): string { + const extracted: Record = {}; -/** - * Walk a dot-path like "data.notecount" into an object, returning the value - * at that path or `undefined` if any segment is missing. - */ -function getAtPath(obj: Record, path: string): unknown { - let cur: unknown = obj; - - for (const segment of path.split(".")) { - if (cur === null || cur === undefined || typeof cur !== "object") { - return undefined; + for (const field of fields.slice(0).sort()) { + const value = get(chart, field); + if (value === undefined) { + continue; } - - cur = (cur as Record)[segment]; + extracted[field] = value; } - return cur; -} - -/** - * Recursively sorts object keys so JSON.stringify produces a deterministic - * string regardless of insertion order. - */ -function sortDeep(value: unknown): unknown { - if (value === null || value === undefined) { - return value; - } - - if (Array.isArray(value)) { - return value.map(sortDeep); - } - - if (typeof value === "object") { - const sorted: Record = {}; - - for (const key of Object.keys(value as Record).sort()) { - sorted[key] = sortDeep((value as Record)[key]); - } - - return sorted; - } - - return value; + return fjsh.hash(extracted, "sha256"); } /** * Compute a stable SHA-256 hex digest over the subset of chart fields that - * affect score derivation for a given GPT. + * affect score derivation for a given game. * * The checksum is deterministic: same field values always produce the same * hash, regardless of object key ordering. */ -export function computeDerivationChecksum( - chart: MONGO_ChartDocument, - fields: Array, -): string { - const extracted: Record = {}; +export function ComputeChartStabilityChecksum(game: V3Game, chart: ChartDocument): string { + const impl = GAME_IMPLEMENTATIONS[game]; - for (const field of [...fields].sort()) { - extracted[field] = sortDeep(getAtPath(chart as unknown as Record, field)); - } - - const payload = JSON.stringify(extracted); - - return crypto.createHash("sha256").update(payload).digest("hex"); -} - -/** - * Compute the derivation checksum for a chart using the game's declared - * `derivationRelevantFields`. - */ -export function computeDerivationChecksumForGPT( - gpt: GPTString, - chart: MONGO_ChartDocument, -): string { - const impl = GPT_SERVER_IMPLEMENTATIONS[gpt]; - - return computeDerivationChecksum(chart, impl.derivationRelevantFields); + return ComputeChartStabilityChecksumInner(chart, impl.chartDataRelevantFields); } diff --git a/typescript/server/src/game-implementations/utils/pb-merge.test.ts b/typescript/server/src/game-implementations/utils/pb-merge.test.ts index 78f71f0fd..e9283e663 100644 --- a/typescript/server/src/game-implementations/utils/pb-merge.test.ts +++ b/typescript/server/src/game-implementations/utils/pb-merge.test.ts @@ -1,4 +1,4 @@ -import type { MONGO_ScoreData } from "tachi-common"; +import type { ScoreData } from "tachi-common"; import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; @@ -15,10 +15,10 @@ describe("CreatePBMergeFor (Postgres)", () => { userId: number, scores: Array<{ highlight?: boolean; - mongo: MONGO_ScoreData<"iidx:SP">; + mongo: ScoreData<"iidx-sp">; timeAchievedMs: number; }>, - ): Promise<{ chartLegacyId: string }> { + ): Promise<{ chartId: string; chartLegacyId: string }> { const n = ++counter; const songId = `song-pbm-${n}`; const chartId = `chart-pbm-${n}`; @@ -55,7 +55,7 @@ describe("CreatePBMergeFor (Postgres)", () => { for (const s of scores) { const scoreId = `score-pbm-${n}-${s.timeAchievedMs}`; - const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { ...s.mongo, judgements: s.mongo.judgements ?? {}, }); @@ -82,7 +82,7 @@ describe("CreatePBMergeFor (Postgres)", () => { .execute(); } - return { chartLegacyId }; + return { chartLegacyId, chartId }; } const basePb = () => @@ -90,8 +90,7 @@ describe("CreatePBMergeFor (Postgres)", () => { composedFrom: [{ name: "Primary", scoreID: "x" }], userID: 0, chartID: "", - game: "iidx", - playtype: "SP", + game: "iidx-sp", songID: 0, scoreData: {} as any, calculatedData: {}, @@ -102,7 +101,7 @@ describe("CreatePBMergeFor (Postgres)", () => { it("returns null when no scores match", async () => { const { id: userId } = await seedUser(); - const merge = CreatePBMergeFor<"iidx:SP">( + const merge = CreatePBMergeFor<"iidx-sp">( "largest", { metric: "score", type: "REGULAR" }, "Best Score", @@ -114,7 +113,7 @@ describe("CreatePBMergeFor (Postgres)", () => { it("picks largest score", async () => { const { id: userId } = await seedUser(); - const { chartLegacyId } = await seedIidxChartAndScores(userId, [ + const { chartId } = await seedIidxChartAndScores(userId, [ { timeAchievedMs: 1_000_000, mongo: { @@ -123,7 +122,7 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 0, score: 100, optional: {}, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, { timeAchievedMs: 2_000_000, @@ -133,18 +132,18 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 0, score: 999, optional: {}, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, ]); - const merge = CreatePBMergeFor<"iidx:SP">( + const merge = CreatePBMergeFor<"iidx-sp">( "largest", { metric: "score", type: "REGULAR" }, "Best Score", () => {}, ); const pb = basePb(); - const result = await merge(userId, chartLegacyId, null, pb); + const result = await merge(userId, chartId, null, pb); expect(result?.scoreID).toMatch(/^score-pbm-/u); const picked = await DB.selectFrom("score") @@ -156,7 +155,7 @@ describe("CreatePBMergeFor (Postgres)", () => { it("breaks ties by oldest timeAchieved", async () => { const { id: userId } = await seedUser(); - const { chartLegacyId } = await seedIidxChartAndScores(userId, [ + const { chartId } = await seedIidxChartAndScores(userId, [ { timeAchievedMs: 5_000_000, mongo: { @@ -165,7 +164,7 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 50, score: 500, optional: {}, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, { timeAchievedMs: 1_000_000, @@ -175,24 +174,24 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 50, score: 500, optional: {}, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, ]); - const merge = CreatePBMergeFor<"iidx:SP">( + const merge = CreatePBMergeFor<"iidx-sp">( "largest", { metric: "score", type: "REGULAR" }, "Best Score", () => {}, ); - const result = await merge(userId, chartLegacyId, null, basePb()); + const result = await merge(userId, chartId, null, basePb()); expect(result?.scoreID).toContain("1000000"); }); it("respects asOfTimestamp", async () => { const { id: userId } = await seedUser(); - const { chartLegacyId } = await seedIidxChartAndScores(userId, [ + const { chartId } = await seedIidxChartAndScores(userId, [ { timeAchievedMs: 100, mongo: { @@ -201,7 +200,7 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 0, score: 2000, optional: {}, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, { timeAchievedMs: 5000, @@ -211,24 +210,24 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 0, score: 9000, optional: {}, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, ]); - const merge = CreatePBMergeFor<"iidx:SP">( + const merge = CreatePBMergeFor<"iidx-sp">( "largest", { metric: "score", type: "REGULAR" }, "Best Score", () => {}, ); - const result = await merge(userId, chartLegacyId, 3000, basePb()); + const result = await merge(userId, chartId, 3000, basePb()); expect(result?.scoreID).toContain("100"); }); it("picks smallest optional.bp", async () => { const { id: userId } = await seedUser(); - const { chartLegacyId } = await seedIidxChartAndScores(userId, [ + const { chartId } = await seedIidxChartAndScores(userId, [ { timeAchievedMs: 10_000, mongo: { @@ -237,7 +236,7 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 50, score: 400, optional: { bp: 12 }, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, { timeAchievedMs: 20_000, @@ -247,24 +246,24 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 50, score: 400, optional: { bp: 3 }, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, ]); - const merge = CreatePBMergeFor<"iidx:SP">( + const merge = CreatePBMergeFor<"iidx-sp">( "smallest", { metric: "bp", type: "REGULAR" }, "Lowest BP", () => {}, ); - const result = await merge(userId, chartLegacyId, null, basePb()); + const result = await merge(userId, chartId, null, basePb()); expect(result?.scoreID).toContain("20000"); }); it("merges highlight from chosen score", async () => { const { id: userId } = await seedUser(); - const { chartLegacyId } = await seedIidxChartAndScores(userId, [ + const { chartId } = await seedIidxChartAndScores(userId, [ { timeAchievedMs: 1, highlight: true, @@ -274,11 +273,11 @@ describe("CreatePBMergeFor (Postgres)", () => { percent: 0, score: 50, optional: {}, - } as MONGO_ScoreData<"iidx:SP">, + } as ScoreData<"iidx-sp">, }, ]); - const merge = CreatePBMergeFor<"iidx:SP">( + const merge = CreatePBMergeFor<"iidx-sp">( "largest", { metric: "score", type: "REGULAR" }, "Best Score", @@ -286,7 +285,7 @@ describe("CreatePBMergeFor (Postgres)", () => { ); const pb = basePb(); pb.highlight = false; - await merge(userId, chartLegacyId, null, pb); + await merge(userId, chartId, null, pb); expect(pb.highlight).toBe(true); }); }); diff --git a/typescript/server/src/game-implementations/utils/pb-merge.ts b/typescript/server/src/game-implementations/utils/pb-merge.ts index f7edc5042..415c225f4 100644 --- a/typescript/server/src/game-implementations/utils/pb-merge.ts +++ b/typescript/server/src/game-implementations/utils/pb-merge.ts @@ -1,11 +1,11 @@ import type { PBMergeFunction } from "#game-implementations/types"; -import type { MONGO_PBScoreDocumentNoRank } from "#lib/score-import/framework/pb/create-pb-doc"; +import type { PBScoreDocumentNoRank } from "#lib/score-import/framework/pb/create-pb-doc"; import type { ConfDerivedMetrics, ConfOptionalMetrics, ConfProvidedMetrics, - GPTString, - MONGO_ScoreDocument, + ScoreDocument, + V3Game, } from "tachi-common"; import { @@ -15,21 +15,21 @@ import { } from "#lib/db-formats/score"; import { scoreVisibleSql } from "#lib/score-import/framework/pg/score-visibility"; import DB from "#services/pg/db"; -import { UnixMillisecondsToISO8601 } from "#utils/time.js"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; import { sql } from "kysely"; // insane typemagic to get mongodb-safe names for this GPT's metrics. -type MetricKeys = +type MetricKeys = | { - metric: keyof ConfDerivedMetrics[GPT]; + metric: keyof ConfDerivedMetrics[TGame]; type: "DERIVED"; } | { - metric: keyof ConfOptionalMetrics[GPT] | keyof ConfProvidedMetrics[GPT]; + metric: keyof ConfOptionalMetrics[TGame] | keyof ConfProvidedMetrics[TGame]; type: "REGULAR"; }; -function metricSortValueSql(metric: MetricKeys) { +function metricSortValueSql(metric: MetricKeys) { if (metric.type === "DERIVED") { return sql`(score.derived_data::jsonb->>${sql.lit(metric.metric)})::double precision`; } @@ -37,7 +37,7 @@ function metricSortValueSql(metric: MetricKeys) { return sql`(score.data::jsonb->>${sql.lit(metric.metric)})::double precision`; } -function metricIsNumericSql(metric: MetricKeys) { +function metricIsNumericSql(metric: MetricKeys) { if (metric.type === "DERIVED") { return sql`jsonb_typeof(score.derived_data::jsonb -> ${sql.lit(metric.metric)}) = ${sql.lit("number")}`; } @@ -54,12 +54,12 @@ function metricIsNumericSql(metric: MetricKeys) { * * @param direction - Whether to pick the largest value or smallest value for this metric. */ -export function CreatePBMergeFor( +export function CreatePBMergeFor( direction: "largest" | "smallest", - metric: MetricKeys, + metric: MetricKeys, name: string, - applicator: (base: MONGO_PBScoreDocumentNoRank, score: MONGO_ScoreDocument) => void, -): PBMergeFunction { + applicator: (base: PBScoreDocumentNoRank, score: ScoreDocument) => void, +): PBMergeFunction { return async (userID, chartID, asOfTimestamp, base) => { let q = DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") @@ -67,7 +67,7 @@ export function CreatePBMergeFor( .leftJoin("import", "import.id", "score.import_id") .select(SELECT_SCORE_DOCUMENT) .where("score.user_id", "=", userID) - .where("chart.legacy_id", "=", chartID) + .where("chart.id", "=", chartID) .where(metricIsNumericSql(metric)) .where(scoreVisibleSql()); @@ -95,7 +95,7 @@ export function CreatePBMergeFor( const bestScoreFor = ToScoreDocument( row as ScoreDocumentJoinRow, - ) as unknown as MONGO_ScoreDocument; + ) as unknown as ScoreDocument; applicator(base, bestScoreFor); diff --git a/typescript/server/src/game-implementations/utils/profile-calc.test.ts b/typescript/server/src/game-implementations/utils/profile-calc.test.ts index 4418aebb0..c314a7191 100644 --- a/typescript/server/src/game-implementations/utils/profile-calc.test.ts +++ b/typescript/server/src/game-implementations/utils/profile-calc.test.ts @@ -72,7 +72,7 @@ describe("profile-calc (Postgres)", () => { await seedChunithmChartsWithPbs(userId, Array(50).fill(17.15)); const fn = ProfileAvgBestN("rating", 50, false, 100); - const result = await fn("chunithm", "Single", userId); + const result = await fn("chunithm", userId); expect(result).toBe(17.15); }); @@ -82,7 +82,7 @@ describe("profile-calc (Postgres)", () => { await seedChunithmChartsWithPbs(userId, [16, 16, 16, 16]); const fn = ProfileAvgBestN("rating", 50, false, 100); - const result = await fn("chunithm", "Single", userId); + const result = await fn("chunithm", userId); expect(result).toBe(1.28); }); @@ -92,7 +92,7 @@ describe("profile-calc (Postgres)", () => { await seedChunithmChartsWithPbs(userId, [16, 16, 16, 16]); const fn = ProfileAvgBestN("rating", 50, true, 100); - const result = await fn("chunithm", "Single", userId); + const result = await fn("chunithm", userId); expect(result).toBeNull(); }); @@ -102,7 +102,7 @@ describe("profile-calc (Postgres)", () => { await seedChunithmChartsWithPbs(userId, [10, 20, 30]); const fn = ProfileSumBestN("rating", 2); - const result = await fn("chunithm", "Single", userId); + const result = await fn("chunithm", userId); expect(result).toBe(50); }); @@ -163,7 +163,7 @@ describe("profile-calc (Postgres)", () => { .execute(); const fn = ProfileSumBestN("rating", 5); - const result = await fn("chunithm", "Single", userId); + const result = await fn("chunithm", userId); expect(result).toBeNull(); }); diff --git a/typescript/server/src/game-implementations/utils/profile-calc.ts b/typescript/server/src/game-implementations/utils/profile-calc.ts index 76592a785..90bb5fc6f 100644 --- a/typescript/server/src/game-implementations/utils/profile-calc.ts +++ b/typescript/server/src/game-implementations/utils/profile-calc.ts @@ -2,13 +2,10 @@ import { type PbDocumentJoinRow, ToPbScoreDocument } from "#lib/db-formats/pb"; import DB from "#services/pg/db"; import { sql } from "kysely"; import { - type GameGroup, - GamePTToV3, - type GPTString, type integer, - type MONGO_PBScoreDocument, - type Playtype, + type PBScoreDocument, type ScoreRatingAlgorithms, + type V3Game, } from "tachi-common"; /** @@ -23,21 +20,19 @@ import { * @returns - Number if the user has scores with that rating algorithm, null if they have * no scores with this rating algorithm that are non-null. */ -function CalcN( - key: ScoreRatingAlgorithms[GPT], +function CalcN( + key: ScoreRatingAlgorithms[TGame], n: integer, returnMean = false, nullIfNotEnoughScores = false, multiplier = 1, ) { - return async (game: GameGroup, playtype: Playtype, userID: integer) => { - const v3Game = GamePTToV3(game, playtype); - + return async (game: TGame, userID: integer) => { const rows = await DB.selectFrom("pb") .innerJoin("chart", "chart.id", "pb.chart_id") .select("pb.calculated_data") .where("pb.user_id", "=", userID) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where("chart.is_primary", "=", true) .where((eb) => eb( @@ -83,8 +78,8 @@ function CalcN( }; } -export function ProfileSumBestN( - key: ScoreRatingAlgorithms[GPT], +export function ProfileSumBestN( + key: ScoreRatingAlgorithms[TGame], n: integer, nullIfNotEnoughScores = false, multiplier = 1, @@ -92,8 +87,8 @@ export function ProfileSumBestN( return CalcN(key, n, false, nullIfNotEnoughScores, multiplier); } -export function ProfileAvgBestN( - key: ScoreRatingAlgorithms[GPT], +export function ProfileAvgBestN( + key: ScoreRatingAlgorithms[TGame], n: integer, nullIfNotEnoughScores = false, multiplier = 1, @@ -104,17 +99,14 @@ export function ProfileAvgBestN( export async function GetBestRatingOnSongs( songIDs: Array, userID: integer, - game: GameGroup, - playtype: Playtype, + game: V3Game, ratingProp: "skill", limit: integer, -): Promise> { +): Promise> { if (songIDs.length === 0) { return []; } - const v3Game = GamePTToV3(game, playtype); - const rows = await sql` WITH filtered AS ( SELECT pb.row_id, @@ -126,8 +118,8 @@ export async function GetBestRatingOnSongs( INNER JOIN chart ON chart.id = pb.chart_id INNER JOIN song ON song.id = chart.song_id WHERE pb.user_id = ${userID} - AND chart.game = ${v3Game} - AND song.legacy_id in (${sql.join(songIDs)}) + AND chart.game = ${game} + AND song.id in (${sql.join(songIDs)}) ) SELECT pb.row_id, @@ -145,7 +137,7 @@ export async function GetBestRatingOnSongs( pb.ranking_value_tb5, pb.highlight, pb.time_achieved, - song.legacy_id as song_legacy_id, + song.id as song_id, chart.game as chart_game, chart.is_primary as is_primary, chart_leaderboard.rank AS leaderboard_rank, diff --git a/typescript/server/src/game-implementations/utils/session-calc.ts b/typescript/server/src/game-implementations/utils/session-calc.ts index cf42fab01..3f4665014 100644 --- a/typescript/server/src/game-implementations/utils/session-calc.ts +++ b/typescript/server/src/game-implementations/utils/session-calc.ts @@ -1,5 +1,5 @@ import type { SessionCalculator } from "#game-implementations/types"; -import type { GPTString, integer, MONGO_ScoreDocument } from "tachi-common"; +import type { integer, ScoreDocument, V3Game } from "tachi-common"; export function SessionAverageBestN(vals: Array, n = 10) { const numbers = vals.filter((e) => typeof e === "number") as Array; @@ -16,9 +16,9 @@ export function SessionAverageBestN(vals: Array, n = ); } -export function SessionAvgBest10For( - prop: keyof MONGO_ScoreDocument["calculatedData"], -): SessionCalculator { +export function SessionAvgBest10For( + prop: keyof ScoreDocument["calculatedData"], +): SessionCalculator { return (arr) => SessionAverageBestN( arr.map((e) => e[prop]), @@ -26,10 +26,10 @@ export function SessionAvgBest10For( ); } -export function SessionAvgBestNFor( - prop: keyof MONGO_ScoreDocument["calculatedData"], +export function SessionAvgBestNFor( + prop: keyof ScoreDocument["calculatedData"], n: integer, -): SessionCalculator { +): SessionCalculator { return (arr) => SessionAverageBestN( arr.map((e) => e[prop]), diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index 1ec208099..8c1668896 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -1,6 +1,7 @@ import DB from "#services/pg/db"; import { type ActionTaker, type AnonActionTaker, MakeActionGuts } from "bliss"; import { type ActionSignature } from "bliss/actions"; +import { ALL_GAMES as ALL_GAMES } from "tachi-common"; import { z } from "zod"; const APP_NAME = "TACHI_SERVER"; @@ -307,17 +308,17 @@ export const ActionSignatures = { PATCH_UGPT_SETTINGS: { input: z.object({ userID: z.number().int(), - game: z.string(), - playtype: z.string(), + game: z.enum(ALL_GAMES), preferences: z.any(), }), - output: z.object({}), + output: z.object({ + settings: z.any(), + }), }, SET_RIVALS: { input: z.object({ userID: z.number().int(), - game: z.string(), - playtype: z.string(), + game: z.enum(ALL_GAMES), rivalIDs: z.array(z.number().int().positive()), }), output: z.object({}), @@ -325,8 +326,7 @@ export const ActionSignatures = { ADD_GOAL: { input: z.object({ userID: z.number().int(), - game: z.string(), - playtype: z.string(), + game: z.enum(ALL_GAMES), charts: z.any(), criteria: z.any(), }), @@ -337,8 +337,7 @@ export const ActionSignatures = { REMOVE_GOAL_SUBSCRIPTION: { input: z.object({ userID: z.number().int(), - game: z.string(), - playtype: z.string(), + game: z.enum(ALL_GAMES), goalID: z.string(), }), output: z.object({}), @@ -346,8 +345,7 @@ export const ActionSignatures = { UPDATE_UGPT_SHOWCASE: { input: z.object({ userID: z.number().int(), - game: z.string(), - playtype: z.string(), + game: z.enum(ALL_GAMES), stats: z.array(z.any()), }), output: z.object({}), @@ -419,7 +417,7 @@ export const AnonActionSignatures = { "!password": z.string().min(8), captcha: z.string(), inviteCode: z.string().nullable(), - username: z.string().min(3).max(20), + username: z.string().regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), }), output: z.object({ userID: z.number().int(), diff --git a/typescript/server/src/lib/activity/activity.test.ts b/typescript/server/src/lib/activity/activity.test.ts index 73ed935c2..f6c2fa47d 100644 --- a/typescript/server/src/lib/activity/activity.test.ts +++ b/typescript/server/src/lib/activity/activity.test.ts @@ -124,12 +124,7 @@ describe("GetRecentActivity (Postgres)", () => { timeEndedMs: base + 6000, }); - const result = await GetRecentActivity( - "iidx", - { game: "iidx", playtype: "SP", userID: userId }, - 2, - null, - ); + const result = await GetRecentActivity("iidx-sp", { userID: userId }, 2, null); expect(result.recentSessions).toHaveLength(2); expect(result.recentSessions[0]?.sessionID).toBe(`act-s3-${base}`); @@ -176,12 +171,7 @@ describe("GetRecentActivity (Postgres)", () => { timeAddedMs: base - 5000, }); - const result = await GetRecentActivity( - "iidx", - { game: "iidx", playtype: "SP", userID: userId }, - 2, - null, - ); + const result = await GetRecentActivity("iidx-sp", { userID: userId }, 2, null); const ids = result.recentlyHighlightedScores.map((s) => s.scoreID); expect(ids).toContain(`sc-in-${base}`); @@ -212,12 +202,7 @@ describe("GetRecentActivity (Postgres)", () => { }) .execute(); - const result = await GetRecentActivity( - "iidx", - { game: "iidx", playtype: "SP", userID: userId }, - 10, - null, - ); + const result = await GetRecentActivity("iidx-sp", { userID: userId }, 10, null); expect(result.achievedClasses).toHaveLength(1); expect(result.achievedClasses[0]?.classOldValue).toBe(null); @@ -225,12 +210,7 @@ describe("GetRecentActivity (Postgres)", () => { }); it("returns no sessions, scores, or class rows when userID $in is empty", async () => { - const result = await GetRecentActivity( - "iidx", - { game: "iidx", playtype: "SP", userID: { $in: [] } }, - 10, - null, - ); + const result = await GetRecentActivity("iidx-sp", { userID: { $in: [] } }, 10, null); expect(result.recentSessions).toHaveLength(0); expect(result.recentlyHighlightedScores).toHaveLength(0); @@ -240,7 +220,7 @@ describe("GetRecentActivity (Postgres)", () => { }); }); -describe("GET /api/v1/games/:game/:playtype/activity (smoke)", () => { +describe("GET /api/v1/games/:gameGroup/:playtype/activity (smoke)", () => { it("returns success with seeded iidx SP activity", async () => { const { id: userId } = await seedUser({ username: `act_api_${Date.now()}`, @@ -262,11 +242,11 @@ describe("GET /api/v1/games/:game/:playtype/activity (smoke)", () => { }) .execute(); - const res = await mockApi.get("/api/v1/games/iidx/SP/activity"); + const res = await mockApi.get("/api/v1/games/iidx-sp/activity"); expect(res.status).toBe(200); expect(res.body.success).toBe(true); - expect(res.body.body.recentSessions).toEqual( + expect(res.body.body["iidx-sp"].recentSessions).toEqual( expect.arrayContaining([expect.objectContaining({ sessionID: `act-api-s-${base}` })]), ); }); diff --git a/typescript/server/src/lib/activity/activity.ts b/typescript/server/src/lib/activity/activity.ts index 06845d3bf..3e8430892 100644 --- a/typescript/server/src/lib/activity/activity.ts +++ b/typescript/server/src/lib/activity/activity.ts @@ -1,5 +1,4 @@ import type { Request, Response } from "express-serve-static-core"; -import type { Game } from "tachi-db"; import { SELECT_CLASS_ACHIEVEMENT_DOCUMENT, @@ -16,31 +15,26 @@ import { import { DedupeArr } from "#utils/misc"; import { scoreDocumentJoin } from "#utils/queries/scores"; import { GetScoreIdsGroupedBySessionId } from "#utils/queries/sessions"; -import { GetGPT } from "#utils/req-tachi-data"; +import { REQ_GetGame } from "#utils/req-tachi-data"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { GetUsersWithIDs } from "#utils/user"; import { sql } from "kysely"; import { - type GameGroup, - GamePTToV3, - type GPTString, + type ChartDocument, + type ClassAchievementDocument, + type GoalDocument, + type GoalSubscriptionDocument, type integer, - type MONGO_ChartDocument, - type MONGO_ClassAchievementDocument, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, - type MONGO_ScoreDocument, - type MONGO_SessionDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, - type Playtype, + type QuestDocument, + type QuestSubscriptionDocument, + type ScoreDocument, + type SessionDocument, + type SongDocument, + type UserDocument, + type V3Game, } from "tachi-common"; export type ActivityConstraint = { - game: GameGroup; - playtype: Playtype; userID?: integer | { $in: Array }; }; @@ -114,30 +108,29 @@ function whereMsRangeOnColumn( * i.e. [1, 2, 3] 3, 3, 3 [4, 5, 6] */ export async function GetRecentActivity( - game: GameGroup, + game: V3Game, query: ActivityConstraint, sessions = 30, startFrom: number | null = null, ): Promise<{ - achievedClasses: Array; - charts: Array; - goals: Array; - goalSubs: Array; - quests: Array; - questSubs: Array; - recentlyHighlightedScores: Array; - recentSessions: Array; - songs: Array; - users: Array; + achievedClasses: Array; + charts: Array; + goals: Array; + goalSubs: Array; + quests: Array; + questSubs: Array; + recentlyHighlightedScores: Array; + recentSessions: Array; + songs: Array; + users: Array; }> { - const v3Game = GamePTToV3(query.game, query.playtype) as Game; const sessionUserWhere = whereActivityUserId(query.userID, "user_id"); const scoreUserWhere = whereActivityUserId(query.userID, "score.user_id"); const classUserWhere = whereActivityUserId(query.userID, "user_id"); let sessionQ = DB.selectFrom("session") .select(SELECT_SESSION_DOCUMENT) - .where("session.game", "=", v3Game); + .where("session.game", "=", game); if (sessionUserWhere) { sessionQ = sessionQ.where(sessionUserWhere); @@ -183,7 +176,7 @@ export async function GetRecentActivity( let classQ = DB.selectFrom("class_achievement") .select(SELECT_CLASS_ACHIEVEMENT_DOCUMENT) - .where("class_achievement.game", "=", v3Game) + .where("class_achievement.game", "=", game) .where(timeWhereAchieved); if (classUserWhere) { @@ -191,7 +184,7 @@ export async function GetRecentActivity( } let highlightQ = scoreDocumentJoin() - .where("score.game", "=", v3Game) + .where("score.game", "=", game) .where("score.highlight", "=", true) .where(timeWhereScore); @@ -212,7 +205,7 @@ export async function GetRecentActivity( ToScoreDocument(row as ScoreDocumentJoinRow), ); - const { songs, charts } = await GetRelevantSongsAndCharts(recentlyHighlightedScores, game); + const { songs, charts } = await GetRelevantSongsAndCharts(recentlyHighlightedScores); const userIDs = DedupeArr([ ...recentSessions.map((e) => e.userID), @@ -241,29 +234,21 @@ export async function GetRecentActivity( /** * @see {GetRecentActivity}, but for multiple games. Works pretty much as expected. * - * @param gpts - An array of Game+Playtype combos to fetch from. + * @param games - An array of Game+Playtype combos to fetch from. */ export async function GetRecentActivityForMultipleGames( - gpts: Array<{ game: GameGroup; playtype: Playtype }>, + games: Array, sessions = 30, startFrom: number | null = null, ) { // { "iidx:SP": {recentSessions: ..., ...} } - const data: Partial>>> = {}; + const data: Partial>>> = {}; await Promise.all( - gpts.map(async ({ game, playtype }) => { - const activity = await GetRecentActivity( - game, - { - game, - playtype, - }, - sessions, - startFrom, - ); + games.map(async (game) => { + const activity = await GetRecentActivity(game, {}, sessions, startFrom); - data[`${game}:${playtype}` as GPTString] = activity; + data[game] = activity; }), ); @@ -273,7 +258,7 @@ export async function GetRecentActivityForMultipleGames( // very far back, resulting in us skipping over data. const flatPointer = Object.entries(data) as Array< - [GPTString, { recentSessions: Array }] + [V3Game, { recentSessions: Array }] >; // sort all games data to find the Nth session (where we should set our cutoff). @@ -309,7 +294,7 @@ export async function GetRecentActivityForMultipleGames( */ export function CreateActivityRouteHandler(query: ActivityConstraint) { return async (req: Request, res: Response) => { - const { game } = GetGPT(req); + const game = REQ_GetGame(req); const qSessions = req.query.sessions; const qStartTime = req.query.startTime; diff --git a/typescript/server/src/lib/auth/auth.ts b/typescript/server/src/lib/auth/auth.ts index 4d6d33d11..54a00c754 100644 --- a/typescript/server/src/lib/auth/auth.ts +++ b/typescript/server/src/lib/auth/auth.ts @@ -10,10 +10,10 @@ import { type Transaction } from "kysely"; import { p } from "prudence"; import { type integer, - type MONGO_InviteCodeDocument, - type MONGO_UserDocument, - type MONGO_UserSettingsDocument, + type InviteCodeDocument, UserAuthLevels, + type UserDocument, + type UserSettingsDocument, } from "tachi-common"; import { type Database } from "tachi-db"; @@ -48,7 +48,7 @@ export function ReinstateInvite(code: string) { .execute(); } -export async function AddNewInvite(user: MONGO_UserDocument): Promise { +export async function AddNewInvite(user: UserDocument): Promise { const code = Random20Hex(); await DB.insertInto("priv_invite") @@ -74,7 +74,7 @@ export async function AddNewInvite(user: MONGO_UserDocument): Promise, - username: string, plaintext: string, email: string, -): Promise<{ newSettings: MONGO_UserSettingsDocument; newUser: MONGO_UserDocument }> { +): Promise<{ newSettings: UserSettingsDocument; newUser: UserDocument }> { const hashedPassword = await HashPassword(plaintext); log.debug(`Hashed password for ${username}.`); - // no - this is bad - // // all created users on a dev instance should be admins, for convenience. - // if (Env.NODE_ENV === "dev") { - // userDoc.authLevel = UserAuthLevels.ADMIN; - // } - const userID = await txn .insertInto("account") .values({ @@ -118,7 +111,7 @@ export async function AddNewUser( .executeTakeFirstOrThrow() .then((res) => res.id); - const userDoc: MONGO_UserDocument = { + const userDoc: UserDocument = { id: userID, username, usernameLowercase: username.toLowerCase(), @@ -159,7 +152,7 @@ export function InsertPrivateUserInfo( export async function InsertDefaultUserSettings( txn: Transaction, userID: integer, -): Promise { +): Promise { log.debug(`Inserting default settings for ${userID}.`); await txn @@ -223,8 +216,8 @@ export async function ValidateCaptcha( export function MountAuthCookie( req: Express.Request, - user: MONGO_UserDocument, - settings: MONGO_UserSettingsDocument, + user: UserDocument, + settings: UserSettingsDocument, ) { req.session.tachi = { user, diff --git a/typescript/server/src/lib/builtin-clients/builtin-clients.ts b/typescript/server/src/lib/builtin-clients/builtin-clients.ts index b02a47475..ebaf69c94 100644 --- a/typescript/server/src/lib/builtin-clients/builtin-clients.ts +++ b/typescript/server/src/lib/builtin-clients/builtin-clients.ts @@ -2,13 +2,13 @@ import { ACTION_InstallBuiltinClient } from "#actions/install-builtin-client"; import { log } from "#lib/log/log"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; import _ from "lodash"; -import { type APIPermissions, type MONGO_TachiAPIClientDocument } from "tachi-common"; +import { type APIPermissions, type TachiAPIClientDocument } from "tachi-common"; /* eslint-disable no-await-in-loop */ import { GetClientByID } from "#utils/queries/api-clients"; import { GetFirstAdmin } from "#utils/user"; import fjsh from "fast-json-stable-hash"; -type DefaultClients = Array>; +type DefaultClients = Array>; // Defines some Tachi API Clients that should come default with a Tachi // environment. diff --git a/typescript/server/src/lib/db-formats/api-client.ts b/typescript/server/src/lib/db-formats/api-client.ts index 863282e73..0ade35fd3 100644 --- a/typescript/server/src/lib/db-formats/api-client.ts +++ b/typescript/server/src/lib/db-formats/api-client.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type APIPermissions, type MONGO_TachiAPIClientDocument } from "tachi-common"; +import { type APIPermissions, type TachiAPIClientDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_API_CLIENT = [ @@ -23,7 +23,7 @@ export const SELECT_API_CLIENT = [ export function ToAPIClientDocument( row: Selection, -): MONGO_TachiAPIClientDocument { +): TachiAPIClientDocument { const requestedPermissions: Array = []; if (row.pm_customise_profile) { requestedPermissions.push("customise_profile"); diff --git a/typescript/server/src/lib/db-formats/api-token.ts b/typescript/server/src/lib/db-formats/api-token.ts index 6a79ee54d..eb5e5d341 100644 --- a/typescript/server/src/lib/db-formats/api-token.ts +++ b/typescript/server/src/lib/db-formats/api-token.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type APIPermissions, type MONGO_APITokenDocument } from "tachi-common"; +import { type APIPermissions, type APITokenDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_API_TOKEN = [ @@ -19,7 +19,7 @@ export const SELECT_API_TOKEN = [ export function ToAPITokenDocument( row: Selection, -): MONGO_APITokenDocument { +): APITokenDocument { const permissions: Partial> = {}; if (row.pm_customise_profile) { diff --git a/typescript/server/src/lib/db-formats/cg-card-info.ts b/typescript/server/src/lib/db-formats/cg-card-info.ts index 10719e49a..fa82ccc3b 100644 --- a/typescript/server/src/lib/db-formats/cg-card-info.ts +++ b/typescript/server/src/lib/db-formats/cg-card-info.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type MONGO_CGCardInfo } from "tachi-common"; +import { type CGCardInfo } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_CG_CARD_INFO = [ @@ -11,10 +11,10 @@ export const SELECT_CG_CARD_INFO = [ export function ToCGCardInfo( row: Selection, -): MONGO_CGCardInfo { +): CGCardInfo { return { userID: row.user_id, - service: row.service as MONGO_CGCardInfo["service"], + service: row.service as CGCardInfo["service"], cardID: row.card_id, pin: row.pin, }; diff --git a/typescript/server/src/lib/db-formats/chart.test.ts b/typescript/server/src/lib/db-formats/chart.test.ts index 5d4591e85..f29ed7e3f 100644 --- a/typescript/server/src/lib/db-formats/chart.test.ts +++ b/typescript/server/src/lib/db-formats/chart.test.ts @@ -1,13 +1,12 @@ import { GetChartById } from "#lib/db-formats/chart"; import DB from "#services/pg/db"; import { CloseServerConnection } from "#test-utils/mock-api"; -import { GamePTToV3 } from "tachi-common"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; afterAll(() => CloseServerConnection()); const SONG_PG_ID = "S_TEST_CHARTFMT_SONG_001"; -const CHART_PG_ID = "C_TEST_CHARTFMT_CHART_001"; +const CHART_ID = "C_TEST_CHARTFMT_CHART_001"; const SONG_LEGACY_ID = 50_001; const CHART_LEGACY_ID = "c2311194e3897ddb5745b1760d2c0141f933e683"; @@ -30,7 +29,7 @@ async function seedSong() { async function seedChart() { await DB.insertInto("chart") .values({ - id: CHART_PG_ID, + id: CHART_ID, legacy_id: CHART_LEGACY_ID, game: "iidx-sp", song_id: SONG_PG_ID, @@ -45,8 +44,8 @@ async function seedChart() { } async function cleanup() { - await DB.deleteFrom("chart").where("id", "=", CHART_PG_ID).execute(); - await DB.deleteFrom("song").where("id", "=", SONG_PG_ID).execute(); + await DB.deleteFrom("chart").where("chart.id", "=", CHART_ID).execute(); + await DB.deleteFrom("song").where("song.id", "=", SONG_PG_ID).execute(); } describe("GetChartById", () => { @@ -57,16 +56,17 @@ describe("GetChartById", () => { }); it("resolves by Postgres chart id", async () => { - const c = await GetChartById(GamePTToV3("iidx", "SP"), CHART_PG_ID); + const c = await GetChartById(CHART_ID); expect(c).toBeDefined(); - expect(c!.chartID).toBe(CHART_PG_ID); - expect(c!.songID).toBe(SONG_LEGACY_ID); + expect(c!.game).toBe("iidx-sp"); + expect(c!.chartID).toBe(CHART_ID); + expect(c!.song.id).toBe(SONG_PG_ID); expect(c!.versions).toContain("27"); }); it("returns undefined when no chart matches", async () => { - const c = await GetChartById(GamePTToV3("iidx", "SP"), "nonexistent-chart-id"); + const c = await GetChartById("nonexistent-chart-id"); expect(c).toBeUndefined(); }); diff --git a/typescript/server/src/lib/db-formats/chart.ts b/typescript/server/src/lib/db-formats/chart.ts index 6058e3541..916742800 100644 --- a/typescript/server/src/lib/db-formats/chart.ts +++ b/typescript/server/src/lib/db-formats/chart.ts @@ -3,16 +3,17 @@ import type { Database, Game } from "tachi-db"; import DB from "#services/pg/db"; import { type Selection, sql, type SqlBool } from "kysely"; import { + type ChartDocument, + type ChartDocumentData, type Difficulties, - type GameGroup, - type GPTString, - type MONGO_ChartDocument, - type MONGO_ChartDocumentData, - V3ToGameGroup, - V3ToGamePT, + GameToGameGroup, + type SongDocument, + type V3Game, type Versions, } from "tachi-common"; +import { SELECT_SONG_DOCUMENT } from "./song"; + export const SELECT_CHART = [ "chart.id as chart_id", "chart.game as chart_game", @@ -23,24 +24,29 @@ export const SELECT_CHART = [ "chart.versions as chart_versions", "chart.data as chart_data", "chart.song_id as chart_song_id", - "song.legacy_id as song_legacy_id", + ...SELECT_SONG_DOCUMENT, ] as const; export type ChartRow = Selection; -export function ToChartDocument(row: ChartRow): MONGO_ChartDocument { - const { playtype } = V3ToGamePT(row.chart_game); - +export function ToChartDocument(row: ChartRow): ChartDocument { return { + game: row.chart_game, chartID: row.chart_id, - songID: row.song_legacy_id, + song: { + altTitles: row.song_alt_titles, + artist: row.song_artist, + data: row.song_data as SongDocument["data"], + id: row.song_id, + searchTerms: row.song_search_terms, + title: row.song_title, + }, level: row.chart_level, levelNum: row.chart_level_num, isPrimary: row.chart_is_primary, - difficulty: row.chart_difficulty as Difficulties[GPTString], - playtype, - data: row.chart_data as MONGO_ChartDocumentData[GPTString], - versions: row.chart_versions as Versions[GPTString][], + difficulty: row.chart_difficulty as Difficulties[V3Game], + data: row.chart_data as ChartDocumentData[V3Game], + versions: row.chart_versions as Versions[V3Game][], }; } @@ -53,14 +59,14 @@ export async function GetChartsBySongId( v3Game: Game, songID: string, opts?: { omit2dxtraCharts?: boolean }, -): Promise { +): Promise { let q = DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .where("song_id", "=", songID) .where("game", "=", v3Game); - const gameGroup = V3ToGameGroup(v3Game); + const gameGroup = GameToGameGroup(v3Game); if (opts?.omit2dxtraCharts && gameGroup === "iidx") { q = q.where(sql`(chart.data->>'2dxtraSet') IS NULL`); @@ -72,16 +78,12 @@ export async function GetChartsBySongId( } /** - * Loads a single chart by Postgres `chart.id` or `legacy_id`, scoped to `v3Game`. + * Loads a single chart by Postgres `chart.id`. */ -export async function GetChartById( - v3Game: Game, - chartID: string, -): Promise { +export async function GetChartById(chartID: string): Promise { const chartRow = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("chart.game", "=", v3Game) .where("chart.id", "=", chartID) .executeTakeFirst(); @@ -92,12 +94,27 @@ export async function GetChartById( return ToChartDocument(chartRow); } +export async function GetChartByIdForGame( + game: Game, + chartID: string, +): Promise { + const chartRow = await DB.selectFrom("chart") + .innerJoin("song", "song.id", "chart.song_id") + .select(SELECT_CHART) + .where("chart.id", "=", chartID) + .where("chart.game", "=", game) + .executeTakeFirst(); + + if (!chartRow) { + return undefined; + } + + return ToChartDocument(chartRow); +} + // Loads charts from a list of IDs. // This function should rarely be used - it's an antipattern in sql to do queries like this. -export async function GetChartsByIds( - game: GameGroup, - chartIDs: Array, -): Promise> { +export async function GetChartsByIds(chartIDs: Array): Promise> { if (chartIDs.length === 0) { return []; } @@ -107,7 +124,6 @@ export async function GetChartsByIds( const rows = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", game) .where("chart.id", "in", unique) .execute(); diff --git a/typescript/server/src/lib/db-formats/class-achievement.ts b/typescript/server/src/lib/db-formats/class-achievement.ts index 13c8d1f60..293ac245c 100644 --- a/typescript/server/src/lib/db-formats/class-achievement.ts +++ b/typescript/server/src/lib/db-formats/class-achievement.ts @@ -1,8 +1,8 @@ -import type { Database, Game } from "tachi-db"; +import type { Database } from "tachi-db"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Selection } from "kysely"; -import { type MONGO_ClassAchievementDocument, V3ToGamePT } from "tachi-common"; +import { type ClassAchievementDocument, type V3Game } from "tachi-common"; export const SELECT_CLASS_ACHIEVEMENT_DOCUMENT = [ "class_achievement.game", @@ -19,15 +19,10 @@ type ClassAchievementRow = Selection< (typeof SELECT_CLASS_ACHIEVEMENT_DOCUMENT)[number] >; -export function ToClassAchievementDocument( - row: ClassAchievementRow, -): MONGO_ClassAchievementDocument { - const { game, playtype } = V3ToGamePT(row.game as Game); - +export function ToClassAchievementDocument(row: ClassAchievementRow): ClassAchievementDocument { return { - game, - playtype, - classSet: row.class_set as MONGO_ClassAchievementDocument["classSet"], + game: row.game as V3Game, + classSet: row.class_set as ClassAchievementDocument["classSet"], classOldValue: row.class_prev_value === "" ? null : row.class_prev_value, classValue: row.class_value, timeAchieved: ISO8601ToUnixMilliseconds(row.timestamp), diff --git a/typescript/server/src/lib/db-formats/fervidex-settings.ts b/typescript/server/src/lib/db-formats/fervidex-settings.ts index aa77e1e5a..c251bd913 100644 --- a/typescript/server/src/lib/db-formats/fervidex-settings.ts +++ b/typescript/server/src/lib/db-formats/fervidex-settings.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type MONGO_FervidexSettingsDocument } from "tachi-common"; +import { type FervidexSettingsDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_FER_SETTINGS = [ @@ -10,7 +10,7 @@ export const SELECT_FER_SETTINGS = [ export function ToFervidexSettingsDocument( row: Selection, cards: Array | null, -): MONGO_FervidexSettingsDocument { +): FervidexSettingsDocument { return { userID: row.user_id, cards, diff --git a/typescript/server/src/lib/db-formats/folders.ts b/typescript/server/src/lib/db-formats/folders.ts index 5b51003a9..aff599ccc 100644 --- a/typescript/server/src/lib/db-formats/folders.ts +++ b/typescript/server/src/lib/db-formats/folders.ts @@ -1,6 +1,6 @@ import DB from "#services/pg/db"; import { type Selection } from "kysely"; -import { type MONGO_FolderDocument, V3ToGamePT } from "tachi-common"; +import { type FolderDocument, type V3Game } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_FOLDER = [ @@ -16,18 +16,16 @@ export const SELECT_FOLDER = [ export function ToFolderDocument( row: Selection, -): MONGO_FolderDocument { - const { game, playtype } = V3ToGamePT(row.game); - +): FolderDocument { return { folderID: row.id, + slug: row.slug, data: { $comment: "This data does not exist anymore, and you never should've been depending on it, anyway. This field is only here for backwards compatibility.", }, - game, + game: row.game, inactive: row.inactive, - playtype, searchTerms: row.search_terms ?? [], title: row.title, type: "charts", @@ -36,7 +34,7 @@ export function ToFolderDocument( export async function LoadFolderDocumentById( folderID: string, -): Promise { +): Promise { const row = await DB.selectFrom("folder") .select(SELECT_FOLDER) .where("id", "=", folderID) @@ -51,7 +49,7 @@ export async function LoadFolderDocumentById( export async function LoadFolderDocumentsByIds( folderIds: Array, -): Promise> { +): Promise> { if (folderIds.length === 0) { return new Map(); } @@ -61,7 +59,7 @@ export async function LoadFolderDocumentsByIds( .where("id", "in", folderIds) .execute(); - const out = new Map(); + const out = new Map(); for (const row of rows) { out.set(row.id, ToFolderDocument(row)); @@ -69,3 +67,46 @@ export async function LoadFolderDocumentsByIds( return out; } + +export async function LoadFolderDocumentByGameAndSlug( + game: V3Game, + slug: string, +): Promise { + const row = await DB.selectFrom("folder") + .select(SELECT_FOLDER) + .where("game", "=", game) + .where("slug", "=", slug) + .executeTakeFirst(); + + if (!row) { + return undefined; + } + + return ToFolderDocument(row); +} + +/** + * Loads folders by slug for one game; map keys are **slug** (not id). + */ +export async function LoadFolderDocumentsByGameAndSlugs( + game: V3Game, + slugs: Array, +): Promise> { + if (slugs.length === 0) { + return new Map(); + } + + const rows = await DB.selectFrom("folder") + .select(SELECT_FOLDER) + .where("game", "=", game) + .where("slug", "in", slugs) + .execute(); + + const out = new Map(); + + for (const row of rows) { + out.set(row.slug, ToFolderDocument(row)); + } + + return out; +} diff --git a/typescript/server/src/lib/db-formats/game-profiles.ts b/typescript/server/src/lib/db-formats/game-profiles.ts index f731d5b6f..3c8599e01 100644 --- a/typescript/server/src/lib/db-formats/game-profiles.ts +++ b/typescript/server/src/lib/db-formats/game-profiles.ts @@ -1,10 +1,9 @@ import { type Selection } from "kysely"; import { type AnyClasses, - type GPTString, - type MONGO_UserGameStats, type ProfileRatingAlgorithms, - V3ToGamePT, + type UserGameStats, + type V3Game, } from "tachi-common"; import { type Database } from "tachi-db"; @@ -17,14 +16,11 @@ export const SELECT_GAME_PROFILE = [ export function ToGameStatsDocument( row: Selection, -): MONGO_UserGameStats { - const { game, playtype } = V3ToGamePT(row.game); - +): UserGameStats { return { userID: row.user_id, - game, - playtype, - ratings: row.ratings as Partial>, + game: row.game, + ratings: row.ratings as Partial>, classes: row.classes as AnyClasses, }; } diff --git a/typescript/server/src/lib/db-formats/import-document.test.ts b/typescript/server/src/lib/db-formats/import-document.test.ts index cf843df09..63f735a56 100644 --- a/typescript/server/src/lib/db-formats/import-document.test.ts +++ b/typescript/server/src/lib/db-formats/import-document.test.ts @@ -11,7 +11,7 @@ describe("LoadImportDocumentById", () => { expect(doc).toBeUndefined(); }); - it("composes an MONGO_ImportDocument from Postgres rows", async () => { + it("composes an ImportDocument from Postgres rows", async () => { const { id: userId } = await seedUser(); const importId = `import-fmt-${Date.now()}`; const now = new Date().toISOString(); @@ -40,15 +40,13 @@ describe("LoadImportDocumentById", () => { expect(doc).toMatchObject({ importID: importId, userID: userId, - game: "iidx", + gameGroup: "iidx", + games: ["iidx-sp"], importType: "ir/direct-manual", userIntent: true, errors: [{ type: "T", message: "m" }], - gptStrings: expect.arrayContaining(["iidx:SP"]), goalInfo: [], questInfo: [], }); - - expect(doc?.playtypes).toContain("SP"); }); }); diff --git a/typescript/server/src/lib/db-formats/import-document.ts b/typescript/server/src/lib/db-formats/import-document.ts index 6b518c2f7..a33a760ee 100644 --- a/typescript/server/src/lib/db-formats/import-document.ts +++ b/typescript/server/src/lib/db-formats/import-document.ts @@ -24,12 +24,9 @@ export const SELECT_IMPORT_TRACKER = [ import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type ClassDelta, - GetGPTString, - type GPTString, + type ImportDocument, + type ImportTrackerDocument, type ImportTypes, - type MONGO_ImportDocument, - type MONGO_ImportTrackerDocument, - V3ToGamePT, } from "tachi-common"; function mongoImportDocumentFromParts( @@ -39,36 +36,15 @@ function mongoImportDocumentFromParts( classes: Array<{ game: Game; new: string; prev: string | null; set: string }>, sessions: Array<{ session_id: string; type: string }>, scoreIds: Array, -): MONGO_ImportDocument { - const gptStringSet = new Set(); +): ImportDocument { + const gamesList = [...new Set(games.map((g) => g.game))]; - for (const g of games) { - const { game, playtype } = V3ToGamePT(g.game); - - gptStringSet.add(GetGPTString(game, playtype)); - } - - const gptStrings = [...gptStringSet]; - - const playtypeSet = new Set( - games.map((g) => { - const { playtype } = V3ToGamePT(g.game); - - return playtype; - }), - ); - - const classDeltas: Array = classes.map((c) => { - const { game, playtype } = V3ToGamePT(c.game); - - return { - game, - playtype, - set: c.set as ClassDelta["set"], - old: c.prev, - new: c.new, - }; - }); + const classDeltas: Array = classes.map((c) => ({ + game: c.game, + set: c.set as ClassDelta["set"], + old: c.prev, + new: c.new, + })); const createdSessions = sessions.map((s) => { const t = s.type; @@ -85,11 +61,10 @@ function mongoImportDocumentFromParts( userID: base.user_id, timeStarted: ISO8601ToUnixMilliseconds(base.time_started), timeFinished: ISO8601ToUnixMilliseconds(base.time_finished), - gptStrings, importID: base.id, scoreIDs: scoreIds, - game: base.game_group, - playtypes: [...playtypeSet] as MONGO_ImportDocument["playtypes"], + gameGroup: base.game_group, + games: gamesList, errors: errors.map((e) => ({ message: e.message, type: e.type })), createdSessions, importType: base.import_type as ImportTypes, @@ -117,10 +92,10 @@ function parseStoredTrackerError(err: unknown): { message: string; statusCode?: } /** - * Maps a Postgres `import_tracker` row to the API {@link MONGO_ImportTrackerDocument} shape. + * Maps a Postgres `import_tracker` row to the API {@link ImportTrackerDocument} shape. * Rows with `error` set are {@link ImportTrackerFailed}; otherwise {@link ImportTrackerOngoing}. */ -export function ToImportTrackerDocument(row: ImportTracker): MONGO_ImportTrackerDocument { +export function ToImportTrackerDocument(row: ImportTracker): ImportTrackerDocument { const base = { timeStarted: ISO8601ToUnixMilliseconds(row.time_started), importID: row.import_id, @@ -141,12 +116,12 @@ export function ToImportTrackerDocument(row: ImportTracker): MONGO_ImportTracker } /** - * Build a full {@link MONGO_ImportDocument} from normalized Postgres import tables. + * Build a full {@link ImportDocument} from normalized Postgres import tables. * `goalInfo` / `questInfo` are always empty (historical import_goal / import_quest were not migrated). */ export async function LoadImportDocumentById( importID: string, -): Promise { +): Promise { const base = await DB.selectFrom("import") .select(SELECT_IMPORT) .where("import.id", "=", importID) @@ -188,7 +163,7 @@ export async function ListRecentImportDocuments(opts: { limit: number; userId?: number; userIntent?: boolean; -}): Promise { +}): Promise { let q = DB.selectFrom("import").select(SELECT_IMPORT); if (opts.userId !== undefined) { @@ -302,7 +277,7 @@ export async function ListFailedImportTrackers(opts: { limit: number; userId?: number; userIntent?: boolean; -}): Promise { +}): Promise { let q = DB.selectFrom("import_tracker") .select(SELECT_IMPORT_TRACKER) .where("import_tracker.error", "is not", null); @@ -326,7 +301,7 @@ export async function ListFailedImportTrackers(opts: { export async function GetImportTrackerByImportId( importID: string, -): Promise { +): Promise { const row = await DB.selectFrom("import_tracker") .select(SELECT_IMPORT_TRACKER) .where("import_tracker.import_id", "=", importID) @@ -339,4 +314,4 @@ export async function GetImportTrackerByImportId( return ToImportTrackerDocument(row); } -type SessionInfoType = MONGO_ImportDocument["createdSessions"][number]["type"]; +type SessionInfoType = ImportDocument["createdSessions"][number]["type"]; diff --git a/typescript/server/src/lib/db-formats/invite.ts b/typescript/server/src/lib/db-formats/invite.ts index e9113bcae..25d813d81 100644 --- a/typescript/server/src/lib/db-formats/invite.ts +++ b/typescript/server/src/lib/db-formats/invite.ts @@ -1,6 +1,6 @@ import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Selection } from "kysely"; -import { type MONGO_InviteCodeDocument } from "tachi-common"; +import { type InviteCodeDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_INVITE = [ @@ -14,7 +14,7 @@ export const SELECT_INVITE = [ export function ToInviteDocument( row: Selection, -): MONGO_InviteCodeDocument { +): InviteCodeDocument { const base = { code: row.code, createdBy: row.created_by, diff --git a/typescript/server/src/lib/db-formats/kai-auth-token.ts b/typescript/server/src/lib/db-formats/kai-auth-token.ts index 42e1efd91..541f4ad45 100644 --- a/typescript/server/src/lib/db-formats/kai-auth-token.ts +++ b/typescript/server/src/lib/db-formats/kai-auth-token.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type MONGO_KaiAuthDocument } from "tachi-common"; +import { type KaiAuthDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_KAI_AUTH_TOKEN = [ @@ -11,10 +11,10 @@ export const SELECT_KAI_AUTH_TOKEN = [ export function ToKaiAuthDocument( row: Selection, -): MONGO_KaiAuthDocument { +): KaiAuthDocument { return { userID: row.user_id, - service: row.service as MONGO_KaiAuthDocument["service"], + service: row.service as KaiAuthDocument["service"], token: row.token, refreshToken: row.refresh_token, }; diff --git a/typescript/server/src/lib/db-formats/kshook-sv6c-settings.ts b/typescript/server/src/lib/db-formats/kshook-sv6c-settings.ts index 1ea07d58f..4ebd02ed6 100644 --- a/typescript/server/src/lib/db-formats/kshook-sv6c-settings.ts +++ b/typescript/server/src/lib/db-formats/kshook-sv6c-settings.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type MONGO_KsHookSettingsDocument } from "tachi-common"; +import { type KsHookSettingsDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_KSHOOK_SV6C_SETTINGS = [ @@ -13,7 +13,7 @@ export function ToKshookSv6cSettings( "svc_kshook_sv6c_settings", (typeof SELECT_KSHOOK_SV6C_SETTINGS)[number] >, -): MONGO_KsHookSettingsDocument { +): KsHookSettingsDocument { return { userID: row.user_id, forceStaticImport: row.force_static_import, diff --git a/typescript/server/src/lib/db-formats/myt-card-info.ts b/typescript/server/src/lib/db-formats/myt-card-info.ts index c4c35d7b4..a5102035d 100644 --- a/typescript/server/src/lib/db-formats/myt-card-info.ts +++ b/typescript/server/src/lib/db-formats/myt-card-info.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type MONGO_MytCardInfo } from "tachi-common"; +import { type MytCardInfo } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_MYT_CARD_INFO = [ @@ -9,7 +9,7 @@ export const SELECT_MYT_CARD_INFO = [ export function ToMytCardInfo( row: Selection, -): MONGO_MytCardInfo { +): MytCardInfo { return { userID: row.user_id, cardAccessCode: row.card_access_code, diff --git a/typescript/server/src/lib/db-formats/notification.ts b/typescript/server/src/lib/db-formats/notification.ts index 3da92608b..3f4c88278 100644 --- a/typescript/server/src/lib/db-formats/notification.ts +++ b/typescript/server/src/lib/db-formats/notification.ts @@ -1,4 +1,4 @@ -import type { MONGO_NotificationDocument, NotificationBody } from "tachi-common"; +import type { NotificationBody, NotificationDocument } from "tachi-common"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Selection } from "kysely"; @@ -16,7 +16,7 @@ export const SELECT_NOTIFICATION = [ export function ToNotificationDocument( row: Selection, -): MONGO_NotificationDocument { +): NotificationDocument { const body = row.payload as NotificationBody; return { diff --git a/typescript/server/src/lib/db-formats/pb.ts b/typescript/server/src/lib/db-formats/pb.ts index 12e5ca1ef..5d4f7e979 100644 --- a/typescript/server/src/lib/db-formats/pb.ts +++ b/typescript/server/src/lib/db-formats/pb.ts @@ -5,16 +5,9 @@ import DB from "#services/pg/db"; import { EscapeForILIKE } from "#utils/misc"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { sql } from "kysely"; -import { - type GameGroup, - type MONGO_PBScoreDocument, - type PBReference, - type Playtype, - type V3Game, - V3ToGamePT, -} from "tachi-common"; +import { type PBReference, type PBScoreDocument, type V3Game } from "tachi-common"; -/** Row shape from `pb` joined with `chart` and `song` for building a {@link MONGO_PBScoreDocument}. */ +/** Row shape from `pb` joined with `chart` and `song` for building a {@link PBScoreDocument}. */ export interface PbDocumentJoinRow { row_id: string; user_id: number; @@ -31,7 +24,7 @@ export interface PbDocumentJoinRow { highlight: boolean; time_achieved: string | null; chart_id: string; - song_legacy_id: number; + song_id: string; chart_game: Game; is_primary: boolean; /** From `chart_leaderboard` (window rank / count on this chart + lens). */ @@ -77,7 +70,7 @@ export const SELECT_PB_DOCUMENT_JOIN = [ "pb.highlight", "pb.time_achieved", "chart.id as chart_id", - "song.legacy_id as song_legacy_id", + "song.id as song_id", "chart.game as chart_game", "chart.is_primary as is_primary", ] as const; @@ -96,9 +89,7 @@ export const SELECT_PB_DOCUMENT_WITH_LEADERBOARD = [ /** * Maps a Postgres `pb` row (+ chart/song join columns) to the legacy Mongo PB document shape. */ -export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise { - const { game, playtype } = V3ToGamePT(row.chart_game as V3Game); - +export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise { const composedRows = await DB.selectFrom("pb_composed_from") .where("pb_id", "=", row.row_id) .select("score_id") @@ -134,64 +125,21 @@ export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise { - const row = await DB.selectFrom("pb") - .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") - .innerJoin("chart", "chart.id", "pb.chart_id") - .innerJoin("song", "song.id", "chart.song_id") - .select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD) - .where("pb.user_id", "=", userId) - .where("chart.id", "=", chartLegacyId) - .executeTakeFirst(); - - if (!row) { - return null; - } - - return ToPbScoreDocument(row as PbDocumentJoinRow); -} - -/** Best PB for a user on a chart (by Postgres `chart.id`). */ -export async function LoadPbByUserAndChartPgId( - userId: number, - chartPgId: string, -): Promise { - const row = await DB.selectFrom("pb") - .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") - .innerJoin("chart", "chart.id", "pb.chart_id") - .innerJoin("song", "song.id", "chart.song_id") - .select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD) - .where("pb.user_id", "=", userId) - .where("chart.id", "=", chartPgId) - .executeTakeFirst(); - - if (!row) { - return null; - } - - return ToPbScoreDocument(row as PbDocumentJoinRow); -} - -/** Best PB for a user on a chart, matching either `chart.id` or `chart.legacy_id`. */ -export async function LoadPbByUserAndChartID( +/** Best PB for a user on a chart. */ +export async function GetPBOnChart( userID: number, chartID: string, -): Promise { +): Promise { const row = await DB.selectFrom("pb") .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") .innerJoin("chart", "chart.id", "pb.chart_id") @@ -213,7 +161,7 @@ export async function LoadPbsForUserOnChartsByPgIds( userId: number, chartPgIds: string[], opts?: { limit?: number }, -): Promise { +): Promise { if (chartPgIds.length === 0) { return []; } @@ -233,7 +181,7 @@ export async function LoadPbsForUserOnChartsByPgIds( } /** Every PB on a chart (Postgres `chart.id`), e.g. beatoraja IR leaderboard. */ -export async function LoadAllPbsForChartPgId(chartPgId: string): Promise { +export async function LoadAllPbsForChartPgId(chartPgId: string): Promise { const rows = await DB.selectFrom("pb") .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") .innerJoin("chart", "chart.id", "pb.chart_id") @@ -249,7 +197,7 @@ export async function LoadAllPbsForChartPgId(chartPgId: string): Promise { +): Promise { const rows = await DB.selectFrom("pb") .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") .innerJoin("chart", "chart.id", "pb.chart_id") @@ -270,7 +218,7 @@ export async function LoadPbDocumentsForUserPrimaryChartsSortedByAlg( v3Game: Game, alg: string, limit: number, -): Promise { +): Promise { const rows = await DB.selectFrom("pb") .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") .innerJoin("chart", "chart.id", "pb.chart_id") @@ -295,7 +243,7 @@ export async function LoadPbDocumentsForGameSortedByCalculatedAlg( v3Game: Game, alg: string, limit: number, -): Promise { +): Promise { const rows = await DB.selectFrom("pb") .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") .innerJoin("chart", "chart.id", "pb.chart_id") @@ -321,7 +269,7 @@ export async function LoadPbDocumentsForUserSetSortedByCalculatedAlg( v3Game: Game, alg: string, limit: number, -): Promise { +): Promise { if (userIds.length === 0) { return []; } @@ -346,7 +294,7 @@ export async function LoadPbDocumentsForUserSetSortedByCalculatedAlg( export async function LoadPbsByUserIdsAndChartPgId( userIds: number[], chartPgId: string, -): Promise { +): Promise { if (userIds.length === 0) { return []; } @@ -364,18 +312,15 @@ export async function LoadPbsByUserIdsAndChartPgId( } /** - * Top-ranked PB on a chart (best `ranking_value`), used as a stand-in for Mongo - * `rankingData.rank === 1` server-record semantics when per-user rank is populated separately. + * Top-ranked PB on a chart (best `ranking_value`) */ -export async function LoadPbServerRecordForChartLegacyId( - chartLegacyId: string, -): Promise { +export async function GetServerRecordOnChart(chartID: string): Promise { const row = await DB.selectFrom("pb") .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") .innerJoin("chart", "chart.id", "pb.chart_id") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD) - .where("chart.id", "=", chartLegacyId) + .where("chart.id", "=", chartID) .orderBy("pb.ranking_value", "desc") .limit(1) .executeTakeFirst(); @@ -389,11 +334,11 @@ export async function LoadPbServerRecordForChartLegacyId( /** PBs on a chart with leaderboard rank strictly above / below the user’s rank (USC-style ladders). */ export async function LoadPbsAdjacentByChartRank( - chartLegacyId: string, + chartID: string, userRank: number, dir: "above" | "below", limit: number, -): Promise> { +): Promise> { const rankExpr = sql`chart_leaderboard.rank`; const cmp = dir === "above" @@ -406,7 +351,7 @@ export async function LoadPbsAdjacentByChartRank( .innerJoin("chart", "chart.id", "pb.chart_id") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD) - .where("chart.id", "=", chartLegacyId) + .where("chart.id", "=", chartID) .where(cmp) .orderBy(rankExpr, order) .limit(limit); @@ -416,16 +361,14 @@ export async function LoadPbsAdjacentByChartRank( return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow))); } -/** PB with ladder rank 1 on a chart (USC IR “server record” / GET record semantics). */ -export async function LoadPbRankOneOnChartLegacyId( - chartLegacyId: string, -): Promise { +/** PB with ladder rank 1 on a chart */ +export async function LoadPbRankOneOnChartID(chartID: string): Promise { const row = await DB.selectFrom("pb") .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") .innerJoin("chart", "chart.id", "pb.chart_id") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD) - .where("chart.id", "=", chartLegacyId) + .where("chart.id", "=", chartID) .where(sql`chart_leaderboard.rank = 1`) .executeTakeFirst(); @@ -440,7 +383,7 @@ export async function LoadPbRankOneOnChartLegacyId( export async function LoadPbsOnChartByRankingValueDesc( chartLegacyId: string, limit: number, -): Promise> { +): Promise> { const rows = await DB.selectFrom("pb") .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") .innerJoin("chart", "chart.id", "pb.chart_id") @@ -473,7 +416,7 @@ export async function LoadPbsOnChartByRankAsc( chartLegacyId: string, startRanking: number, limit: number, -): Promise> { +): Promise> { const rankExpr = sql`chart_leaderboard.rank`; const rows = await DB.selectFrom("pb") @@ -498,7 +441,7 @@ export async function LoadPbsOnChartByRankAsc( export async function LoadPbsOnChartForUserSearch( chartLegacyId: string, search: string, -): Promise> { +): Promise> { const likeEsc = EscapeForILIKE(search.toLowerCase()); const pattern = `%${likeEsc}%`; diff --git a/typescript/server/src/lib/db-formats/questline.ts b/typescript/server/src/lib/db-formats/questline.ts index f0ba9320e..eab6d2ee4 100644 --- a/typescript/server/src/lib/db-formats/questline.ts +++ b/typescript/server/src/lib/db-formats/questline.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type MONGO_QuestlineDocument, V3ToGamePT } from "tachi-common"; +import { type QuestlineDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_QUESTLINE = [ @@ -22,15 +22,12 @@ export type QuestlineRow = Selection, -): MONGO_QuestlineDocument { - const { game, playtype } = V3ToGamePT(row.questline_game); - +): QuestlineDocument { return { questlineID: row.questline_id, name: row.questline_name, desc: row.questline_description, - game, - playtype, + game: row.questline_game, quests: questIdsOrdered, }; } diff --git a/typescript/server/src/lib/db-formats/score.ts b/typescript/server/src/lib/db-formats/score.ts index 104f11bf0..7897dec0a 100644 --- a/typescript/server/src/lib/db-formats/score.ts +++ b/typescript/server/src/lib/db-formats/score.ts @@ -1,15 +1,10 @@ import { pgScoreDataToMongo } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; -import { - type GameGroup, - type ImportTypes, - type MONGO_ScoreDocument, - V3ToGamePT, -} from "tachi-common"; +import { type GameGroup, type ImportTypes, type ScoreDocument } from "tachi-common"; import { type Game } from "tachi-db"; -/** Columns from `score` joined with chart/song for a full {@link MONGO_ScoreDocument}. */ +/** Columns from `score` joined with chart/song for a full {@link ScoreDocument}. */ export const SELECT_SCORE_DOCUMENT = [ "score.id as score_id", "score.user_id as score_user_id", @@ -25,7 +20,7 @@ export const SELECT_SCORE_DOCUMENT = [ "score.comment as score_comment", "chart.id as chart_id", "chart.is_primary as chart_is_primary", - "song.legacy_id as song_legacy_id", + "song.id as song_id", "import.service as import_service", "import.import_type as import_import_type", ] as const; @@ -46,23 +41,21 @@ export interface ScoreDocumentJoinRow { score_comment: string | null; chart_id: string; chart_is_primary: boolean; - song_legacy_id: number; + song_id: string; import_service: string | null; import_import_type: string | null; } -export function ToScoreDocument(row: ScoreDocumentJoinRow): MONGO_ScoreDocument { - const { game, playtype } = V3ToGamePT(row.score_game); - +export function ToScoreDocument(row: ScoreDocumentJoinRow): ScoreDocument { const scoreData = pgScoreDataToMongo(row.score_game, { data: row.score_data as any, derived: row.score_derived_data as any, judgements: row.score_judgements as any, }); - const scoreMeta = row.score_meta as MONGO_ScoreDocument["scoreMeta"]; + const scoreMeta = row.score_meta as ScoreDocument["scoreMeta"]; - const calculatedData = row.score_calculated_data as MONGO_ScoreDocument["calculatedData"]; + const calculatedData = row.score_calculated_data as ScoreDocument["calculatedData"]; const service = row.import_service !== null && @@ -74,8 +67,7 @@ export function ToScoreDocument(row: ScoreDocumentJoinRow): MONGO_ScoreDocument return { // todo(?) service, - game, - playtype, + game: row.score_game, userID: row.score_user_id, scoreData, scoreMeta: scoreMeta ?? {}, @@ -83,7 +75,7 @@ export function ToScoreDocument(row: ScoreDocumentJoinRow): MONGO_ScoreDocument timeAchieved: row.score_time_achieved ? ISO8601ToUnixMilliseconds(row.score_time_achieved) : null, - songID: row.song_legacy_id, + songID: row.song_id, chartID: row.chart_id, isPrimary: row.chart_is_primary, highlight: row.score_highlight, @@ -94,9 +86,7 @@ export function ToScoreDocument(row: ScoreDocumentJoinRow): MONGO_ScoreDocument }; } -export async function LoadScoreDocumentById( - scoreID: string, -): Promise { +export async function LoadScoreDocumentById(scoreID: string): Promise { const row = await DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") .innerJoin("song", "song.id", "chart.song_id") @@ -113,9 +103,7 @@ export async function LoadScoreDocumentById( } /** All scores linked to a Postgres `import.id` (`score.import_id`). */ -export async function LoadScoreDocumentsForImport( - importId: string, -): Promise> { +export async function LoadScoreDocumentsForImport(importId: string): Promise> { const rows = await DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") .innerJoin("song", "song.id", "chart.song_id") @@ -133,7 +121,7 @@ export async function LoadScoreDocumentsForImport( export async function LoadScoreDocumentsByChartKeyAndGameGroup( game: GameGroup, chartID: string, -): Promise> { +): Promise> { const rows = await DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") .innerJoin("song", "song.id", "chart.song_id") diff --git a/typescript/server/src/lib/db-formats/session.full.test.ts b/typescript/server/src/lib/db-formats/session.full.test.ts index 5b643c74b..237892d1e 100644 --- a/typescript/server/src/lib/db-formats/session.full.test.ts +++ b/typescript/server/src/lib/db-formats/session.full.test.ts @@ -90,8 +90,7 @@ describe("LoadSessionDocumentById", () => { userID: userId, name: "N", scoreIDs: [scoreId], - game: "iidx", - playtype: "SP", + game: "iidx-sp", }); }); }); diff --git a/typescript/server/src/lib/db-formats/session.ts b/typescript/server/src/lib/db-formats/session.ts index b11c2c017..2cfc8f081 100644 --- a/typescript/server/src/lib/db-formats/session.ts +++ b/typescript/server/src/lib/db-formats/session.ts @@ -1,7 +1,7 @@ import DB from "#services/pg/db"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Selection } from "kysely"; -import { type MONGO_SessionDocument, V3ToGamePT } from "tachi-common"; +import { type SessionDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_SESSION_DOCUMENT = [ @@ -20,13 +20,11 @@ export const SELECT_SESSION_DOCUMENT = [ export function ToSessionDocument( row: Selection, scoreIDs: Array, -): MONGO_SessionDocument { - const { game, playtype } = V3ToGamePT(row.game); - +): SessionDocument { const calculatedData = typeof row.calculated_data === "string" - ? (JSON.parse(row.calculated_data) as MONGO_SessionDocument["calculatedData"]) - : (row.calculated_data as MONGO_SessionDocument["calculatedData"]); + ? (JSON.parse(row.calculated_data) as SessionDocument["calculatedData"]) + : (row.calculated_data as SessionDocument["calculatedData"]); return { userID: row.user_id, @@ -34,8 +32,7 @@ export function ToSessionDocument( scoreIDs, name: row.name, desc: row.description, - game, - playtype, + game: row.game, timeInserted: ISO8601ToUnixMilliseconds(row.time_inserted), timeStarted: ISO8601ToUnixMilliseconds(row.time_started), timeEnded: ISO8601ToUnixMilliseconds(row.time_ended), @@ -46,7 +43,7 @@ export function ToSessionDocument( export async function LoadSessionDocumentById( sessionID: string, -): Promise { +): Promise { const row = await DB.selectFrom("session") .select(SELECT_SESSION_DOCUMENT) .where("id", "=", sessionID) @@ -78,15 +75,13 @@ export const SELECT_SESSION_CALENDAR = [ ] as const; export type SessionCalendarDocument = Pick< - MONGO_SessionDocument, - "desc" | "game" | "highlight" | "name" | "playtype" | "sessionID" | "timeEnded" | "timeStarted" + SessionDocument, + "desc" | "game" | "highlight" | "name" | "sessionID" | "timeEnded" | "timeStarted" >; export function ToSessionCalendarDocument( row: Selection, ): SessionCalendarDocument { - const { game, playtype } = V3ToGamePT(row.game); - return { sessionID: row.id, name: row.name, @@ -94,7 +89,6 @@ export function ToSessionCalendarDocument( highlight: row.highlight, timeStarted: ISO8601ToUnixMilliseconds(row.time_started), timeEnded: ISO8601ToUnixMilliseconds(row.time_ended), - game, - playtype, + game: row.game, }; } diff --git a/typescript/server/src/lib/db-formats/song.ts b/typescript/server/src/lib/db-formats/song.ts index ed7ca5827..40d9cf527 100644 --- a/typescript/server/src/lib/db-formats/song.ts +++ b/typescript/server/src/lib/db-formats/song.ts @@ -1,4 +1,4 @@ -import type { GameGroup, integer, MONGO_SongDocument, SongDocumentData } from "tachi-common"; +import type { GameGroup, SongDocument, SongDocumentData } from "tachi-common"; import DB from "#services/pg/db"; import { type Selection } from "kysely"; @@ -6,7 +6,6 @@ import { type Database } from "tachi-db"; export const SELECT_SONG_DOCUMENT = [ "song.id as song_id", - "song.legacy_id as song_legacy_id", "song.title as song_title", "song.artist as song_artist", "song.search_terms as song_search_terms", @@ -18,7 +17,6 @@ export const SELECT_SONG_DOCUMENT = [ /** Full `song` row for single-table queries (e.g. title search). */ export const SELECT_SONG_ROW = [ "song.id", - "song.legacy_id", "song.game_group", "song.title", "song.artist", @@ -31,18 +29,13 @@ export const SELECT_SONG_ROW = [ export type SongRow = Selection; -/** - * Fetches a song by its legacy numeric ID (from the URL / Mongo era), including - * `search_terms` / `alt_titles` arrays, and returns a fully-formed MONGO_SongDocument - * plus the Postgres UUID needed for downstream chart queries. - */ -export async function GetSongByLegacyID( +export async function GetSongByID( game: GameGroup, - legacyId: number, -): Promise<{ doc: MONGO_SongDocument; newSongID: string } | undefined> { + id: string, +): Promise<{ doc: SongDocument; newSongID: string } | undefined> { const row = await DB.selectFrom("song") .select(SELECT_SONG_DOCUMENT) - .where("legacy_id", "=", legacyId) + .where("song.id", "=", id) .where("game_group", "=", game) .executeTakeFirst(); @@ -50,8 +43,8 @@ export async function GetSongByLegacyID( return undefined; } - const doc: MONGO_SongDocument = { - id: row.song_legacy_id, + const doc: SongDocument = { + id: row.song_id, title: row.song_title, artist: row.song_artist, searchTerms: row.song_search_terms, @@ -62,60 +55,43 @@ export async function GetSongByLegacyID( return { doc, newSongID: row.song_id }; } -/** - * Batch-loads song documents by legacy numeric IDs (order follows first occurrence in `legacyIds`). - */ -export async function GetSongsByLegacyIDs( - game: GameGroup, - legacyIds: Array, -): Promise> { - if (legacyIds.length === 0) { +export async function GetSongsByIDs(ids: Array): Promise> { + if (ids.length === 0) { return []; } - const unique = [...new Set(legacyIds)]; + const unique = [...new Set(ids)]; const songRows = await DB.selectFrom("song") .select(SELECT_SONG_DOCUMENT) - .where("game_group", "=", game) - .where("legacy_id", "in", unique) + .where("song.id", "in", unique) .execute(); if (songRows.length === 0) { return []; } - const byLegacy = new Map(); + const out = []; for (const row of songRows) { - byLegacy.set(row.song_legacy_id, { - id: row.song_legacy_id, + out.push({ + id: row.song_id, title: row.song_title, artist: row.song_artist, searchTerms: row.song_search_terms, altTitles: row.song_alt_titles, - data: row.song_data as SongDocumentData[typeof game], + data: row.song_data as SongDocumentData[GameGroup], }); } - const out: Array = []; - - for (const id of legacyIds) { - const doc = byLegacy.get(id); - - if (doc) { - out.push(doc); - } - } - return out; } export function ToSongDocument( row: Selection, -): MONGO_SongDocument { +): SongDocument { return { - id: row.song_legacy_id, + id: row.song_id, title: row.song_title, artist: row.song_artist, searchTerms: row.song_search_terms, @@ -124,9 +100,9 @@ export function ToSongDocument( }; } -export function ToSongDocumentFromRow(row: SongRow): MONGO_SongDocument { +export function ToSongDocumentFromRow(row: SongRow): SongDocument { return { - id: row.legacy_id, + id: row.id, title: row.title, artist: row.artist, searchTerms: row.search_terms, diff --git a/typescript/server/src/lib/db-formats/table.ts b/typescript/server/src/lib/db-formats/table.ts index 0a9e40d3d..61b8052a6 100644 --- a/typescript/server/src/lib/db-formats/table.ts +++ b/typescript/server/src/lib/db-formats/table.ts @@ -1,7 +1,6 @@ import DB from "#services/pg/db"; -import { toPgGame } from "#services/pg/seeds"; import { type Selection } from "kysely"; -import { type GameGroup, type MONGO_TableDocument, type Playtype, V3ToGamePT } from "tachi-common"; +import { type TableDocument, type V3Game } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_TABLE = [ @@ -16,44 +15,40 @@ export const SELECT_TABLE = [ export type TableRow = Selection; -export function ToTableDocument(row: TableRow, folderIds: Array): MONGO_TableDocument { - const { game, playtype } = V3ToGamePT(row.game); - +export function ToTableDocument(row: TableRow, folderSlugs: Array): TableDocument { return { tableID: row.legacy_id, - game, - playtype, + game: row.game, title: row.title, description: "", - folders: folderIds, + folders: folderSlugs, inactive: row.inactive, default: row.default_value, }; } -async function tableRowToDocumentWithFolders(row: TableRow): Promise { +async function tableRowToDocumentWithFolders(row: TableRow): Promise { const tfRows = await DB.selectFrom("table_folder") - .select("folder_id") - .where("table_id", "=", row.id) + .innerJoin("folder", "folder.id", "table_folder.folder_id") + .select(["folder.slug", "table_folder.ordering"]) + .where("table_folder.table_id", "=", row.id) + .orderBy("table_folder.ordering", "asc") .execute(); return ToTableDocument( row, - tfRows.map((t) => t.folder_id), + tfRows.map((t) => t.slug), ); } /** - * Load table documents for a game/playtype (API shape). No folder *documents* — only folder IDs. + * Load table documents for a game/playtype (API shape). `folders` are folder **slugs** in order. */ -export async function GetTableDocumentsForGamePlaytype( - game: GameGroup, - playtype: Playtype, +export async function GetTableDocumentsForGame( + game: V3Game, includeInactive: boolean, -): Promise> { - const pgGame = toPgGame(game, playtype); - - let q = DB.selectFrom("table").select(SELECT_TABLE).where("game", "=", pgGame); +): Promise> { + let q = DB.selectFrom("table").select(SELECT_TABLE).where("game", "=", game); if (!includeInactive) { q = q.where("inactive", "=", false); @@ -68,8 +63,11 @@ export async function GetTableDocumentsForGamePlaytype( const tableIds = rows.map((r) => r.id); const tfRows = await DB.selectFrom("table_folder") - .select(["table_id", "folder_id"]) - .where("table_id", "in", tableIds) + .innerJoin("folder", "folder.id", "table_folder.folder_id") + .select(["table_folder.table_id", "folder.slug", "table_folder.ordering"]) + .where("table_folder.table_id", "in", tableIds) + .orderBy("table_folder.table_id", "asc") + .orderBy("table_folder.ordering", "asc") .execute(); const foldersByTable = new Map>(); @@ -77,7 +75,7 @@ export async function GetTableDocumentsForGamePlaytype( for (const t of tfRows) { const list = foldersByTable.get(t.table_id) ?? []; - list.push(t.folder_id); + list.push(t.slug); foldersByTable.set(t.table_id, list); } @@ -85,12 +83,12 @@ export async function GetTableDocumentsForGamePlaytype( } /** - * Load one table by API `tableID` (`table.legacy_id`), with folder ids from `table_folder` - * (same join pattern as {@link GetTableDocumentsForGamePlaytype}). + * Load one table by API `tableID` (`table.legacy_id`), with folder slugs from `table_folder` + * (same join pattern as {@link GetTableDocumentsForGame}). */ export async function LoadTableDocumentByLegacyId( legacyId: string, -): Promise { +): Promise { const row = await DB.selectFrom("table") .select(SELECT_TABLE) .where("legacy_id", "=", legacyId) @@ -107,17 +105,14 @@ export async function LoadTableDocumentByLegacyId( * Load one table by API `tableID` for a specific game playtype route (matches Mongo * `tables.findOne({ tableID, game, playtype })`). */ -export async function LoadTableDocumentByLegacyIdForGamePlaytype( +export async function LoadTableDocumentByLegacyIdForGame( legacyId: string, - game: GameGroup, - playtype: Playtype, -): Promise { - const pgGame = toPgGame(game, playtype); - + game: V3Game, +): Promise { const row = await DB.selectFrom("table") .select(SELECT_TABLE) .where("legacy_id", "=", legacyId) - .where("game", "=", pgGame) + .where("game", "=", game) .executeTakeFirst(); if (!row) { diff --git a/typescript/server/src/lib/db-formats/target-documents.ts b/typescript/server/src/lib/db-formats/target-documents.ts index 28331442b..ab714c15b 100644 --- a/typescript/server/src/lib/db-formats/target-documents.ts +++ b/typescript/server/src/lib/db-formats/target-documents.ts @@ -1,37 +1,58 @@ +import { LoadFolderDocumentsByIds } from "#lib/db-formats/folders"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, - V3ToGamePT, + type GoalDocument, + type GoalSubscriptionDocument, + type QuestDocument, + type QuestSubscriptionDocument, } from "tachi-common"; -import { type Game } from "tachi-db"; import { type GoalRow, type GoalSubWithGoalGameRow } from "./goal"; import { type QuestRow, type QuestSubWithQuestGameRow } from "./quest"; -export function ToGoalDocument(row: GoalRow): MONGO_GoalDocument { - const { game, playtype } = V3ToGamePT(row.game); +/** + * Enriches folder-type goals with `charts.folderSlug` for API consumers (navigation). + * `charts.data` remains the internal folder id. + */ +export async function AttachFolderSlugsToGoals(goals: Array): Promise { + const ids: string[] = []; - return { - goalID: row.id, - game, - playtype, - name: row.name, - charts: row.charts as MONGO_GoalDocument["charts"], - criteria: row.criteria as MONGO_GoalDocument["criteria"], - } as MONGO_GoalDocument; + for (const g of goals) { + if (g.charts.type === "folder") { + ids.push(g.charts.data); + } + } + + if (ids.length === 0) { + return; + } + + const map = await LoadFolderDocumentsByIds(ids); + + for (const g of goals) { + if (g.charts.type === "folder") { + const folder = map.get(g.charts.data); + + if (folder) { + g.charts.folderSlug = folder.slug; + } + } + } } -export function ToGoalSubscriptionDocument( - row: GoalSubWithGoalGameRow, -): MONGO_GoalSubscriptionDocument { - const { game, playtype } = V3ToGamePT(row.goal_game as Game); +export function ToGoalDocument(row: GoalRow): GoalDocument { + return { + goalID: row.id, + game: row.game, + name: row.name, + charts: row.charts as GoalDocument["charts"], + criteria: row.criteria as GoalDocument["criteria"], + } as GoalDocument; +} +export function ToGoalSubscriptionDocument(row: GoalSubWithGoalGameRow): GoalSubscriptionDocument { const base = { - game, - playtype, + game: row.goal_game, goalID: row.goal_id, userID: row.user_id, lastInteraction: row.last_interaction @@ -56,27 +77,21 @@ export function ToGoalSubscriptionDocument( }; } -export function ToQuestDocument(row: QuestRow): MONGO_QuestDocument { - const { game, playtype } = V3ToGamePT(row.game); - +export function ToQuestDocument(row: QuestRow): QuestDocument { return { questID: row.id, - game, - playtype, + game: row.game, name: row.name, desc: row.description, - questData: row.quest_data as MONGO_QuestDocument["questData"], + questData: row.quest_data as QuestDocument["questData"], }; } export function ToQuestSubscriptionDocument( row: QuestSubWithQuestGameRow, -): MONGO_QuestSubscriptionDocument { - const { game, playtype } = V3ToGamePT(row.quest_game as Game); - +): QuestSubscriptionDocument { const base = { - game, - playtype, + game: row.quest_game, questID: row.quest_id, userID: row.user_id, progress: row.progress, diff --git a/typescript/server/src/lib/db-formats/ugpt-settings.ts b/typescript/server/src/lib/db-formats/ugpt-settings.ts index 6820743c8..1a064ec54 100644 --- a/typescript/server/src/lib/db-formats/ugpt-settings.ts +++ b/typescript/server/src/lib/db-formats/ugpt-settings.ts @@ -1,13 +1,11 @@ import DB from "#services/pg/db"; import { type Selection } from "kysely"; import { - type GameGroup, - GamePTToV3, type integer, - type MONGO_UGPTSettingsDocument, - type Playtype, + type ShowcaseStatChart, type ShowcaseStatDetails, - V3ToGamePT, + type UGPTSettingsDocument, + type V3Game, } from "tachi-common"; import { type Database } from "tachi-db"; @@ -33,26 +31,24 @@ export function ToUGPTSettingsDocument( row: GameSettingsRow, rivals: Array, stats: Array, -): MONGO_UGPTSettingsDocument { - const { game, playtype } = V3ToGamePT(row.game); - const gameSpecific = row.data as MONGO_UGPTSettingsDocument["preferences"]["gameSpecific"]; +): UGPTSettingsDocument { + const gameSpecific = row.data as UGPTSettingsDocument["preferences"]["gameSpecific"]; return { userID: row.user_id, - game, - playtype, + game: row.game, preferences: { defaultTable: row.pf_default_table, gameSpecific, preferredDefaultEnum: row.pf_preferred_default_enum, preferredProfileAlg: - row.pf_preferred_profile_alg as MONGO_UGPTSettingsDocument["preferences"]["preferredProfileAlg"], + row.pf_preferred_profile_alg as UGPTSettingsDocument["preferences"]["preferredProfileAlg"], preferredRanking: - row.pf_preferred_ranking as MONGO_UGPTSettingsDocument["preferences"]["preferredRanking"], + row.pf_preferred_ranking as UGPTSettingsDocument["preferences"]["preferredRanking"], preferredScoreAlg: - row.pf_preferred_score_alg as MONGO_UGPTSettingsDocument["preferences"]["preferredScoreAlg"], + row.pf_preferred_score_alg as UGPTSettingsDocument["preferences"]["preferredScoreAlg"], preferredSessionAlg: - row.pf_preferred_session_alg as MONGO_UGPTSettingsDocument["preferences"]["preferredSessionAlg"], + row.pf_preferred_session_alg as UGPTSettingsDocument["preferences"]["preferredSessionAlg"], stats, }, rivals, @@ -64,15 +60,12 @@ export function ToUGPTSettingsDocument( */ export async function GetUGPTSettingsDocument( userID: integer, - game: GameGroup, - playtype: Playtype, -): Promise { - const v3Game = GamePTToV3(game, playtype); - + game: V3Game, +): Promise { const row = await DB.selectFrom("game_settings") .select(SELECT_GAME_SETTINGS) .where("game_settings.user_id", "=", userID) - .where("game_settings.game", "=", v3Game) + .where("game_settings.game", "=", game) .executeTakeFirst(); if (!row) { @@ -82,7 +75,7 @@ export async function GetUGPTSettingsDocument( const rivalRows = await DB.selectFrom("game_rival") .select("rival") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .execute(); const rivals = rivalRows.map((r) => r.rival); @@ -90,10 +83,21 @@ export async function GetUGPTSettingsDocument( const showcaseRow = await DB.selectFrom("game_settings_showcase") .select("data") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst(); const stats = showcaseRow ? (showcaseRow.data as Array) : []; - return ToUGPTSettingsDocument(row, rivals, stats); + return ToUGPTSettingsDocument(row, rivals, normalizeShowcaseStats(stats)); +} + +/** Strips legacy `metric` from chart entries stored before chart showcase was PB+playcount-only. */ +function normalizeShowcaseStats(raw: Array): Array { + return raw.map((stat) => { + if (stat.mode === "chart") { + const s = stat as { metric?: string } & ShowcaseStatChart; + return { mode: "chart", chartID: s.chartID }; + } + return stat; + }); } diff --git a/typescript/server/src/lib/db-formats/user-settings.ts b/typescript/server/src/lib/db-formats/user-settings.ts index 3db613975..002c5a640 100644 --- a/typescript/server/src/lib/db-formats/user-settings.ts +++ b/typescript/server/src/lib/db-formats/user-settings.ts @@ -1,5 +1,5 @@ import { type Selection } from "kysely"; -import { type integer, type MONGO_UserSettingsDocument } from "tachi-common"; +import { type integer, type UserSettingsDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_USER_SETTINGS = [ @@ -14,7 +14,7 @@ export const SELECT_USER_SETTINGS = [ export function ToUserSettingsDocument( following: Array, row: Selection, -): MONGO_UserSettingsDocument { +): UserSettingsDocument { return { userID: row.user_id, following, diff --git a/typescript/server/src/lib/db-formats/user.ts b/typescript/server/src/lib/db-formats/user.ts index cf7904fa1..e2976a563 100644 --- a/typescript/server/src/lib/db-formats/user.ts +++ b/typescript/server/src/lib/db-formats/user.ts @@ -1,7 +1,7 @@ import { AuthLevelToInt } from "#utils/conversion"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Selection } from "kysely"; -import { type MONGO_UserDocument, type UserBadges } from "tachi-common"; +import { type UserBadges, type UserDocument } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_USER = [ @@ -29,7 +29,7 @@ export const SELECT_USER = [ export function ToUserDocument( row: Selection, -): MONGO_UserDocument { +): UserDocument { const badges: Array = []; if (row.bd_alpha) { diff --git a/typescript/server/src/lib/email/client.ts b/typescript/server/src/lib/email/client.ts index a1c970442..f1cc9496f 100644 --- a/typescript/server/src/lib/email/client.ts +++ b/typescript/server/src/lib/email/client.ts @@ -5,20 +5,22 @@ import nodemailer, { type SentMessageInfo, type Transporter } from "nodemailer"; let transporter: Transporter | undefined; -if (ServerConfig.EMAIL_CONFIG) { - log.info({ bootInfo: true }, `Connecting to email server...`); - const conf = ServerConfig.EMAIL_CONFIG; +const emailConf = ServerConfig.EMAIL_CONFIG; +log.info({ bootInfo: true }, `Connecting to email server...`); - try { - transporter = nodemailer.createTransport({ - newline: "unix", +try { + transporter = nodemailer.createTransport({ + newline: "unix", - logger: conf.TRANSPORT_OPS?.debug - ? bunyan.createLogger({ name: "Email Logger" }) - : undefined, - ...(conf.TRANSPORT_OPS ?? {}), - }); + logger: emailConf.TRANSPORT_OPS?.debug + ? bunyan.createLogger({ name: "Email Logger" }) + : undefined, + ...emailConf.TRANSPORT_OPS, + }); + if (Env.NODE_ENV === "test") { + log.info({ bootInfo: true }, `Skipping SMTP verify in test.`); + } else { transporter.verify((err) => { if (err) { log.fatal({ err }, `Could not connect to email server.`); @@ -27,17 +29,10 @@ if (ServerConfig.EMAIL_CONFIG) { log.info({ bootInfo: true }, `Successfully connected to email server.`); } }); - } catch (err) { - log.fatal({ err }, `Failed to create email client.`); - throw err; } -} else { - log.warn( - { - bootInfo: true, - }, - `No EMAIL_CONFIG present in conf, emails will not be sent from the server.`, - ); +} catch (err) { + log.fatal({ err }, `Failed to create email client.`); + throw err; } export function SendEmail( @@ -51,8 +46,8 @@ export function SendEmail( return; } - if (!transporter || !ServerConfig.EMAIL_CONFIG) { - log.debug(`Stubbed out SendEmail as no EMAIL_CONFIG was set.`); + if (!transporter) { + log.debug(`Stubbed out SendEmail as transporter was not initialized.`); return; } @@ -65,7 +60,6 @@ export function SendEmail( subject, html: htmlContent, text: textContent, - dkim: ServerConfig.EMAIL_CONFIG.DKIM, headers: transporter.options.headers, }) .catch((err: unknown) => { diff --git a/typescript/server/src/lib/folders/folder-query.ts b/typescript/server/src/lib/folders/folder-query.ts index 0475ee6f2..6175ccf0f 100644 --- a/typescript/server/src/lib/folders/folder-query.ts +++ b/typescript/server/src/lib/folders/folder-query.ts @@ -1,6 +1,6 @@ import type { Database } from "tachi-db"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { type Kysely, sql } from "kysely"; /** diff --git a/typescript/server/src/lib/folders/folders.test.ts b/typescript/server/src/lib/folders/folders.test.ts index 31d20fd1e..402fe4e54 100644 --- a/typescript/server/src/lib/folders/folders.test.ts +++ b/typescript/server/src/lib/folders/folders.test.ts @@ -1,11 +1,11 @@ -import type { MONGO_TableDocument } from "tachi-common"; +import type { TableDocument } from "tachi-common"; import { LoadTableDocumentByLegacyId } from "#lib/db-formats/table"; import { BuildFolderQuery, GetFoldersFromTable, GetTableForIDGuaranteed, -} from "#lib/folders/folders.js"; +} from "#lib/folders/folders"; import DB from "#services/pg/db"; import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; @@ -45,7 +45,7 @@ describe("BuildFolderQuery", () => { game: "iidx-sp", inactive: false, title: "JSON query column", - slug: null, + slug: folderId, where: JSON.stringify({ type: "charts", data: { level: "10" } }), version_filter: null, search_terms: [], @@ -110,7 +110,7 @@ describe("BuildFolderQuery", () => { game: "iidx-sp", inactive: false, title: "BFQ level 10 only", - slug: null, + slug: folderId, where: "chart.level_num = 10", version_filter: null, search_terms: [], @@ -178,7 +178,7 @@ describe("BuildFolderQuery", () => { game: "iidx-sp", inactive: false, title: "Level 10 (EPOLIS)", - slug: null, + slug: folderId, where: "chart.level_num = 10", version_filter: ["epolis"], search_terms: [], @@ -219,7 +219,7 @@ describe("Postgres table + folder helpers", () => { game: "iidx-sp", inactive: false, title, - slug: null, + slug: id, where: "chart.level_num > 0", version_filter: null, search_terms: [], @@ -241,8 +241,8 @@ describe("Postgres table + folder helpers", () => { await DB.insertInto("table_folder") .values([ - { table_id: tablePk, folder_id: folderA }, - { table_id: tablePk, folder_id: folderB }, + { table_id: tablePk, folder_id: folderA, ordering: 0 }, + { table_id: tablePk, folder_id: folderB, ordering: 1 }, ]) .execute(); @@ -250,8 +250,7 @@ describe("Postgres table + folder helpers", () => { expect(loaded).toBeDefined(); expect(loaded!.tableID).toBe(tableLegacy); - expect(loaded!.game).toBe("iidx"); - expect(loaded!.playtype).toBe("SP"); + expect(loaded!.game).toBe("iidx-sp"); expect(loaded!.folders).toEqual([folderA, folderB]); const guaranteed = await GetTableForIDGuaranteed(tableLegacy); @@ -277,7 +276,7 @@ describe("Postgres table + folder helpers", () => { game: "iidx-sp", inactive: false, title, - slug: null, + slug: id, where: "chart.level_num > 0", version_filter: null, search_terms: [], @@ -298,13 +297,12 @@ describe("Postgres table + folder helpers", () => { .execute(); await DB.insertInto("table_folder") - .values({ table_id: tablePk, folder_id: folderA }) + .values({ table_id: tablePk, folder_id: folderA, ordering: 0 }) .execute(); - const table: MONGO_TableDocument = { + const table: TableDocument = { tableID: tableLegacy, - game: "iidx", - playtype: "SP", + game: "iidx-sp", title: "Ord", description: "", folders: [folderB, folderA], diff --git a/typescript/server/src/lib/folders/folders.ts b/typescript/server/src/lib/folders/folders.ts index 8ee54b7e9..d4208ac16 100644 --- a/typescript/server/src/lib/folders/folders.ts +++ b/typescript/server/src/lib/folders/folders.ts @@ -2,43 +2,43 @@ import type { Kysely } from "kysely"; import type { Database } from "tachi-db"; import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; -import { LoadFolderDocumentsByIds } from "#lib/db-formats/folders"; +import { + LoadFolderDocumentsByGameAndSlugs, + LoadFolderDocumentsByIds, +} from "#lib/db-formats/folders"; import { LoadPbsForUserOnChartsByPgIds } from "#lib/db-formats/pb"; -import { GetSongsByLegacyIDs } from "#lib/db-formats/song"; +import { GetSongsByIDs } from "#lib/db-formats/song"; import { LoadTableDocumentByLegacyId } from "#lib/db-formats/table"; import { log } from "#lib/log/log"; import { pgScoreDataToMongo } from "#lib/v3/migration-tools"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import { GetFolderForIDGuaranteed } from "#utils/db"; import { ISO8601ToUnixMilliseconds, UnixMillisecondsToISO8601 } from "#utils/time"; import fjsh from "fast-json-stable-hash"; import { - FormatGameGroup, - type GameGroup, - GamePTToV3, - GetGamePTConfig, + type ChartDocument, + type FolderDocument, + FormatGame, + GetGameConfig, GetScoreEnumConfs, GetScoreMetrics, type integer, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_PBScoreDocument, - type MONGO_RecentlyViewedFolderDocument, - type MONGO_SongDocument, - type MONGO_TableDocument, - type Playtype, - V3ToGamePT, + type PBScoreDocument, + type RecentlyViewedFolderDocument, + type SongDocument, + type TableDocument, + type V3Game, } from "tachi-common"; import { BuildFolderQuery as BuildFolderQueryImpl, GetFolderChartIDs as GetFolderChartIDsImpl, -} from "./folder-query.js"; +} from "./folder-query"; /** Loads charts for a folder using `folder_chart_lookup` + `chart` / `song` joins. */ export async function GetFolderCharts( - folder: MONGO_FolderDocument, -): Promise<{ charts: Array }> { + folder: FolderDocument, +): Promise<{ charts: Array }> { const chartIds = await GetFolderChartIDs(folder.folderID); if (chartIds.length === 0) { @@ -57,12 +57,12 @@ export async function GetFolderCharts( } export async function GetFolderChartsAndSongs( - folder: MONGO_FolderDocument, -): Promise<{ charts: Array; songs: Array }> { + folder: FolderDocument, +): Promise<{ charts: Array; songs: Array }> { const { charts } = await GetFolderCharts(folder); - const legacyIds = [...new Set(charts.map((e) => e.songID))]; - const songs = await GetSongsByLegacyIDs(folder.game, legacyIds); + const legacyIds = [...new Set(charts.map((e) => e.song.id))]; + const songs = await GetSongsByIDs(legacyIds); return { songs, charts }; } @@ -89,12 +89,12 @@ export async function GetFolderIDsForChartId(chartId: string, db: Kysely r.folder_id); } -export async function GetFoldersFromTable(table: MONGO_TableDocument) { - const folderMap = await LoadFolderDocumentsByIds(table.folders); - const folders: Array = []; +export async function GetFoldersFromTable(table: TableDocument) { + const folderMap = await LoadFolderDocumentsByGameAndSlugs(table.game, table.folders); + const folders: Array = []; - for (const folderID of table.folders) { - const doc = folderMap.get(folderID); + for (const slug of table.folders) { + const doc = folderMap.get(slug); if (doc) { folders.push(doc); @@ -114,26 +114,26 @@ export async function GetFoldersFromTable(table: MONGO_TableDocument) { /** * Get the names of all the folders in a Tachi Table in-order. */ -export async function GetFolderNamesInOrder(table: MONGO_TableDocument): Promise> { +export async function GetFolderNamesInOrder(table: TableDocument): Promise> { const folders = await GetFoldersFromTable(table); // we have to iterate over these folders in the order the table document says // to // as bms tables are somewhat sensitive to being placed in the correct order. - const folderMap = new Map(); + const folderMap = new Map(); for (const folder of folders) { - folderMap.set(folder.folderID, folder); + folderMap.set(folder.slug, folder); } const orderedNames = []; - for (const folderID of table.folders) { - const folder = folderMap.get(folderID); + for (const slug of table.folders) { + const folder = folderMap.get(slug); if (!folder) { log.warn( - `Table '${table.title}' refers to folder '${folderID}', but no such folder exists? Ignoring.`, + `Table '${table.title}' refers to folder '${slug}', but no such folder exists? Ignoring.`, ); continue; } @@ -144,7 +144,7 @@ export async function GetFolderNamesInOrder(table: MONGO_TableDocument): Promise return orderedNames; } -export async function GetPBsOnFolder(userID: integer, folder: MONGO_FolderDocument) { +export async function GetPBsOnFolder(userID: integer, folder: FolderDocument) { const { charts, songs } = await GetFolderChartsAndSongs(folder); const chartIds = charts.map((e) => e.chartID); @@ -159,12 +159,12 @@ export async function GetPBsOnFolder(userID: integer, folder: MONGO_FolderDocume /** * Get the distribution for this all gpt enums for this user on this folder. */ -export async function GetEnumDistForFolder(userID: integer, folder: MONGO_FolderDocument) { +export async function GetEnumDistForFolder(userID: integer, folder: FolderDocument) { const pbData = await GetPBsOnFolder(userID, folder); - const gptConfig = GetGamePTConfig(folder.game, folder.playtype); + const gameConfig = GetGameConfig(folder.game); - const enumMetrics = GetScoreMetrics(gptConfig, "ENUM"); + const enumMetrics = GetScoreMetrics(gameConfig, "ENUM"); const allEnumDists: Record> = {}; @@ -174,17 +174,17 @@ export async function GetEnumDistForFolder(userID: integer, folder: MONGO_Folder const chartIDs = await GetFolderChartIDs(folder.folderID); - return { folderID: folder.folderID, chartCount: chartIDs.length, stats: allEnumDists }; + return { slug: folder.slug, chartCount: chartIDs.length, stats: allEnumDists }; } /** * Get the distribution for this all gpt enums for this user on all these folders. */ -export function GetEnumDistForFolders(userID: integer, folders: Array) { +export function GetEnumDistForFolders(userID: integer, folders: Array) { return Promise.all(folders.map((folder) => GetEnumDistForFolder(userID, folder))); } -function GetEnumDist(pbs: Array, enumMetric: string) { +function GetEnumDist(pbs: Array, enumMetric: string) { const enumDist: Record = {}; for (const pb of pbs) { @@ -201,50 +201,35 @@ function GetEnumDist(pbs: Array, enumMetric: string) { return enumDist; } -export function CreateFolderID( - query: Record, - game: GameGroup, - playtype: Playtype, -) { - return `F${fjsh.hash({ game, playtype, ...query }, "SHA256")}`; +export function CreateFolderID(query: Record, game: V3Game) { + return `F${fjsh.hash({ game, ...query }, "SHA256")}`; } -export async function GetRecentlyViewedFolders( - userID: integer, - game: GameGroup, - playtype: Playtype, -) { - const v3Game = GamePTToV3(game, playtype); - +export async function GetRecentlyViewedFolders(userID: integer, game: V3Game) { const rows = await DB.selectFrom("folder_view") .innerJoin("folder", "folder.id", "folder_view.folder_id") - .select(["folder_view.folder_id", "folder_view.last_viewed", "folder.game"]) + .select(["folder_view.folder_id", "folder_view.last_viewed", "folder.game", "folder.slug"]) .where("folder_view.user_id", "=", userID) - .where("folder.game", "=", v3Game) + .where("folder.game", "=", game) .orderBy("folder_view.last_viewed", "desc") .limit(6) .execute(); if (rows.length === 0) { - const emptyViews: Array = []; + const emptyViews: Array = []; return { views: emptyViews, folders: [] }; } - const views: Array = rows.map((r) => { - const { game: g, playtype: pt } = V3ToGamePT(r.game); - - return { - userID, - game: g, - playtype: pt, - folderID: r.folder_id, - lastViewed: ISO8601ToUnixMilliseconds(r.last_viewed), - }; - }); + const views: Array = rows.map((r) => ({ + userID, + game: r.game, + slug: r.slug, + lastViewed: ISO8601ToUnixMilliseconds(r.last_viewed), + })); const folderMap = await LoadFolderDocumentsByIds(rows.map((r) => r.folder_id)); - const folders: Array = []; + const folders: Array = []; for (const r of rows) { const doc = folderMap.get(r.folder_id); @@ -257,7 +242,7 @@ export async function GetRecentlyViewedFolders( return { views, folders }; } -export async function GetTableForIDGuaranteed(tableID: string): Promise { +export async function GetTableForIDGuaranteed(tableID: string): Promise { const table = await LoadTableDocumentByLegacyId(tableID); if (!table) { @@ -283,12 +268,11 @@ export async function GetEnumDistForFolderAsOf( ) { const chartIDs = await GetFolderChartIDs(folderID); const folder = await GetFolderForIDGuaranteed(folderID); - const { game, playtype } = folder; - const gptConfig = GetGamePTConfig(folder.game, folder.playtype); + const gameConfig = GetGameConfig(folder.game); - const enumMetrics = GetScoreEnumConfs(gptConfig); - const v3Game = GamePTToV3(game, playtype); + const enumMetrics = GetScoreEnumConfs(gameConfig); + const v3Game = folder.game; const beforeIso = UnixMillisecondsToISO8601(beforeTime); const metricKeys = Object.keys(enumMetrics); @@ -357,10 +341,7 @@ export async function GetEnumDistForFolderAsOf( if (!val) { log.warn( - `Failed to resolve ${metric} index '${score[metric]}' for ${FormatGameGroup( - game, - playtype, - )}.`, + `Failed to resolve ${metric} index '${score[metric]}' for ${FormatGame(v3Game)}.`, ); continue; } diff --git a/typescript/server/src/lib/folders/rebuild-folder-chart-lookup.ts b/typescript/server/src/lib/folders/rebuild-folder-chart-lookup.ts index 66078d8dc..cfaa3f45f 100644 --- a/typescript/server/src/lib/folders/rebuild-folder-chart-lookup.ts +++ b/typescript/server/src/lib/folders/rebuild-folder-chart-lookup.ts @@ -2,7 +2,7 @@ import type { Kysely } from "kysely"; import type { Database, NewFolderChartLookup } from "tachi-db"; -import { computeFolderChartIdsFromFolderSql } from "./folder-query.js"; +import { computeFolderChartIdsFromFolderSql } from "./folder-query"; const INSERT_CHUNK = 500; diff --git a/typescript/server/src/lib/game-settings/create-game-settings.oldtest.ts b/typescript/server/src/lib/game-settings/create-game-settings.oldtest.ts deleted file mode 100644 index ab0ab5bdc..000000000 --- a/typescript/server/src/lib/game-settings/create-game-settings.oldtest.ts +++ /dev/null @@ -1,33 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -import { CreateGameSettings } from "./create-game-settings"; - -t.test("#CreateGameSettings", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should create a new user's settings.", async (t) => { - await CreateGameSettings(1, "bms", "7K"); - - const data = await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "bms", - playtype: "7K", - }); - - t.not(data, null); - - t.end(); - }); - - t.test("Should throw an error if the user already has game-settings.", async (t) => { - await CreateGameSettings(1, "bms", "7K"); - - t.rejects(() => CreateGameSettings(1, "bms", "7K")); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/game-settings/create-game-settings.test.ts b/typescript/server/src/lib/game-settings/create-game-settings.test.ts new file mode 100644 index 000000000..c7f0e0d62 --- /dev/null +++ b/typescript/server/src/lib/game-settings/create-game-settings.test.ts @@ -0,0 +1,37 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { describe, expect, it } from "vitest"; + +import { CreateGameSettings } from "./create-game-settings"; + +describe("CreateGameSettings", () => { + it("creates settings for a new user and game", async () => { + const { id: userId } = await seedUser(); + + await CreateGameSettings(userId, "bms-7k"); + + const row = await DB.selectFrom("game_settings") + .selectAll() + .where("user_id", "=", userId) + .where("game", "=", "bms-7k") + .executeTakeFirst(); + + expect(row).not.toBeUndefined(); + expect(row?.game).toBe("bms-7k"); + }); + + it("throws if settings already exist", async () => { + const { id } = await seedUser(); + const localUserId = id; + + try { + await CreateGameSettings(localUserId, "popn"); + await expect(CreateGameSettings(localUserId, "popn")).rejects.toThrow( + /Cannot create .* game-settings as one already exists/u, + ); + } finally { + await DB.deleteFrom("game_settings").where("user_id", "=", localUserId).execute(); + await DB.deleteFrom("account").where("id", "=", localUserId).execute(); + } + }); +}); diff --git a/typescript/server/src/lib/game-settings/create-game-settings.ts b/typescript/server/src/lib/game-settings/create-game-settings.ts index 543d53b1f..17f8e5428 100644 --- a/typescript/server/src/lib/game-settings/create-game-settings.ts +++ b/typescript/server/src/lib/game-settings/create-game-settings.ts @@ -1,31 +1,25 @@ import { log } from "#lib/log/log"; import DB from "#services/pg/db"; -import { type GameGroup, GamePTToV3, type integer, type Playtype } from "tachi-common"; +import { type integer, type V3Game } from "tachi-common"; /** * Create GameSettings for a UGPT (which contains their preferences). */ -export async function CreateGameSettings(userID: integer, game: GameGroup, playtype: Playtype) { - const v3Game = GamePTToV3(game, playtype); - +export async function CreateGameSettings(userID: integer, game: V3Game) { const exists = await DB.selectFrom("game_settings") .select("user_id") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst(); if (exists) { - log.error( - `Cannot create ${userID} ${game} ${playtype} game-settings as one already exists?`, - ); + log.error(`Cannot create ${userID} ${game} game-settings as one already exists?`); - throw new Error( - `Cannot create ${userID} ${game} ${playtype} game-settings as one already exists?`, - ); + throw new Error(`Cannot create ${userID} ${game} game-settings as one already exists?`); } const gameSpecific = - game === "iidx" + game === "iidx-sp" || game === "iidx-dp" ? { display2DXTra: false, bpiTarget: 0, @@ -35,7 +29,7 @@ export async function CreateGameSettings(userID: integer, game: GameGroup, playt await DB.insertInto("game_settings") .values({ data: JSON.stringify(gameSpecific), - game: v3Game, + game, pf_default_table: null, pf_preferred_default_enum: null, pf_preferred_profile_alg: null, @@ -46,5 +40,5 @@ export async function CreateGameSettings(userID: integer, game: GameGroup, playt }) .execute(); - log.info(`Created game settings for ${userID} (${game} ${playtype}).`); + log.info(`Created game settings for ${userID} (${game}).`); } diff --git a/typescript/server/src/lib/game-specific/custom-bms-tables.ts b/typescript/server/src/lib/game-specific/custom-bms-tables.ts index 2d351dfb2..bd04be391 100644 --- a/typescript/server/src/lib/game-specific/custom-bms-tables.ts +++ b/typescript/server/src/lib/game-specific/custom-bms-tables.ts @@ -11,17 +11,18 @@ import { GetFoldersFromTable, GetTableForIDGuaranteed, } from "#utils/folder"; -import { GetRecentUGPTScores } from "#utils/queries/scores"; -import { GetUser } from "#utils/req-tachi-data"; +import { GetRecentUGScores } from "#utils/queries/scores"; +import { REQ_GetGame, REQ_GetUser } from "#utils/req-tachi-data"; import path from "path"; import { + type ChartDocument, CreateSongMap, + type FolderDocument, + type GamesForGroup, type integer, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_SongDocument, - type MONGO_TableDocument, - type Playtypes, + LEGACY_GameToPlaytypeFn, + type SongDocument, + type TableDocument, } from "tachi-common"; // Instead of just supporting existing tables, Tachi should also be able @@ -29,12 +30,12 @@ import { function AppendAndConvertChartsToBMSBody( body: Array, - charts: Array>, - songMap: Map, + charts: Array>, + songMap: Map, level: string, ) { for (const chart of charts) { - const song = songMap.get(chart.songID); + const song = songMap.get(chart.song.id); // if we've got metadata to add... if (song) { @@ -58,7 +59,7 @@ function AppendAndConvertChartsToBMSBody( * Convert a table in Tachi into a bms header.json and body.json. */ export async function TachiTableToBMSTableJSON( - table: MONGO_TableDocument, + table: TableDocument, ): Promise> { const body: Array = []; @@ -67,18 +68,18 @@ export async function TachiTableToBMSTableJSON( // we have to iterate over these folders in the order the table document says // to // as bms tables are somewhat sensitive to being placed in the correct order. - const folderMap = new Map(); + const folderMap = new Map(); for (const folder of folders) { - folderMap.set(folder.folderID, folder); + folderMap.set(folder.slug, folder); } - for (const folderID of table.folders) { - const folder = folderMap.get(folderID); + for (const folderSlug of table.folders) { + const folder = folderMap.get(folderSlug); if (!folder) { log.warn( - `Table '${table.title}' refers to folder '${folderID}', yet no such folder exists in the db?`, + `Table '${table.title}' refers to folder '${folderSlug}', yet no such folder exists in the db?`, ); continue; } @@ -87,7 +88,7 @@ export async function TachiTableToBMSTableJSON( // order. // eslint-disable-next-line no-await-in-loop const data = await GetFolderChartsAndSongs(folder); - const charts = data.charts as Array>; + const charts = data.charts as Array>; const songMap = CreateSongMap(data.songs); AppendAndConvertChartsToBMSBody(body, charts, songMap, folder.title); @@ -100,7 +101,7 @@ export type TachiBMSTable = { description: string; // is for all playtypes. - playtype: Playtypes["bms"] | null; // what playtype is this for? If null, this table + game: GamesForGroup["bms"] | null; // what game is this for? If null, this table is for all bms games symbol: string; // what symbol should this table have? tableName: string; // what should it be called in-game? urlName: string; // what do we call this in the url? @@ -109,19 +110,19 @@ export type TachiBMSTable = { forSpecificUser: true; // if this table is user-dependent getBody: ( userID: integer, - playtype: Playtypes["bms"], + game: GamesForGroup["bms"], ) => Promise>; // like, say, their rivals scores or something. // then the callbacks need to recieve that info. getLevelOrder: ( userID: integer, - playtype: Playtypes["bms"], + game: GamesForGroup["bms"], ) => Promise | undefined>; } | { forSpecificUser?: false; - getBody: (playtype: Playtypes["bms"]) => Promise>; - getLevelOrder: (playtype: Playtypes["bms"]) => Promise | undefined>; + getBody: (game: GamesForGroup["bms"]) => Promise>; + getLevelOrder: (game: GamesForGroup["bms"]) => Promise | undefined>; } ); @@ -131,10 +132,12 @@ export type TachiBMSTable = { */ export function BMSTableToAbsoluteURL( bmsTable: TachiBMSTable, - playtype: Playtypes["bms"], + game: GamesForGroup["bms"], headerOrBody: "body" | "header", userID: number | null, ) { + const playtype = LEGACY_GameToPlaytypeFn(game); + return ( ServerConfig.OUR_URL + path.join( @@ -153,7 +156,7 @@ export function BMSTableToAbsoluteURL( function GetUserID(req: Request) { if ("userID" in req.params) { - const user = GetUser(req); + const user = REQ_GetUser(req); return user.id; } @@ -161,28 +164,20 @@ function GetUserID(req: Request) { throw new Error(`No userID in params here. Is this route mounted in the right place?`); } -function GetPlaytype(req: Request) { - if ("playtype" in req.params) { - return req.params.playtype as Playtypes["bms"]; - } - - throw new Error(`No playtype in params here. Is this route mounted in the right place?`); -} - /** * Handle a request for a bms table. This endpoint should return "HTML" with the caveat * that atleast one of the lines should refer to a "bmstable" meta header. */ export function HandleBMSTableHTMLRequest(bmsTable: TachiBMSTable, req: Request, res: Response) { let absURL; - const playtype = GetPlaytype(req); + const game = REQ_GetGame(req) as GamesForGroup["bms"]; if (bmsTable.forSpecificUser === true) { const userID = GetUserID(req); - absURL = BMSTableToAbsoluteURL(bmsTable, playtype, "header", userID); + absURL = BMSTableToAbsoluteURL(bmsTable, game, "header", userID); } else { - absURL = BMSTableToAbsoluteURL(bmsTable, playtype, "header", null); + absURL = BMSTableToAbsoluteURL(bmsTable, game, "header", null); } return res.status(200).send(` @@ -204,17 +199,17 @@ export async function HandleBMSTableHeaderRequest( try { let levelOrder; let dataUrl; - const playtype = GetPlaytype(req); + const game = REQ_GetGame(req) as GamesForGroup["bms"]; if (bmsTable.forSpecificUser === true) { const userID = GetUserID(req); - dataUrl = BMSTableToAbsoluteURL(bmsTable, playtype, "body", userID); + dataUrl = BMSTableToAbsoluteURL(bmsTable, game, "body", userID); - levelOrder = await bmsTable.getLevelOrder(userID, playtype); + levelOrder = await bmsTable.getLevelOrder(userID, game); } else { - levelOrder = await bmsTable.getLevelOrder(playtype); - dataUrl = BMSTableToAbsoluteURL(bmsTable, playtype, "body", null); + levelOrder = await bmsTable.getLevelOrder(game); + dataUrl = BMSTableToAbsoluteURL(bmsTable, game, "body", null); } const header: BMSTableHead = { @@ -245,14 +240,14 @@ export async function HandleBMSTableBodyRequest( try { let body; - const playtype = GetPlaytype(req); + const game = REQ_GetGame(req) as GamesForGroup["bms"]; if (bmsTable.forSpecificUser === true) { const userID = GetUserID(req); - body = await bmsTable.getBody(userID, playtype); + body = await bmsTable.getBody(userID, game); } else { - body = await bmsTable.getBody(playtype); + body = await bmsTable.getBody(game); } return res.status(200).send(body); @@ -276,7 +271,7 @@ export async function HandleBMSTableBodyRequest( export const CUSTOM_TACHI_BMS_TABLES: Array = [ { urlName: "sieglindeEC", - playtype: "7K", + game: "bms-7k", symbol: "sgl-", tableName: "Sieglinde EC", description: @@ -294,7 +289,7 @@ export const CUSTOM_TACHI_BMS_TABLES: Array = [ }, { urlName: "sieglindeHC", - playtype: "7K", + game: "bms-7k", symbol: "sgl-", tableName: "Sieglinde HC", description: @@ -313,13 +308,13 @@ export const CUSTOM_TACHI_BMS_TABLES: Array = [ { urlName: "rival-info", - playtype: null, + game: null, symbol: "Rival", tableName: `${TachiConfig.NAME} Rival Stats`, forSpecificUser: true, description: `Folders for your rivals on ${TachiConfig.NAME}. This includes things like their recent highlights and plays.`, - async getBody(userID, playtype) { - const rivals = await GetRivalUsers(userID, "bms", playtype); + async getBody(userID, game) { + const rivals = await GetRivalUsers(userID, game); const body: Array = []; @@ -328,11 +323,11 @@ export const CUSTOM_TACHI_BMS_TABLES: Array = [ for (const rival of rivals) { promises.push( (async () => { - const scores = await GetRecentUGPTScores(rival.id, "bms", playtype); + const scores = await GetRecentUGScores(rival.id, game); - const data = await GetRelevantSongsAndCharts(scores, "bms"); + const data = await GetRelevantSongsAndCharts(scores); const charts = data.charts as unknown as Array< - MONGO_ChartDocument<"bms:7K" | "bms:14K"> + ChartDocument >; const songMap = CreateSongMap(data.songs); @@ -348,11 +343,11 @@ export const CUSTOM_TACHI_BMS_TABLES: Array = [ promises.push( (async () => { - const scores = await GetRecentUGPTScores(rival.id, "bms", playtype); + const scores = await GetRecentUGScores(rival.id, game); - const data = await GetRelevantSongsAndCharts(scores, "bms"); + const data = await GetRelevantSongsAndCharts(scores); const charts = data.charts as unknown as Array< - MONGO_ChartDocument<"bms:7K" | "bms:14K"> + ChartDocument >; const songMap = CreateSongMap(data.songs); @@ -371,8 +366,8 @@ export const CUSTOM_TACHI_BMS_TABLES: Array = [ return body; }, - async getLevelOrder(userID, playtype) { - const rivals = await GetRivalUsers(userID, "bms", playtype); + async getLevelOrder(userID, game) { + const rivals = await GetRivalUsers(userID, game); return rivals.flatMap((rival) => [ `${rival.username} Recent Plays`, diff --git a/typescript/server/src/lib/game-specific/iidx-playlists.ts b/typescript/server/src/lib/game-specific/iidx-playlists.ts index e0be61449..e619cbfd8 100644 --- a/typescript/server/src/lib/game-specific/iidx-playlists.ts +++ b/typescript/server/src/lib/game-specific/iidx-playlists.ts @@ -1,11 +1,9 @@ -import type { Game } from "tachi-db"; - import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; import { sql, type SqlBool } from "kysely"; import { PoyashiBPI } from "rg-stats"; -import { GamePTToV3, type integer, type MONGO_ChartDocument, type Playtypes } from "tachi-common"; +import { type ChartDocument, type GamesForGroup, type integer } from "tachi-common"; interface PlaylistEntry { entry_id: integer; @@ -21,9 +19,8 @@ export interface IIDXPlaylist { export type TachiIIDXPlaylist = { description: string; - + game: "iidx-dp" | "iidx-sp" | null; // What game is this for? If null, this playlist is for sp or dp. playlistName: string; // what should this playlist be called in-game? - playtype: Playtypes["iidx"] | null; // What playtype is this for? If null, this playlist is for any. urlName: string; // what should we call this playlist in the url? } & ( | { @@ -32,17 +29,17 @@ export type TachiIIDXPlaylist = { // then the callbacks need to recieve that info. getPlaylists: ( userID: integer, - playtype: Playtypes["iidx"], + game: "iidx-dp" | "iidx-sp", ) => Promise>; } | { forSpecificUser?: false; - getPlaylists: (playtype: Playtypes["iidx"]) => Promise>; + getPlaylists: (game: "iidx-dp" | "iidx-sp") => Promise>; } ); function ChartsToPlaylistFormat( - charts: Array>, + charts: Array>, ): Array { const arr: Array = []; @@ -74,13 +71,13 @@ function ChartsToPlaylistFormat( // highlight individual difference charts as "event". if ( - chart.playtype === "SP" && - (chart as MONGO_ChartDocument<"iidx:SP">).data.hcTier?.individualDifference + chart.game === "iidx-sp" && + (chart as unknown as ChartDocument<"iidx-sp">).data.hcTier?.individualDifference ) { barStyle = "event"; } else if ( - chart.playtype === "DP" && - (chart as MONGO_ChartDocument<"iidx:DP">).data.dpTier?.individualDifference + chart.game === "iidx-dp" && + (chart as unknown as ChartDocument<"iidx-dp">).data.dpTier?.individualDifference ) { barStyle = "event"; } @@ -101,10 +98,10 @@ function ChartsToPlaylistFormat( export const CUSTOM_TACHI_IIDX_PLAYLISTS: Array = [ { urlName: "aaa-bpi", - playtype: null, + game: null, description: "Folders for how much an AAA is worth in BPI.", playlistName: "AAA BPIs", - async getPlaylists(playtype) { + async getPlaylists(game) { const cutoffs = [-10, -5, 0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; const bounds: Array<[number, number]> = []; @@ -113,19 +110,15 @@ export const CUSTOM_TACHI_IIDX_PLAYLISTS: Array = [ bounds.push([cutoffs[i], cutoffs[i + 1]] as [number, number]); } - const v3Game = GamePTToV3("iidx", playtype) as Game; - const rows = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where(sql`(chart.data->>'kaidenAverage') IS NOT NULL`) .where(sql`(chart.data->>'2dxtraSet') IS NULL`) .execute(); - const charts = rows.map(ToChartDocument) as Array< - MONGO_ChartDocument<"iidx:DP" | "iidx:SP"> - >; + const charts = rows.map(ToChartDocument) as Array>; const entries = []; @@ -150,7 +143,7 @@ export const CUSTOM_TACHI_IIDX_PLAYLISTS: Array = [ for (const [lower, upper] of bounds) { playlists.push({ name: `AAA BPI ${lower}~${upper}`, - play_style: playtype, + play_style: game === "iidx-sp" ? "SP" : "DP", charts: ChartsToPlaylistFormat( entries.filter((e) => e.bpi <= upper && e.bpi > lower).map((e) => e.chart), ), diff --git a/typescript/server/src/lib/imports/imports.oldtest.ts b/typescript/server/src/lib/imports/imports.oldtest.ts deleted file mode 100644 index 178998b89..000000000 --- a/typescript/server/src/lib/imports/imports.oldtest.ts +++ /dev/null @@ -1,60 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkFakeImport } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingIIDXSPScore } from "#test-utils/test-data"; -import t from "tap"; - -import { RevertImport } from "./imports"; - -t.test("#RevertImport", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => - MONGODB_KILL.scores.insert([ - dmf(TestingIIDXSPScore, { scoreID: "score_1" }), - dmf(TestingIIDXSPScore, { scoreID: "score_2" }), - dmf(TestingIIDXSPScore, { scoreID: "score_3" }), - ]), - ); - - t.test("Should revert an import and all of its contained scores.", async (t) => { - const importDoc = mkFakeImport({ - scoreIDs: ["score_1", "score_2"], - }); - - await MONGODB_KILL.imports.insert(importDoc); - - await RevertImport(importDoc); - - const dbRes = await MONGODB_KILL.imports.findOne({ importID: "fake_import" }); - - t.equal(dbRes, null, "Should have removed the import from the DB."); - - t.resolveMatch( - MONGODB_KILL.scores.findOne({ userID: 1, scoreID: "score_1" }), - - // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020 - null, - "Score_1 should be removed from the database.", - ); - - t.resolveMatch( - MONGODB_KILL.scores.findOne({ userID: 1, scoreID: "score_2" }), - - // @ts-expect-error see above - null, - "Score_2 should be removed from the database.", - ); - - t.resolveMatch( - MONGODB_KILL.scores.findOne({ userID: 1, scoreID: "score_3" }), - - // @ts-expect-error see above - { scoreID: "score_3" }, - "Score_2 should NOT be removed from the database.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/imports/imports.test.ts b/typescript/server/src/lib/imports/imports.test.ts new file mode 100644 index 000000000..96f0ccf31 --- /dev/null +++ b/typescript/server/src/lib/imports/imports.test.ts @@ -0,0 +1,137 @@ +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { mkFakeImport, mkFakeScoreIIDXSP } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { RevertImport } from "./imports"; + +const chart = Testing511SPA; + +async function seedIidx511Chart() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertIidxScore(opts: { + chartId: string; + importId: string | null; + scoreId: string; + userId: number; +}) { + const doc = mkFakeScoreIIDXSP({ + userID: opts.userId, + chartID: opts.chartId, + scoreID: opts.scoreId, + }); + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", doc.scoreData); + const ts = UnixMillisecondsToISO8601(Date.now()); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: opts.chartId, + game: "iidx-sp", + session_id: null, + import_id: opts.importId, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("RevertImport", () => { + beforeEach(seedIidx511Chart); + + it("deletes the import row and only scores linked by import_id", async () => { + const { id: userId } = await seedUser({ username: "revert_import_u" }); + const chartId = chart.chartID; + const importId = `revert-imp-${Date.now()}`; + const now = new Date().toISOString(); + + await DB.insertInto("import") + .values({ + id: importId, + user_id: userId, + time_started: now, + time_finished: now, + game_group: "iidx", + import_type: "file/batch-manual" as never, + user_intent: true, + service: "test", + }) + .execute(); + + await insertIidxScore({ userId, scoreId: "score_1", chartId, importId }); + await insertIidxScore({ userId, scoreId: "score_2", chartId, importId }); + await insertIidxScore({ userId, scoreId: "score_3", chartId, importId: null }); + + const importDoc = mkFakeImport({ + importID: importId, + userID: userId, + scoreIDs: ["score_1", "score_2"], + }); + + const err = await RevertImport(importDoc); + expect(err).toBeNull(); + + const importRow = await DB.selectFrom("import") + .select("id") + .where("id", "=", importId) + .executeTakeFirst(); + expect(importRow).toBeUndefined(); + + const s1 = await DB.selectFrom("score") + .select("id") + .where("id", "=", "score_1") + .executeTakeFirst(); + const s2 = await DB.selectFrom("score") + .select("id") + .where("id", "=", "score_2") + .executeTakeFirst(); + const s3 = await DB.selectFrom("score") + .select("id") + .where("id", "=", "score_3") + .executeTakeFirst(); + + expect(s1).toBeUndefined(); + expect(s2).toBeUndefined(); + expect(s3).toEqual({ id: "score_3" }); + }); +}); diff --git a/typescript/server/src/lib/imports/imports.ts b/typescript/server/src/lib/imports/imports.ts index 8c7f1989d..97da17d0f 100644 --- a/typescript/server/src/lib/imports/imports.ts +++ b/typescript/server/src/lib/imports/imports.ts @@ -1,4 +1,4 @@ -import type { MONGO_ImportDocument } from "tachi-common"; +import type { ImportDocument } from "tachi-common"; import { LoadScoreDocumentsForImport } from "#lib/db-formats/score"; import { log } from "#lib/log/log"; @@ -14,15 +14,13 @@ interface OngoingImportError { } /** - * Given an MONGO_ImportDocument, undo it. This will remove all of the scores inside the import. + * Given an ImportDocument, undo it. This will remove all of the scores inside the import. * * It will *not* undo things like classes that were set, but it will invoke a profile recalculation. * * If this results in sessions being deleted, it will delete them. */ -export async function RevertImport( - importDoc: MONGO_ImportDocument, -): Promise { +export async function RevertImport(importDoc: ImportDocument): Promise { log.info({ importDoc }, `Received revert-import request for import '${importDoc.importID}'`); const scores = await GetImportScores(importDoc); @@ -65,6 +63,6 @@ export async function RevertImport( /** * Loads all scores that belong to this import (Postgres `score.import_id`). */ -export function GetImportScores(importDoc: MONGO_ImportDocument) { +export function GetImportScores(importDoc: ImportDocument) { return LoadScoreDocumentsForImport(importDoc.importID); } diff --git a/typescript/server/src/lib/invites/invites.oldtest.ts b/typescript/server/src/lib/invites/invites.oldtest.ts deleted file mode 100644 index 30bff3953..000000000 --- a/typescript/server/src/lib/invites/invites.oldtest.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ONE_MONTH } from "#lib/constants/time"; -import { ServerConfig } from "#lib/setup/config"; -import { mkFakeUser } from "#test-utils/misc"; -import t from "tap"; - -import { GetTotalAllowedInvites } from "./invites"; - -t.test("#GetTotalAllowedInvites", (t) => { - t.test("Should give the user more invites each month", (t) => { - t.equal( - GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now() })), - 0, - "Should return 0 for a non-alpha/beta tester user who joined today.", - ); - - t.equal( - GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now(), badges: ["beta"] })), - ServerConfig.INVITE_CODE_CONFIG?.BETA_USER_BONUS, - "Should return BETA_USER_BONUS for a beta tester user who joined today.", - ); - t.equal( - GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now(), badges: ["alpha"] })), - ServerConfig.INVITE_CODE_CONFIG?.BETA_USER_BONUS, - "Should return BETA_USER_BONUS for a alpha tester user who joined today.", - ); - - t.equal( - GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now() - ONE_MONTH })), - ServerConfig.INVITE_CODE_CONFIG?.BATCH_SIZE, - "Should return BATCH_SIZE for a user who joined a month ago.", - ); - - t.equal( - GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now() - ONE_MONTH * 2 })), - (ServerConfig.INVITE_CODE_CONFIG?.BATCH_SIZE ?? 0) * 2, - "Should return 2 * BATCH_SIZE for a user who joined two months ago.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/invites/invites.test.ts b/typescript/server/src/lib/invites/invites.test.ts new file mode 100644 index 000000000..1092088ff --- /dev/null +++ b/typescript/server/src/lib/invites/invites.test.ts @@ -0,0 +1,28 @@ +import { ONE_MONTH } from "#lib/constants/time"; +import { ServerConfig } from "#lib/setup/config"; +import { mkFakeUser } from "#test-utils/misc"; +import { describe, expect, it } from "vitest"; + +import { GetTotalAllowedInvites } from "./invites"; + +describe("GetTotalAllowedInvites", () => { + it("scales invite allowance by account age and badges", () => { + expect(GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now() }))).toBe(0); + + expect( + GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now(), badges: ["beta"] })), + ).toBe(ServerConfig.INVITE_CODE_CONFIG?.BETA_USER_BONUS); + + expect( + GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now(), badges: ["alpha"] })), + ).toBe(ServerConfig.INVITE_CODE_CONFIG?.BETA_USER_BONUS); + + expect(GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now() - ONE_MONTH }))).toBe( + ServerConfig.INVITE_CODE_CONFIG?.BATCH_SIZE, + ); + + expect( + GetTotalAllowedInvites(mkFakeUser(1, { joinDate: Date.now() - ONE_MONTH * 2 })), + ).toBe((ServerConfig.INVITE_CODE_CONFIG?.BATCH_SIZE ?? 0) * 2); + }); +}); diff --git a/typescript/server/src/lib/invites/invites.ts b/typescript/server/src/lib/invites/invites.ts index 37fdf83f8..2ab489156 100644 --- a/typescript/server/src/lib/invites/invites.ts +++ b/typescript/server/src/lib/invites/invites.ts @@ -1,4 +1,4 @@ -import type { MONGO_UserDocument } from "tachi-common"; +import type { UserDocument } from "tachi-common"; import { ONE_MONTH } from "#lib/constants/time"; import { log } from "#lib/log/log"; @@ -11,7 +11,7 @@ import { ServerConfig } from "#lib/setup/config"; * Users get those N additional invites every month since they join. * This is capped at INVITE_CAP, which defaults to 100. */ -export function GetTotalAllowedInvites(user: MONGO_UserDocument) { +export function GetTotalAllowedInvites(user: UserDocument) { if (!ServerConfig.INVITE_CODE_CONFIG) { log.warn(`No INVITE_CODE_CONFIG set, but tried to get total allowed invites? Returning 0.`); return 0; diff --git a/typescript/server/src/lib/jobs/bms-ai-table-sync.ts b/typescript/server/src/lib/jobs/bms-ai-table-sync.ts index a8b43ee8c..d61ce70c3 100644 --- a/typescript/server/src/lib/jobs/bms-ai-table-sync.ts +++ b/typescript/server/src/lib/jobs/bms-ai-table-sync.ts @@ -1,4 +1,4 @@ -import type { MONGO_ChartDocument } from "tachi-common"; +import type { ChartDocument } from "tachi-common"; import { log } from "#lib/log/log"; import { PullDatabaseSeeds } from "#lib/seeds/repo"; @@ -19,7 +19,7 @@ export async function UpdateAILevels() { const repo = await PullDatabaseSeeds(); - await repo.MutateCollection>("charts-bms", (charts) => { + await repo.MutateCollection>("charts-bms", (charts) => { for (const chart of charts) { chart.data.aiLevel = map[chart.data.hashMD5] ?? null; } diff --git a/typescript/server/src/lib/jobs/drain-dirty-queues.ts b/typescript/server/src/lib/jobs/drain-dirty-queues.ts index 141741193..08295418c 100644 --- a/typescript/server/src/lib/jobs/drain-dirty-queues.ts +++ b/typescript/server/src/lib/jobs/drain-dirty-queues.ts @@ -2,7 +2,14 @@ import { log } from "#lib/log/log"; import { ProcessPBs } from "#lib/score-import/framework/pb/process-pbs"; import { rederiveScoresForChart } from "#lib/score-import/framework/pb/rederive-scores"; import DB from "#services/pg/db"; -import { type GameGroup, type integer, type Playtype, type V3Game, V3ToGamePT } from "tachi-common"; +import { + type GameGroup, + type integer, + LEGACY_GameGroupPTToGame, + LEGACY_GameToGameGroupPT, + type LEGACY_Playtype, + type V3Game, +} from "tachi-common"; const PB_DIRTY_BATCH = 1000; const SCORE_REDERIVE_BATCH = 50; @@ -25,11 +32,11 @@ export async function drainPbDirty(): Promise { const groups = new Map< string, - { chartIDs: Set; game: GameGroup; playtype: Playtype; userID: integer } + { chartIDs: Set; game: GameGroup; playtype: LEGACY_Playtype; userID: integer } >(); for (const row of rows) { - const { game, playtype } = V3ToGamePT(row.chart_game as V3Game); + const { gameGroup: game, playtype } = LEGACY_GameToGameGroupPT(row.chart_game as V3Game); const key = `${game}:${playtype}:${row.user_id}`; let group = groups.get(key); @@ -43,8 +50,9 @@ export async function drainPbDirty(): Promise { } for (const group of groups.values()) { + const v3Game = LEGACY_GameGroupPTToGame(group.game, group.playtype); // eslint-disable-next-line no-await-in-loop - await ProcessPBs(group.game, group.playtype, group.userID, group.chartIDs, log); + await ProcessPBs(v3Game, group.userID, group.chartIDs, log); } const processedPairs = rows.map((r) => [r.user_id, r.chart_id] as const); diff --git a/typescript/server/src/lib/jobs/rebuild-folder-chart-lookup.ts b/typescript/server/src/lib/jobs/rebuild-folder-chart-lookup.ts index fb84f6e86..d0473e594 100644 --- a/typescript/server/src/lib/jobs/rebuild-folder-chart-lookup.ts +++ b/typescript/server/src/lib/jobs/rebuild-folder-chart-lookup.ts @@ -1,6 +1,6 @@ -import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup.js"; +import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup"; import { log } from "#lib/log/log"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; /** Nightly (or cron) full rebuild of `folder_chart_lookup`. */ export async function RebuildFolderChartLookupJob() { diff --git a/typescript/server/src/lib/notifications/notification-wrappers.ts b/typescript/server/src/lib/notifications/notification-wrappers.ts index d2c58a58e..c494961d0 100644 --- a/typescript/server/src/lib/notifications/notification-wrappers.ts +++ b/typescript/server/src/lib/notifications/notification-wrappers.ts @@ -1,13 +1,6 @@ import DB from "#services/pg/db"; import { sql } from "kysely"; -import { - FormatGameGroup, - type GameGroup, - GamePTToV3, - type integer, - type MONGO_UserDocument, - type Playtype, -} from "tachi-common"; +import { FormatGame, type integer, type UserDocument, type V3Game } from "tachi-common"; import { BulkSendNotification, SendNotification } from "./notifications"; @@ -21,13 +14,12 @@ import { BulkSendNotification, SendNotification } from "./notifications"; */ export async function SendSetRivalNotification( toUserID: integer, - fromUser: MONGO_UserDocument, - game: GameGroup, - playtype: Playtype, + fromUser: UserDocument, + game: V3Game, ) { const body = { type: "RIVALED_BY" as const, - content: { userID: fromUser.id, game, playtype }, + content: { userID: fromUser.id, game }, }; const alreadyBeenPinged = await DB.selectFrom("notification") @@ -42,31 +34,25 @@ export async function SendSetRivalNotification( } return SendNotification( - `${fromUser.username} just added you as a rival for ${FormatGameGroup(game, playtype)}`, + `${fromUser.username} just added you as a rival for ${FormatGame(game)}`, toUserID, { type: "RIVALED_BY", content: { userID: fromUser.id, game, - playtype, }, }, ); } -export async function SendSiteAnnouncementNotification( - title: string, - maybeGame?: GameGroup, - maybePlaytype?: Playtype, -) { +export async function SendSiteAnnouncementNotification(title: string, maybeGame?: V3Game) { let toUserIDs: integer[]; - if (maybeGame && maybePlaytype) { - const v3Game = GamePTToV3(maybeGame, maybePlaytype); + if (maybeGame) { const rows = await DB.selectFrom("game_profile") .select("user_id") - .where("game", "=", v3Game) + .where("game", "=", maybeGame) .execute(); toUserIDs = rows.map((e) => e.user_id); } else { diff --git a/typescript/server/src/lib/notifications/notifications.oldtest.ts b/typescript/server/src/lib/notifications/notifications.oldtest.ts deleted file mode 100644 index b4f63550d..000000000 --- a/typescript/server/src/lib/notifications/notifications.oldtest.ts +++ /dev/null @@ -1,77 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -import { BulkSendNotification, SendNotification } from "./notifications"; - -t.test("#SendNotification", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should send a notification.", async (t) => { - await SendNotification("title", 1, { - type: "QUEST_CHANGED", - content: { questID: "foo", game: "iidx", playtype: "SP" }, - }); - - const dbRes = await MONGODB_KILL.notifications.findOne({ - sentTo: 1, - }); - - t.hasStrict(dbRes, { - sentTo: 1, - title: "title", - read: false, - body: { - type: "QUEST_CHANGED", - content: { questID: "foo" }, - }, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("#BulkSendNotification", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should send multiple notifications.", async (t) => { - await BulkSendNotification("title", [1, 2], { - type: "QUEST_CHANGED", - content: { questID: "foo", game: "iidx", playtype: "SP" }, - }); - - const dbRes = await MONGODB_KILL.notifications.findOne({ - sentTo: 1, - }); - - t.hasStrict(dbRes, { - sentTo: 1, - title: "title", - read: false, - body: { - type: "QUEST_CHANGED", - content: { questID: "foo" }, - }, - }); - - const dbRes2 = await MONGODB_KILL.notifications.findOne({ - sentTo: 2, - }); - - t.hasStrict(dbRes2, { - sentTo: 2, - title: "title", - read: false, - body: { - type: "QUEST_CHANGED", - content: { questID: "foo" }, - }, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/notifications/notifications.test.ts b/typescript/server/src/lib/notifications/notifications.test.ts index 4f8ada58c..ecfbb8728 100644 --- a/typescript/server/src/lib/notifications/notifications.test.ts +++ b/typescript/server/src/lib/notifications/notifications.test.ts @@ -15,7 +15,7 @@ describe("SendNotification", () => { it("inserts a notification row with expected kind and payload", async () => { await SendNotification("Quest updated", userId, { type: "QUEST_CHANGED", - content: { questID: "q1", game: "iidx", playtype: "SP" }, + content: { questID: "q1", game: "iidx-sp" }, }); const row = await DB.selectFrom("notification") @@ -28,7 +28,7 @@ describe("SendNotification", () => { expect(row.kind).toBe("quest_changed"); expect(row.payload).toEqual({ type: "QUEST_CHANGED", - content: { questID: "q1", game: "iidx", playtype: "SP" }, + content: { questID: "q1", game: "iidx-sp" }, }); }); }); diff --git a/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.test.ts b/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.test.ts index 26ad4aae2..0534ed0eb 100644 --- a/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.test.ts +++ b/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.test.ts @@ -72,7 +72,7 @@ describe("DeorphanBmsIfInOrphanChartPg", () => { }) .execute(); - const chart = await DeorphanBmsIfInOrphanChartPg("7K", "md5", md5); + const chart = await DeorphanBmsIfInOrphanChartPg("bms-7k", "md5", md5); expect(chart).not.toBeNull(); expect(chart!.chartID).toBe(orphanChartId); diff --git a/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts b/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts index 9057726a6..46aa99a21 100644 --- a/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts +++ b/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts @@ -1,37 +1,25 @@ -import type { Game } from "tachi-db"; - -import { GetChartById } from "#lib/db-formats/chart"; +import { GetChartByIdForGame } from "#lib/db-formats/chart"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; import { GetNextBmsPmsSongLegacyId } from "#utils/db"; import { sql } from "kysely"; import { + type BMSGames, + type ChartDocument, CreateSongID, - GamePTToV3, - type MONGO_ChartDocument, - type MONGO_SongDocument, - type Playtypes, + type GameGroupFromGame, + type SongDocument, } from "tachi-common"; -function parseStoredJson(raw: unknown): T { - if (typeof raw === "string") { - return JSON.parse(raw) as T; - } - - return raw as T; -} - /** * Forcefully de-orphan a BMS song/chart from `orphan_chart` when it matches a hash, * inserting into `song` / `chart` in Postgres. Used by BMS table sync (and tests). */ export async function DeorphanBmsIfInOrphanChartPg( - playtype: Playtypes["bms"], + game: BMSGames, checksumType: "md5" | "sha256", value: string, -): Promise | null> { - const v3Game = GamePTToV3("bms", playtype) as Game; - +): Promise | null> { const hashMatch = checksumType === "md5" ? sql`(orphan_chart.chart_doc::jsonb->'data'->>'hashMD5') = ${value}` @@ -39,7 +27,7 @@ export async function DeorphanBmsIfInOrphanChartPg( const orphanRow = await DB.selectFrom("orphan_chart") .select(["orphan_chart.id", "orphan_chart.chart_doc", "orphan_chart.song_doc"]) - .where("orphan_chart.game", "=", v3Game) + .where("orphan_chart.game", "=", game) .where(hashMatch) .executeTakeFirst(); @@ -47,18 +35,16 @@ export async function DeorphanBmsIfInOrphanChartPg( return null; } - const chartDoc = parseStoredJson>( - orphanRow.chart_doc, - ); - const songDoc = parseStoredJson>(orphanRow.song_doc); + const chartDoc = orphanRow.chart_doc as ChartDocument; + const songDoc = orphanRow.song_doc as SongDocument; log.info(`Song ${songDoc.title} was unorphaned forcefully (Postgres).`); const songLegacyId = await GetNextBmsPmsSongLegacyId("bms"); const songNewID = CreateSongID(); - songDoc.id = songLegacyId; - chartDoc.songID = songLegacyId; + songDoc.id = songNewID; + chartDoc.song = songDoc; const ftsDocument = [...songDoc.searchTerms, ...songDoc.altTitles].filter(Boolean).join(" "); @@ -83,7 +69,7 @@ export async function DeorphanBmsIfInOrphanChartPg( .values({ id: chartDoc.chartID, legacy_id: chartDoc.chartID, - game: v3Game, + game, song_id: songNewID, level: chartDoc.level, level_num: chartDoc.levelNum, @@ -102,12 +88,12 @@ export async function DeorphanBmsIfInOrphanChartPg( await trx.deleteFrom("orphan_chart").where("id", "=", orphanRow.id).execute(); }); - const loaded = await GetChartById(v3Game, chartDoc.chartID); + const loaded = await GetChartByIdForGame(game, chartDoc.chartID); if (!loaded) { log.error(`Deorphan succeeded but GetChartById failed for ${chartDoc.chartID}.`); return null; } - return loaded as MONGO_ChartDocument<"bms:7K" | "bms:14K">; + return loaded as ChartDocument; } diff --git a/typescript/server/src/lib/orphan-queue/orphan-queue.ts b/typescript/server/src/lib/orphan-queue/orphan-queue.ts index 5aeef591d..ae369e34a 100644 --- a/typescript/server/src/lib/orphan-queue/orphan-queue.ts +++ b/typescript/server/src/lib/orphan-queue/orphan-queue.ts @@ -7,20 +7,19 @@ import { GetNextBmsPmsSongLegacyId } from "#utils/db"; import { DedupeArr } from "#utils/misc"; import { type Kysely, sql } from "kysely"; import { + type ChartDocument, CreateSongID, - type GPTString, - type GPTStringToGame, - type GPTStringToPlaytype, - GPTStringToV3, + type GameGroupFromGame, + GameToGameGroup, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, + type SongDocument, + type V3Game, } from "tachi-common"; /** Match shape used by Beatoraja BMS/PMS orphan handling (Postgres `chart_doc` JSON). */ -export type OrphanQueueMatchCriteria = { +export type OrphanQueueMatchCriteria = { "chartDoc.data.hashSHA256"?: string; - playtype?: GPTStringToPlaytype[GPT]; + game: TGame; }; function parseStoredJson(raw: unknown): T { @@ -31,34 +30,27 @@ function parseStoredJson(raw: unknown): T { return raw as T; } -async function selectOrphanByCriteria( - gptString: GPT, - orphanMatchCriteria: OrphanQueueMatchCriteria, +async function selectOrphanByCriteria( + game: TGame, + orphanMatchCriteria: OrphanQueueMatchCriteria, ) { - const v3Game = GPTStringToV3(gptString) as Game; - let q = DB.selectFrom("orphan_chart") .select(["orphan_chart.id", "orphan_chart.chart_doc", "orphan_chart.song_doc"]) - .where("orphan_chart.game", "=", v3Game); + .where("orphan_chart.game", "=", game); const sha = orphanMatchCriteria["chartDoc.data.hashSHA256"]; if (sha !== undefined) { q = q.where(sql`(orphan_chart.chart_doc::jsonb->'data'->>'hashSHA256') = ${sha}`); } - const pt = orphanMatchCriteria.playtype; - if (pt !== undefined) { - q = q.where(sql`(orphan_chart.chart_doc::jsonb->>'playtype') = ${pt}`); - } - const row = await q.executeTakeFirst(); if (!row) { return null; } - const chartDoc = parseStoredJson>(row.chart_doc); - const songDoc = parseStoredJson>(row.song_doc); + const chartDoc = parseStoredJson>(row.chart_doc); + const songDoc = parseStoredJson>(row.song_doc); return { id: row.id, chartDoc, songDoc }; } @@ -76,8 +68,8 @@ async function writeBmsPmsSongAndChart( trx: Kysely, gameGroup: "bms" | "pms", v3Game: Game, - songDoc: MONGO_SongDocument<"bms" | "pms">, - chartDoc: MONGO_ChartDocument, + songDoc: SongDocument<"bms" | "pms">, + chartDoc: ChartDocument, songNewID: string, songLegacyId: integer, ) { @@ -127,22 +119,20 @@ async function writeBmsPmsSongAndChart( * If the chart has been seen before, and has >= N unique players who have * played it, unorphan the chart, and return it. */ -export async function HandleOrphanQueue( - gptString: GPT, - game: GPTStringToGame[GPT], - chartDoc: MONGO_ChartDocument, - songDoc: MONGO_SongDocument, - orphanMatchCriteria: OrphanQueueMatchCriteria, +export async function HandleOrphanQueue( + v3Game: TGame, + chartDoc: ChartDocument, + songDoc: SongDocument, + orphanMatchCriteria: OrphanQueueMatchCriteria, queueSize: integer, userID: integer, chartName: string, ) { log.debug(`Received orphanqueue request for ${chartName}.`); - const v3Game = GPTStringToV3(gptString) as Game; - const gameGroup = game as "bms" | "pms"; + const gameGroup = GameToGameGroup(v3Game) as "bms" | "pms"; - const orphan = await selectOrphanByCriteria(gptString, orphanMatchCriteria); + const orphan = await selectOrphanByCriteria(v3Game, orphanMatchCriteria); if (!orphan) { log.debug(`Received unknown chart ${chartName}, orphaning.`); @@ -193,19 +183,18 @@ export async function HandleOrphanQueue( log.debug(`${chartName} has been assigned songID ${songLegacyId}.`); const songDocU = { ...orphan.songDoc }; - const chartDocU = { ...orphan.chartDoc }; - - songDocU.id = songLegacyId; - chartDocU.songID = songLegacyId; + let chartDocU = { ...orphan.chartDoc }; const songNewID = CreateSongID(); + songDocU.id = songNewID; + chartDocU = { ...chartDocU, song: songDocU }; await DB.transaction().execute(async (trx) => { await writeBmsPmsSongAndChart( trx, gameGroup, v3Game, - songDocU as MONGO_SongDocument<"bms" | "pms">, + songDocU as SongDocument<"bms" | "pms">, chartDocU, songNewID, songLegacyId, @@ -219,7 +208,7 @@ export async function HandleOrphanQueue( await trx.deleteFrom("orphan_chart").where("id", "=", orphan.id).execute(); }); - const loaded = await GetChartById(v3Game, chartDocU.chartID); + const loaded = await GetChartById(chartDocU.chartID); if (!loaded) { log.error( @@ -228,7 +217,7 @@ export async function HandleOrphanQueue( return null; } - return loaded as MONGO_ChartDocument; + return loaded as ChartDocument; } // otherwise, this play is recorded in orphan_chart_user; no row update needed @@ -244,19 +233,17 @@ export async function HandleOrphanQueue( * Useful for something like BMS-Table-Sync, where we want to load anything in a table * regardless of how many people have played the chart. */ -export async function DeorphanIfInQueue( - gptString: GPT, - game: GPTStringToGame[GPT], - orphanMatchCriteria: OrphanQueueMatchCriteria, -): Promise | null> { - const orphan = await selectOrphanByCriteria(gptString, orphanMatchCriteria); +export async function DeorphanIfInQueue( + v3Game: TGame, + orphanMatchCriteria: OrphanQueueMatchCriteria, +): Promise | null> { + const orphan = await selectOrphanByCriteria(v3Game, orphanMatchCriteria); if (!orphan) { return null; } - const v3Game = GPTStringToV3(gptString) as Game; - const gameGroup = game as "bms" | "pms"; + const gameGroup = GameToGameGroup(v3Game) as "bms" | "pms"; const { songDoc, chartDoc } = orphan; @@ -267,19 +254,18 @@ export async function DeorphanIfInQueue( log.debug(`${songDoc.title} has been assigned songID ${songLegacyId}.`); const songDocU = { ...songDoc }; - const chartDocU = { ...chartDoc }; - - songDocU.id = songLegacyId; - chartDocU.songID = songLegacyId; + let chartDocU = { ...chartDoc }; const songNewID = CreateSongID(); + songDocU.id = songNewID; + chartDocU = { ...chartDocU, song: songDocU }; await DB.transaction().execute(async (trx) => { await writeBmsPmsSongAndChart( trx, gameGroup, v3Game, - songDocU as MONGO_SongDocument<"bms" | "pms">, + songDocU as SongDocument<"bms" | "pms">, chartDocU, songNewID, songLegacyId, @@ -293,12 +279,12 @@ export async function DeorphanIfInQueue( await trx.deleteFrom("orphan_chart").where("id", "=", orphan.id).execute(); }); - const loaded = await GetChartById(v3Game, chartDocU.chartID); + const loaded = await GetChartById(chartDocU.chartID); if (!loaded) { log.error(`DeorphanIfInQueue succeeded but GetChartById failed for ${chartDocU.chartID}.`); return null; } - return loaded as MONGO_ChartDocument; + return loaded as ChartDocument; } diff --git a/typescript/server/src/lib/rivals/rivals.ts b/typescript/server/src/lib/rivals/rivals.ts index b914c82a5..829c0aefc 100644 --- a/typescript/server/src/lib/rivals/rivals.ts +++ b/typescript/server/src/lib/rivals/rivals.ts @@ -8,39 +8,27 @@ import { pgScoreDataToMongo } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import { ArrayDiff } from "#utils/misc"; import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "#utils/user"; -import { - FormatGameGroup, - type GameGroup, - GamePTToV3, - GetGamePTConfig, - type integer, - type PgScoreData, - type Playtype, -} from "tachi-common"; +import { GetGameConfig, type integer, type PgScoreData, type V3Game } from "tachi-common"; /** * Retrieve all of a user's set rival IDs. * Throws if the user hasn't played the GPT in question. */ -export async function GetRivalIDs(userID: integer, game: GameGroup, playtype: Playtype) { - const v3Game = GamePTToV3(game, playtype); - +export async function GetRivalIDs(userID: integer, game: V3Game) { const settings = await DB.selectFrom("game_settings") .select("user_id") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst(); if (!settings) { - throw new Error( - `User ${userID} has not played ${FormatGameGroup(game, playtype)}. Cannot retrieve rivals.`, - ); + throw new Error(`User ${userID} has not played ${game}. Cannot retrieve rivals.`); } const rivalRows = await DB.selectFrom("game_rival") .select("rival") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .execute(); return rivalRows.map((r) => r.rival); @@ -50,8 +38,8 @@ export async function GetRivalIDs(userID: integer, game: GameGroup, playtype: Pl * Get the user documents of the rivals for this UGPT. * Throws if the user hasn't played the GPT in question. */ -export async function GetRivalUsers(userID: integer, game: GameGroup, playtype: Playtype) { - const rivalIDs = await GetRivalIDs(userID, game, playtype); +export async function GetRivalUsers(userID: integer, game: V3Game) { + const rivalIDs = await GetRivalIDs(userID, game); const rivals = await GetUsersWithIDs(rivalIDs); @@ -62,15 +50,10 @@ export async function GetRivalUsers(userID: integer, game: GameGroup, playtype: * Retrieve *all* rival IDs for people on this game. Used to recalculate rival movements on charts, * since that is stored and cached. */ -export async function GetEveryonesRivalIDs( - game: GameGroup, - playtype: Playtype, -): Promise>> { - const v3Game = GamePTToV3(game, playtype) as Game; - +export async function GetEveryonesRivalIDs(game: V3Game): Promise>> { const rows = await DB.selectFrom("game_rival") .select(["user_id", "rival"]) - .where("game", "=", v3Game) + .where("game", "=", game) .execute(); const lookupTable: Record> = {}; @@ -108,8 +91,7 @@ function metricValueFromPbRow( */ export async function setRivalsWithResult( userID: integer, - game: GameGroup, - playtype: Playtype, + game: V3Game, newRivals: Array, ): Promise { if (newRivals.length > ServerConfig.MAX_RIVALS) { @@ -120,11 +102,9 @@ export async function setRivalsWithResult( return SetRivalsFailReasons.RIVALED_SELF; } - const v3Game = GamePTToV3(game, playtype) as Game; - const { count } = await DB.selectFrom("game_settings") .select(DB.fn.countAll().as("count")) - .where("game", "=", v3Game) + .where("game", "=", game) .where("user_id", "in", newRivals) .executeTakeFirstOrThrow(); @@ -137,39 +117,31 @@ export async function setRivalsWithResult( const currentGameSettings = await DB.selectFrom("game_settings") .select("user_id") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst(); if (!currentGameSettings) { log.error( - `User ${userID} attempted to set rivals for ${FormatGameGroup( - game, - playtype, - )}, but doesn't have game settings. Was their account deleted in midair?`, + `User ${userID} attempted to set rivals for ${game}, but doesn't have game settings. Was their account deleted in midair?`, ); throw new Error( - `User ${userID} attempted to set rivals for ${FormatGameGroup( - game, - playtype, - )}, but doesn't have game settings. Was their account deleted in midair?`, + `User ${userID} attempted to set rivals for ${game}, but doesn't have game settings. Was their account deleted in midair?`, ); } - const currentRivalIDs = await GetRivalIDs(userID, game, playtype); + const currentRivalIDs = await GetRivalIDs(userID, game); const newSubs = ArrayDiff(currentRivalIDs, newRivals); const user = await GetUserWithIDGuaranteed(userID); - await Promise.all( - newSubs.map((toUserID) => SendSetRivalNotification(toUserID, user, game, playtype)), - ); + await Promise.all(newSubs.map((toUserID) => SendSetRivalNotification(toUserID, user, game))); await DB.transaction().execute(async (trx) => { await trx .deleteFrom("game_rival") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .execute(); if (newRivals.length > 0) { @@ -178,7 +150,7 @@ export async function setRivalsWithResult( .values( newRivals.map((rival) => ({ user_id: userID, - game: v3Game, + game, rival, })), ) @@ -186,34 +158,28 @@ export async function setRivalsWithResult( } }); - await UpdatePlayersRivalRankings(userID, game, playtype); + await UpdatePlayersRivalRankings(userID, game); return null; } export function SetRivals( userID: integer, - game: GameGroup, - playtype: Playtype, + game: V3Game, newRivals: Array, ): Promise { - return setRivalsWithResult(userID, game, playtype, newRivals); + return setRivalsWithResult(userID, game, newRivals); } /** * Add a single new rival by their userID. */ -export async function AddRival( - userID: integer, - game: GameGroup, - playtype: Playtype, - newRival: integer, -) { - const rivalIDs = await GetRivalIDs(userID, game, playtype); +export async function AddRival(userID: integer, game: V3Game, newRival: integer) { + const rivalIDs = await GetRivalIDs(userID, game); rivalIDs.push(newRival); - return setRivalsWithResult(userID, game, playtype, rivalIDs); + return setRivalsWithResult(userID, game, rivalIDs); } /** @@ -222,13 +188,8 @@ export async function AddRival( * @returns null if this UGPT is not rivals with the user, and therefore there is * nothing to change. */ -export async function RemoveRival( - userID: integer, - game: GameGroup, - playtype: Playtype, - toRemove: integer, -) { - const rivalIDs = await GetRivalIDs(userID, game, playtype); +export async function RemoveRival(userID: integer, game: V3Game, toRemove: integer) { + const rivalIDs = await GetRivalIDs(userID, game); const filteredRivals = rivalIDs.filter((e) => e !== toRemove); @@ -236,18 +197,16 @@ export async function RemoveRival( return null; } - return setRivalsWithResult(userID, game, playtype, filteredRivals); + return setRivalsWithResult(userID, game, filteredRivals); } /** * Get all of the userIDs of people who rival the userID for this GPT. */ -export async function GetChallengerIDs(userID: integer, game: GameGroup, playtype: Playtype) { - const v3Game = GamePTToV3(game, playtype) as Game; - +export async function GetChallengerIDs(userID: integer, game: V3Game) { const result = await DB.selectFrom("game_rival") .select("user_id") - .where("game", "=", v3Game) + .where("game", "=", game) .where("rival", "=", userID) .execute(); @@ -257,8 +216,8 @@ export async function GetChallengerIDs(userID: integer, game: GameGroup, playtyp /** * Get the user documents of everyone who is rivalling this userID for this GPT. */ -export async function GetChallengerUsers(userID: integer, game: GameGroup, playtype: Playtype) { - const challengerIDs = await GetChallengerIDs(userID, game, playtype); +export async function GetChallengerUsers(userID: integer, game: V3Game) { + const challengerIDs = await GetChallengerIDs(userID, game); return GetUsersWithIDs(challengerIDs); } @@ -274,21 +233,16 @@ export async function GetChallengerUsers(userID: integer, game: GameGroup, playt * * this sucks though. */ -export async function UpdatePlayersRivalRankings( - userID: integer, - game: GameGroup, - playtype: Playtype, -) { - const gptConfig = GetGamePTConfig(game, playtype); - const metricKey = String(gptConfig.defaultMetric); - const rivalIDs = await GetRivalIDs(userID, game, playtype); - const v3Game = GamePTToV3(game, playtype) as Game; +export async function UpdatePlayersRivalRankings(userID: integer, game: V3Game) { + const gameConfig = GetGameConfig(game); + const metricKey = String(gameConfig.defaultMetric); + const rivalIDs = await GetRivalIDs(userID, game); const userPBs = await DB.selectFrom("pb") .innerJoin("chart", "chart.id", "pb.chart_id") .select(["pb.row_id", "pb.chart_id", "pb.data", "pb.derived_data", "pb.calculated_data"]) .where("pb.user_id", "=", userID) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where("pb.lens", "is", null) .execute(); @@ -316,7 +270,7 @@ export async function UpdatePlayersRivalRankings( await Promise.all( userPBs.map(async (pb) => { - const userVal = metricValueFromPbRow(v3Game, pb.data, pb.derived_data, metricKey); + const userVal = metricValueFromPbRow(game, pb.data, pb.derived_data, metricKey); if (userVal === null) { return; } @@ -326,7 +280,7 @@ export async function UpdatePlayersRivalRankings( if (r.chart_id !== pb.chart_id) { continue; } - const rivalVal = metricValueFromPbRow(v3Game, r.data, r.derived_data, metricKey); + const rivalVal = metricValueFromPbRow(game, r.data, r.derived_data, metricKey); if (rivalVal !== null && rivalVal > userVal) { betterCount++; } diff --git a/typescript/server/src/lib/router/middleware.ts b/typescript/server/src/lib/router/middleware.ts new file mode 100644 index 000000000..36cf7296e --- /dev/null +++ b/typescript/server/src/lib/router/middleware.ts @@ -0,0 +1,613 @@ +/** + * TypedRouter middleware functions for the Tachi API. + * + * Each function accepts a Request and returns a Promise resolving to a specific context + * record that gets merged into `data.ctx`. Throwing an ExpectedErr rejects the request. + */ + +import type { Request } from "express"; + +import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; +import { GetChartByIdForGame } from "#lib/db-formats/chart"; +import { LoadFolderDocumentByGameAndSlug } from "#lib/db-formats/folders"; +import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles"; +import { SELECT_GOAL } from "#lib/db-formats/goal"; +import { LoadImportDocumentById } from "#lib/db-formats/import-document"; +import { SELECT_QUEST } from "#lib/db-formats/quest"; +import { LoadScoreDocumentById } from "#lib/db-formats/score"; +import { LoadSessionDocumentById } from "#lib/db-formats/session"; +import { GetSongByID } from "#lib/db-formats/song"; +import { LoadTableDocumentByLegacyId } from "#lib/db-formats/table"; +import { + AttachFolderSlugsToGoals, + ToGoalDocument, + ToQuestDocument, +} from "#lib/db-formats/target-documents"; +import { AllEnabledGames, Env, ServerConfig, TachiConfig } from "#lib/setup/config"; +import DB from "#services/pg/db"; +import { IsEnabledGame, IsEnabledGameGroup } from "#utils/misc"; +import { GetClientByID } from "#utils/queries/api-clients"; +import { GetQuestlineById } from "#utils/queries/questlines"; +import { REQ_AssignToReqTachiData } from "#utils/req-tachi-data"; +import { GetUserWithID, IsRequesterAdmin, ResolveUser } from "#utils/user"; +import { ExpectedErr } from "bliss"; +import { + type APIPermissions, + type ChartDocument, + type FolderDocument, + type GameGroup, + GameToGameGroup, + type GoalDocument, + type ImportDocument, + type QuestDocument, + type QuestlineDocument, + type ScoreDocument, + type SessionDocument, + type SongDocument, + type TableDocument, + type TachiAPIClientDocument, + UserAuthLevels, + type UserDocument, + type UserGameStats, + type V3Game, +} from "tachi-common"; + +import type { MiddlewareFn } from "./typed-router"; + +export type { MiddlewareFn } from "./typed-router"; + +/** + * Resolves :userID param ("me" or numeric/username) to a user document. + */ +export const withRequestedUser = async (req: Request): Promise<{ requestedUser: UserDocument }> => { + const paramUserID = req.params.userID; + + if (!paramUserID) { + throw new ExpectedErr(400, "No userID given."); + } + + if (paramUserID === "me") { + const authUserID = req[SYMBOL_TACHI_API_AUTH].userID; + + if (authUserID === null) { + throw new ExpectedErr(401, "Cannot use 'me' userID with no authentication."); + } + + if (req.session.tachi?.user) { + return { requestedUser: req.session.tachi.user }; + } + + const user = await GetUserWithID(authUserID); + + if (!user) { + throw new ExpectedErr(500, "You are signed in as someone who does not exist."); + } + + return { requestedUser: user }; + } + + const user = await ResolveUser(paramUserID); + + if (!user) { + throw new ExpectedErr(404, `The user ${paramUserID} does not exist.`); + } + + return { requestedUser: user }; +}; + +/** + * Like {@link withRequestedUser}, but also assigns `requestedUser` onto request + * tachi data for legacy helpers (e.g. REQ_GetUser, BMS table handlers). + */ +export const withRequestedUserAndReqData: MiddlewareFn = async (req) => { + const ctx = await withRequestedUser(req); + REQ_AssignToReqTachiData(req, { requestedUser: ctx.requestedUser }); + return ctx; +}; + +/** + * Requires session-level auth where the requester IS the :userID param. + * Does not return context; only validates the requester. + */ +export const withSelf = (req: Request): Promise> => { + const auth = req[SYMBOL_TACHI_API_AUTH]; + + if (auth.userID === null) { + throw new ExpectedErr(401, "This endpoint requires session-level authentication."); + } + + if (auth.token !== null) { + throw new ExpectedErr( + 403, + "This request cannot be performed by an API key and requires session authentication as this user.", + ); + } + + if (!req.session.tachi?.user.id) { + throw new ExpectedErr(401, "This endpoint requires session-level authentication."); + } + + const paramUserID = req.params.userID; + + if (paramUserID !== "me" && Number(paramUserID) !== auth.userID) { + throw new ExpectedErr(403, "You are not permitted to perform this action for this user."); + } + + return Promise.resolve({} as Record); +}; + +/** + * Requires the requesting user to be the :userID param or an admin. + * Does not return context; only validates the requester. + */ +export const withAuthedAsUser = async (req: Request): Promise> => { + const auth = req[SYMBOL_TACHI_API_AUTH]; + + if (auth.userID === null) { + throw new ExpectedErr(401, "Authentication is required for this endpoint."); + } + + const requestingUser = await GetUserWithID(auth.userID); + + if (!requestingUser) { + throw new ExpectedErr(500, "You are signed in as someone who does not exist."); + } + + if (requestingUser.authLevel === UserAuthLevels.ADMIN) { + return {} as Record; + } + + const paramUserID = req.params.userID; + + if (paramUserID !== "me" && Number(paramUserID) !== auth.userID) { + throw new ExpectedErr(403, "You are not authorised as this user."); + } + + return {} as Record; +}; + +/** Validates the :gameGroup param and returns `{ gameGroup }`. */ +export const withGameGroup = (req: Request): Promise<{ gameGroup: GameGroup }> => { + const gameGroup = req.params.gameGroup; + + if (!gameGroup || !IsEnabledGameGroup(gameGroup)) { + throw new ExpectedErr( + 400, + `Invalid/unsupported game ${gameGroup} - Expected one of ${TachiConfig.GAME_GROUPS.join(", ")}`, + ); + } + + return Promise.resolve({ gameGroup }); +}; + +export const withGame = (req: Request): Promise<{ game: V3Game }> => { + const gameParam = req.params.game; + + if (!gameParam || !IsEnabledGame(gameParam)) { + throw new ExpectedErr( + 400, + `Invalid/unsupported game ${gameParam} - Expected one of ${AllEnabledGames().join(", ")}`, + ); + } + + return Promise.resolve({ game: gameParam }); +}; + +/** + * Resolves the :userID param, validates :game, and loads the + * user's game stats. Returns `{ game, requestedUser, userGameStats }`. + */ +export const withUserGameProfile = async ( + req: Request, +): Promise<{ + game: V3Game; + requestedUser: UserDocument; + userGameStats: UserGameStats; +}> => { + const paramUserID = req.params.userID; + + if (!paramUserID) { + throw new ExpectedErr(400, "No userID given."); + } + + let user: UserDocument; + + if (paramUserID === "me") { + const authUserID = req[SYMBOL_TACHI_API_AUTH].userID; + + if (authUserID === null) { + throw new ExpectedErr(401, "Cannot use 'me' userID with no authentication."); + } + + if (req.session.tachi?.user) { + user = req.session.tachi.user; + } else { + const resolved = await GetUserWithID(authUserID); + + if (!resolved) { + throw new ExpectedErr(500, "You are signed in as someone who does not exist."); + } + + user = resolved; + } + } else { + const resolved = await ResolveUser(paramUserID); + + if (!resolved) { + throw new ExpectedErr(404, `The user ${paramUserID} does not exist.`); + } + + user = resolved; + } + + const gameParam = req.params.game; + + if (!gameParam || !IsEnabledGame(gameParam)) { + throw new ExpectedErr( + 400, + `Invalid/unsupported game ${gameParam} - Expected one of ${AllEnabledGames().join(", ")}`, + ); + } + + const game = gameParam; + + const row = await DB.selectFrom("game_profile") + .select(SELECT_GAME_PROFILE) + .where("user_id", "=", user.id) + .where("game", "=", game) + .executeTakeFirst(); + + if (!row) { + throw new ExpectedErr(404, `The user ${user.username} has not played ${game}`); + } + + const userGameStats = ToGameStatsDocument(row); + + return { game, requestedUser: user, userGameStats }; +}; + +/** Resolves :scoreID to a score document. Returns `{ scoreDoc }`. */ +export const withScore = async (req: Request): Promise<{ scoreDoc: ScoreDocument }> => { + const score = await LoadScoreDocumentById(req.params.scoreID); + + if (!score) { + throw new ExpectedErr(404, "This score does not exist."); + } + + return { scoreDoc: score }; +}; + +/** + * Requires the authenticated user to own the score at :scoreID (or be admin). + * Does not return context; only validates ownership. + */ +export const withScoreOwner = async (req: Request): Promise> => { + const score = await LoadScoreDocumentById(req.params.scoreID); + + if (!score) { + throw new ExpectedErr(404, "This score does not exist."); + } + + const auth = req[SYMBOL_TACHI_API_AUTH]; + + if (auth.userID === null) { + throw new ExpectedErr(401, "Authentication is required to modify this score."); + } + + if (score.userID !== auth.userID) { + const isAdmin = await IsRequesterAdmin(auth); + + if (!isAdmin) { + throw new ExpectedErr(403, "You are not authorised to perform this action."); + } + } + + return {} as Record; +}; + +/** Resolves :sessionID to a session document. Returns `{ sessionDoc }`. */ +export const withSession = async (req: Request): Promise<{ sessionDoc: SessionDocument }> => { + const session = await LoadSessionDocumentById(req.params.sessionID); + + if (!session) { + throw new ExpectedErr(404, "This session does not exist."); + } + + return { sessionDoc: session }; +}; + +/** + * Requires the authenticated user to own the session at :sessionID. + * Does not return context; only validates ownership. + */ +export const withSessionOwner = async (req: Request): Promise> => { + const session = await LoadSessionDocumentById(req.params.sessionID); + + if (!session) { + throw new ExpectedErr(404, "This session does not exist."); + } + + const auth = req[SYMBOL_TACHI_API_AUTH]; + + if (auth.userID !== session.userID) { + throw new ExpectedErr(403, "You are not authorised to modify this session."); + } + + return {} as Record; +}; + +/** + * Resolves :importID to an import document. Returns `{ importDoc }`. + */ +export const withImport = async (req: Request): Promise<{ importDoc: ImportDocument }> => { + const importDoc = await LoadImportDocumentById(req.params.importID); + + if (!importDoc) { + throw new ExpectedErr(404, "This import does not exist."); + } + + return { importDoc }; +}; + +/** + * Resolves :chartID for the game in params. + * Returns `{ chartDoc }`. + */ +export const withChart = async (req: Request): Promise<{ chartDoc: ChartDocument }> => { + const gameParam = req.params.game; + + if (!gameParam || !IsEnabledGame(gameParam)) { + throw new ExpectedErr(400, `Invalid game ${gameParam}.`); + } + + const game = gameParam; + + const chart = await GetChartByIdForGame(game, req.params.chartID); + + if (!chart) { + throw new ExpectedErr(404, `The chart ${req.params.chartID} does not exist.`); + } + + return { chartDoc: chart }; +}; + +/** + * Resolves :clientID to an API client document (secret stripped). + * Returns `{ apiClientDoc }`. + */ +export const withClient = async ( + req: Request, +): Promise<{ apiClientDoc: Omit }> => { + const client = await GetClientByID(req.params.clientID); + + if (!client) { + throw new ExpectedErr(404, "This client does not exist."); + } + + const { clientSecret: _secret, ...publicClient } = client; + + return { apiClientDoc: publicClient }; +}; + +/** + * Resolves :folderSlug to a folder document (requires :game). Returns `{ folderDoc }`. + */ +export const withFolder = async (req: Request): Promise<{ folderDoc: FolderDocument }> => { + const gpt = await withGame(req); + const folder = await LoadFolderDocumentByGameAndSlug(gpt.game, req.params.folderSlug); + + if (!folder) { + throw new ExpectedErr(404, `The folder ${req.params.folderSlug} does not exist.`); + } + + return { folderDoc: folder }; +}; + +/** + * Resolves :tableID (legacy string ID) to a table document. + * Returns `{ tableDoc }`. + */ +export const withTable = async (req: Request): Promise<{ tableDoc: TableDocument }> => { + const table = await LoadTableDocumentByLegacyId(req.params.tableID); + + if (!table) { + throw new ExpectedErr(404, `The table ${req.params.tableID} does not exist.`); + } + + return { tableDoc: table }; +}; + +/** + * Resolves :songID for the game in params. + * Returns `{ newSongID, songDoc }`. + */ +export const withSong = async ( + req: Request, +): Promise<{ newSongID: string; songDoc: SongDocument }> => { + const gameParam = req.params.game; + + if (!gameParam || !IsEnabledGame(gameParam)) { + throw new ExpectedErr(400, `Invalid game ${gameParam}.`); + } + + const gameGroup = GameToGameGroup(gameParam); + + const result = await GetSongByID(gameGroup, req.params.songID); + + if (!result) { + throw new ExpectedErr(404, `The song ${req.params.songID} does not exist.`); + } + + return { newSongID: result.newSongID, songDoc: result.doc }; +}; + +/** + * Resolves :goalID for the game in params. + * Returns `{ goalDoc }`. + */ +export const withGoal = async (req: Request): Promise<{ goalDoc: GoalDocument }> => { + const gameParam = req.params.game; + + if (!gameParam || !IsEnabledGame(gameParam)) { + throw new ExpectedErr(400, `Invalid game ${gameParam}.`); + } + + const game = gameParam; + + const row = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "=", req.params.goalID) + .where("goal.game", "=", game) + .executeTakeFirst(); + + if (!row) { + throw new ExpectedErr(404, `A goal with ID ${req.params.goalID} doesn't exist.`); + } + + const goalDoc = ToGoalDocument(row); + await AttachFolderSlugsToGoals([goalDoc]); + + return { goalDoc }; +}; + +/** + * Resolves :questID for the game in params. + * Returns `{ questDoc }`. + */ +export const withQuest = async (req: Request): Promise<{ questDoc: QuestDocument }> => { + const gameParam = req.params.game; + + if (!gameParam || !IsEnabledGame(gameParam)) { + throw new ExpectedErr(400, `Invalid game ${gameParam}.`); + } + + const game = gameParam; + + const row = await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "=", req.params.questID) + .where("quest.game", "=", game) + .executeTakeFirst(); + + if (!row) { + throw new ExpectedErr(404, `A quest with ID ${req.params.questID} doesn't exist.`); + } + + return { questDoc: ToQuestDocument(row) }; +}; + +/** + * Resolves :questlineID for the game in params. + * Returns `{ questlineDoc }`. + */ +export const withQuestline = async (req: Request): Promise<{ questlineDoc: QuestlineDocument }> => { + const gameParam = req.params.game; + + if (!gameParam || !IsEnabledGame(gameParam)) { + throw new ExpectedErr(400, `Invalid game ${gameParam}.`); + } + + const game = gameParam; + + const questline = await GetQuestlineById(game, req.params.questlineID); + + if (!questline) { + throw new ExpectedErr(404, `A questline with ID ${req.params.questlineID} doesn't exist.`); + } + + return { questlineDoc: questline }; +}; + +/** + * Requires the requesting user to have admin-level auth. + * Does not return context; only validates. + */ +export const withAdmin = async (req: Request): Promise> => { + const auth = req[SYMBOL_TACHI_API_AUTH]; + + if (auth.userID === null) { + throw new ExpectedErr(401, "You are not authenticated."); + } + + const userDoc = await GetUserWithID(auth.userID); + + if (!userDoc) { + throw new ExpectedErr(500, "An internal error has occurred."); + } + + if (userDoc.authLevel !== UserAuthLevels.ADMIN) { + throw new ExpectedErr(403, "You are not authorised to perform this."); + } + + return {} as Record; +}; + +/** + * Restricts the route to local development/test environments. + * Does not return context; only validates. + */ +export const withLocalDev = (_req: Request): Promise> => { + if (Env.NODE_ENV !== "dev" && Env.NODE_ENV !== "test") { + throw new ExpectedErr( + 403, + "This endpoint is only available in local development or test environments.", + ); + } + + return Promise.resolve({} as Record); +}; + +/** + * Restricts the route to Bokutachi (or omni) instances. + * Does not return context; only validates. + */ +export const withBokutachi = (_req: Request): Promise> => { + if (TachiConfig.TYPE !== "boku" && TachiConfig.TYPE !== "omni") { + throw new ExpectedErr(404, `This endpoint is not available on ${TachiConfig.NAME}.`); + } + + return Promise.resolve({} as Record); +}; + +/** + * Restricts the route to Kamaitachi (or omni) instances. + * Does not return context; only validates. + */ +export const withKamaitachi = (_req: Request): Promise> => { + if (TachiConfig.TYPE !== "kamai" && TachiConfig.TYPE !== "omni") { + throw new ExpectedErr(404, `This endpoint is not available on ${TachiConfig.NAME}.`); + } + + return Promise.resolve({} as Record); +}; + +/** + * Restricts the route to instances that have invite codes configured. + * Does not return context; only validates. + */ +export const withInvitesEnabled = (_req: Request): Promise> => { + if (!ServerConfig.INVITE_CODE_CONFIG) { + throw new ExpectedErr(404, "Invites are not enabled on this instance."); + } + + return Promise.resolve({} as Record); +}; + +/** + * Returns a middleware that requires a specific API permission. + * Does not return context; only validates. + * + * @example + * router.add("POST /route", withPermission("submit_score"), handler) + */ +export const withPermission = + (permission: APIPermissions) => + (req: Request): Promise> => { + const auth = req[SYMBOL_TACHI_API_AUTH]; + + if (!auth.permissions[permission]) { + throw new ExpectedErr(403, `This request requires the "${permission}" permission.`); + } + + return Promise.resolve({} as Record); + }; diff --git a/typescript/server/src/lib/router/typed-router.ts b/typescript/server/src/lib/router/typed-router.ts new file mode 100644 index 000000000..c412155b0 --- /dev/null +++ b/typescript/server/src/lib/router/typed-router.ts @@ -0,0 +1,264 @@ +import { log } from "#lib/log/log"; +import { staticAssertUnreachable } from "#utils/misc"; +import { ExpectedErr } from "bliss"; +import { type Handler, type Request, type RequestHandler, type Response, Router } from "express"; +import { type output, type ZodObject, type ZodTypeAny } from "zod"; + +/// Extract what params are available from the param string. +type ExtractPathParams = S extends `${string}:${infer Param}/${infer Rest}` + ? { [K in Param]: string } & ExtractPathParams + : S extends `${string}:${infer Param}` + ? { [K in Param]: string } + : Record; + +export type RouteName = `${Method} ${string}`; +type Method = "DELETE" | "GET" | "PATCH" | "POST" | "PUT"; + +type ExtractRouteParams = Route extends `${Method} ${infer Path}` + ? ExtractPathParams + : Record; + +export type MiddlewareFn = (req: Request) => Promise>; + +type UnionToIntersection = (U extends unknown ? (x: U) => void : never) extends ( + x: infer I, +) => void + ? I + : never; + +type MergeCtx = [M[number]] extends [never] + ? Record + : UnionToIntersection>>; + +export interface RouteSpec { + description: string; + input: ZodObject; + output: ZodTypeAny; +} + +export type AnyRouterSpec = Record; + +export interface RouteData< + TParams extends Record, + TInput, + TCtx = Record, +> { + params: TParams; + /** + * Parsed input. This is req.query for GET requests and req.body for everything else. + */ + input: TInput; + ctx: TCtx; + /** Escape hatch */ + req: Request; + /** Escape hatch for handlers that must send their own response (e.g. redirects). */ + res: Response; +} + +export interface ApiResponse { + success: true; + description: string; + body: T; + /** + * Optional HTTP status code override. Defaults to 200. + * Stripped from the JSON response body before sending. + */ + $status?: number; +} + +/** Returns a typed ApiResponse with status 200. */ +export function success(description: string, body: T): ApiResponse { + return { success: true, description, body }; +} + +/** + * Wraps an Express RequestHandler as a TypedRouter MiddlewareFn. + * Useful for adapting Express middleware (e.g. rate limiters) that call next(). + */ +export function wrapExpressMiddleware(mw: RequestHandler): MiddlewareFn { + return (req: Request) => + new Promise((resolve, reject) => { + // req.res is set by Express internally on every request. + mw(req, req.res!, (err?: unknown) => { + if (err) { + reject(err instanceof Error ? err : new Error(String(err))); + } else { + resolve({}); + } + }); + }); +} + +type MaybePromise = Promise | T; + +/** Type helper for declaring handlers in sub-files. */ +export type RouteHandler< + S extends AnyRouterSpec, + Route extends keyof S & string, + M extends readonly MiddlewareFn[] = readonly [], +> = ( + data: RouteData< + ExtractRouteParams, + output<(RouteSpec & S[Route])["input"]>, + MergeCtx + >, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => MaybePromise>; + +type StoredEntry = { + impl: ( + data: RouteData, unknown, unknown>, + ) => ApiResponse | Promise>; + middleware: readonly MiddlewareFn[]; +}; + +export class TypedRouter { + readonly #rawRoutes: Array<{ + args: Array; + method: "DELETE" | "GET" | "OPTIONS" | "PATCH" | "POST" | "PUT"; + route: string; + }> = []; + readonly #routes = new Map(); + readonly #spec: S; + + constructor(spec: S) { + this.#spec = spec; + } + + add( + route: Route, + ...args: [...M, RouteHandler] + ): this { + if (this.#routes.has(route)) { + throw new Error(`TypedRouter: "${route}" was registered twice.`); + } + + const middleware = args.slice(0, -1) as unknown as readonly MiddlewareFn[]; + const impl = args[args.length - 1] as unknown as StoredEntry["impl"]; + + this.#routes.set(route, { middleware, impl }); + + return this; + } + + build(): Router { + const missing = (Object.keys(this.#spec) as (keyof S & string)[]).filter( + (route) => !this.#routes.has(route), + ); + + if (missing.length > 0) { + throw new Error( + `TypedRouter.build(): missing implementations for:\n${missing.map((r) => ` - ${r}`).join("\n")}`, + ); + } + + const router = Router({ mergeParams: true }); + + for (const [route, { middleware, impl }] of this.#routes) { + const spaceIdx = route.indexOf(" "); + const method = route.slice(0, spaceIdx) as Method; + const path = route.slice(spaceIdx + 1); + + const wrappedImpl = async (req: Request, res: Response) => { + const ctxParts = await Promise.all(middleware.map((mw) => mw(req))); + const ctx = Object.assign({}, ...ctxParts); + + const specEntry = this.#spec[route as keyof S] as RouteSpec; + const input = method === "GET" ? req.query : req.safeBody; + const parseResult = specEntry.input.safeParse(input); + + if (!parseResult.success) { + throw new ExpectedErr( + 400, + `Invalid request: ${parseResult.error.issues.map((i) => `${i.path} ${i.message}`).join(", ")}`, + ); + } + + const result = await impl({ + params: req.params, + input: parseResult.data, + ctx, + req, + res, + }); + + // Allow handlers to send their own response (e.g. redirects, activity feeds). + if (res.headersSent) { + return; + } + + const outputParseResult = specEntry.output.safeParse(result.body); + + if (!outputParseResult.success) { + log.error( + { route, issues: outputParseResult.error.issues }, + "TypedRouter: output validation failed — handler returned body not matching spec output schema", + ); + } + + const { $status, ...responseBody } = result; + + return res.status($status ?? 200).json(responseBody); + }; + + switch (method) { + case "GET": + router.get(path, wrappedImpl); + break; + case "POST": + router.post(path, wrappedImpl); + break; + case "PUT": + router.put(path, wrappedImpl); + break; + case "DELETE": + router.delete(path, wrappedImpl); + break; + case "PATCH": + router.patch(path, wrappedImpl); + break; + default: + staticAssertUnreachable(method); + } + } + + for (const { method, route, args } of this.#rawRoutes) { + switch (method) { + case "DELETE": + router.delete(route, ...args); + break; + case "GET": + router.get(route, ...args); + break; + case "OPTIONS": + router.options(route, ...args); + break; + case "PATCH": + router.patch(route, ...args); + break; + case "POST": + router.post(route, ...args); + break; + case "PUT": + router.put(route, ...args); + break; + default: + staticAssertUnreachable(method); + } + } + + return router; + } + + rawAdd( + method: "DELETE" | "GET" | "OPTIONS" | "PATCH" | "POST" | "PUT", + route: string, + ...args: Array + ) { + this.#rawRoutes.push({ + method, + route, + args, + }); + } +} diff --git a/typescript/server/src/lib/score-import/framework/calculated-data/profile-classes.ts b/typescript/server/src/lib/score-import/framework/calculated-data/profile-classes.ts index 82bf96b64..190979881 100644 --- a/typescript/server/src/lib/score-import/framework/calculated-data/profile-classes.ts +++ b/typescript/server/src/lib/score-import/framework/calculated-data/profile-classes.ts @@ -1,12 +1,12 @@ -import type { ExtractedClasses, GPTString, MONGO_UserGameStats } from "tachi-common"; +import type { ExtractedClasses, UserGameStats, V3Game } from "tachi-common"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; -export function CalculateDerivedClasses( - gptString: GPT, - profileRatings: MONGO_UserGameStats["ratings"], +export function CalculateDerivedClasses( + game: TGame, + profileRatings: UserGameStats["ratings"], ) { - return GPT_SERVER_IMPLEMENTATIONS[gptString].classDerivers(profileRatings) as Partial< - ExtractedClasses[GPT] + return GAME_IMPLEMENTATIONS[game].classDerivers(profileRatings) as Partial< + ExtractedClasses[TGame] >; } diff --git a/typescript/server/src/lib/score-import/framework/calculated-data/profile.ts b/typescript/server/src/lib/score-import/framework/calculated-data/profile.ts index cecb302b3..f4ef0b445 100644 --- a/typescript/server/src/lib/score-import/framework/calculated-data/profile.ts +++ b/typescript/server/src/lib/score-import/framework/calculated-data/profile.ts @@ -1,28 +1,15 @@ -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; -import { - type GameGroup, - GetGPTString, - type integer, - type MONGO_UserGameStats, - type Playtype, -} from "tachi-common"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { type GPTProfileCalcs } from "#game-implementations/types"; +import { type integer, type UserGameStats, type V3Game } from "tachi-common"; /** - * Calculate profile ratings for this UGPT. + * Calculate profile ratings for this profile. */ export function CalculateProfileRatings( - game: GameGroup, - playtype: Playtype, + game: V3Game, userID: integer, -): Promise { - const gptString = GetGPTString(game, playtype); +): Promise { + const profileCalcs = GAME_IMPLEMENTATIONS[game].profileCalcs as GPTProfileCalcs; - // Per-GPT `profileCalcs` take `GPTStringToGame`; at runtime `game` is correct for `gptString`. - const profileCalcs = GPT_SERVER_IMPLEMENTATIONS[gptString].profileCalcs as ( - game: GameGroup, - playtype: Playtype, - userID: integer, - ) => Promise; - - return profileCalcs(game, playtype, userID); + return profileCalcs(game, userID); } diff --git a/typescript/server/src/lib/score-import/framework/calculated-data/score.ts b/typescript/server/src/lib/score-import/framework/calculated-data/score.ts index c0d0cdf7f..abd03bbaf 100644 --- a/typescript/server/src/lib/score-import/framework/calculated-data/score.ts +++ b/typescript/server/src/lib/score-import/framework/calculated-data/score.ts @@ -1,11 +1,9 @@ -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { - type GameGroup, - GetGPTString, - type GPTString, - type MONGO_ChartDocument, - type MONGO_ScoreData, + type ChartDocument, type MongoDerivedMetrics, + type ScoreData, + type V3Game, } from "tachi-common"; import type { DryScoreData } from "../common/types"; @@ -14,27 +12,25 @@ import type { DryScoreData } from "../common/types"; * Create calculated data for a score. * @param scores - All of the scores in this session. */ -export function CreateScoreCalcData( - game: GameGroup, - scoreData: DryScoreData | MONGO_ScoreData, - chart: MONGO_ChartDocument, +export function CreateScoreCalcData( + scoreData: DryScoreData | ScoreData, + chart: ChartDocument, ) { - const gptString = GetGPTString(game, chart.playtype); - const impl = GPT_SERVER_IMPLEMENTATIONS[gptString]; + const impl = GAME_IMPLEMENTATIONS[chart.game]; // Union of per-GPT `scoreDeriver` signatures is not callable with generic `GPT`. const derivedData = impl.scoreDeriver( scoreData as any, chart as any, - ) as MongoDerivedMetrics[GPT]; + ) as MongoDerivedMetrics[TGame]; // Per-GPT `scoreCalcs` take game-specific score/derived types; at runtime inputs match `gptString`. const scoreCalcs = impl.scoreCalcs as unknown as ( - scoreData: MONGO_ScoreData, - derivedData: MongoDerivedMetrics[GPT], - chart: MONGO_ChartDocument, + scoreData: ScoreData, + derivedData: MongoDerivedMetrics[TGame], + chart: ChartDocument, ) => Record; - return scoreCalcs(scoreData as MONGO_ScoreData, derivedData, chart); + return scoreCalcs(scoreData as ScoreData, derivedData, chart); } diff --git a/typescript/server/src/lib/score-import/framework/calculated-data/session.ts b/typescript/server/src/lib/score-import/framework/calculated-data/session.ts index a4eb1f220..d57a3cabc 100644 --- a/typescript/server/src/lib/score-import/framework/calculated-data/session.ts +++ b/typescript/server/src/lib/score-import/framework/calculated-data/session.ts @@ -1,10 +1,10 @@ -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; -import { type GPTString, type MONGO_ScoreDocument } from "tachi-common"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { type ScoreDocument, type V3Game } from "tachi-common"; /** * Create calculated data for a session of this game and playtype. * @param scores - All of the scores in this session. */ -export function CreateSessionCalcData(gpt: GPTString, scores: Array) { - return GPT_SERVER_IMPLEMENTATIONS[gpt].sessionCalcs(scores.map((e) => e.calculatedData)); +export function CreateSessionCalcData(game: V3Game, scores: Array) { + return GAME_IMPLEMENTATIONS[game].sessionCalcs(scores.map((e) => e.calculatedData)); } diff --git a/typescript/server/src/lib/score-import/framework/calculated-data/types.ts b/typescript/server/src/lib/score-import/framework/calculated-data/types.ts index 140451087..c9c433857 100644 --- a/typescript/server/src/lib/score-import/framework/calculated-data/types.ts +++ b/typescript/server/src/lib/score-import/framework/calculated-data/types.ts @@ -1,6 +1,5 @@ import type { KtLogger } from "#lib/log/log"; -import type { ClassConfigs, Classes, GPTString, integer } from "tachi-common"; -import type { DerivedClassConfig, ProvidedClassConfig } from "tachi-common/types/game-config-utils"; +import type { Classes, integer, V3Game } from "tachi-common"; // type RecordClassProvider = { // [C in keyof ClassConfigs[GPT] as ClassConfigs[GPT][C] extends ProvidedClassConfig @@ -9,11 +8,11 @@ import type { DerivedClassConfig, ProvidedClassConfig } from "tachi-common/types // }; // couldn't figure out how to get this typesafe, sorry. -type RecordClassProvider = Record; +type RecordClassProvider = Record; -export type ClassProvider = ( - gptString: GPT, +export type ClassProvider = ( + game: TGame, userID: integer, ratings: Record, log: KtLogger, -) => Partial> | Promise>> | undefined; +) => Partial> | Promise>> | undefined; diff --git a/typescript/server/src/lib/score-import/framework/common/converter-failures.ts b/typescript/server/src/lib/score-import/framework/common/converter-failures.ts index bfdad8397..1e6978823 100644 --- a/typescript/server/src/lib/score-import/framework/common/converter-failures.ts +++ b/typescript/server/src/lib/score-import/framework/common/converter-failures.ts @@ -1,9 +1,11 @@ // Various Errors that can occur during processing. +import type { + ImportTypeContextMap, + ImportTypeDataMap, +} from "#lib/score-import/import-types/common/types"; import type { ImportTypes } from "tachi-common"; -import type { ImportTypeContextMap, ImportTypeDataMap } from "../../import-types/common/types"; - export type FailureTypes = | "AmbiguousTitle" | "Internal" diff --git a/typescript/server/src/lib/score-import/framework/common/get-input-parser.ts b/typescript/server/src/lib/score-import/framework/common/get-input-parser.ts index 4d7dec16b..ff45fb83f 100644 --- a/typescript/server/src/lib/score-import/framework/common/get-input-parser.ts +++ b/typescript/server/src/lib/score-import/framework/common/get-input-parser.ts @@ -5,7 +5,7 @@ import type { ParserFunctionReturns, } from "#lib/score-import/import-types/common/types"; import type { ScoreImportJobData } from "#lib/score-import/worker/types"; -import type { ImportTypes } from "tachi-common"; +import type { ImportTypes, V3Game } from "tachi-common"; import { Parsers } from "#lib/score-import/import-types/parsers"; @@ -20,7 +20,7 @@ export function GetInputParser(jobData: ScoreImportJobDat // achieve the dynamic passing we need to, so lets just override // it here. ParserFunction(...jobData.parserArguments, log) as Promise< - ParserFunctionReturns + ParserFunctionReturns >; return InputParser; diff --git a/typescript/server/src/lib/score-import/framework/common/import-logger.ts b/typescript/server/src/lib/score-import/framework/common/import-logger.ts index 53b17f7c9..9d70c25f2 100644 --- a/typescript/server/src/lib/score-import/framework/common/import-logger.ts +++ b/typescript/server/src/lib/score-import/framework/common/import-logger.ts @@ -1,10 +1,10 @@ -import type { ImportTypes, MONGO_UserDocument } from "tachi-common"; +import type { ImportTypes, UserDocument } from "tachi-common"; import { type KtLogger, log } from "#lib/log/log"; import { FormatUserDoc } from "#utils/user"; export function CreateScoreLogger( - user: MONGO_UserDocument, + user: UserDocument, importID: string, importType: ImportTypes, ): KtLogger { diff --git a/typescript/server/src/lib/score-import/framework/common/score-utils.ts b/typescript/server/src/lib/score-import/framework/common/score-utils.ts index aba57fa75..b4169a489 100644 --- a/typescript/server/src/lib/score-import/framework/common/score-utils.ts +++ b/typescript/server/src/lib/score-import/framework/common/score-utils.ts @@ -28,10 +28,7 @@ export function ParseDateFromString(str: string | null | undefined): number | nu * Museca actually handles clears differently with a bunch of grafica nonsense, * but nobody actually cares about it, so... */ -export function MusecaGetLamp( - score: integer, - missCount: integer, -): GetEnumValue<"museca:Single", "lamp"> { +export function MusecaGetLamp(score: integer, missCount: integer): GetEnumValue<"museca", "lamp"> { if (score === 1_000_000) { return "PERFECT CONNECT ALL"; } else if (missCount === 0) { @@ -43,10 +40,7 @@ export function MusecaGetLamp( return "FAILED"; } -export function JubeatGetLamp( - score: integer, - missCount: integer, -): GetEnumValue<"jubeat:Single", "lamp"> { +export function JubeatGetLamp(score: integer, missCount: integer): GetEnumValue<"jubeat", "lamp"> { if (score === 1_000_000) { return "EXCELLENT"; } else if (missCount === 0) { diff --git a/typescript/server/src/lib/score-import/framework/common/string-asserts.oldtest.ts b/typescript/server/src/lib/score-import/framework/common/string-asserts.oldtest.ts deleted file mode 100644 index e9f4bc181..000000000 --- a/typescript/server/src/lib/score-import/framework/common/string-asserts.oldtest.ts +++ /dev/null @@ -1,187 +0,0 @@ -import t from "tap"; - -import { InvalidScoreFailure } from "./converter-failures"; -import { AssertStrAsPositiveInt, AssertStrAsPositiveNonZeroInt } from "./string-asserts"; - -function astr(v: string) { - try { - const r = AssertStrAsPositiveInt(v, "err"); - - return r; - } catch (e) { - return e; - } -} - -function astrp(v: string) { - try { - const r = AssertStrAsPositiveNonZeroInt(v, "err"); - - return r; - } catch (e) { - return e; - } -} - -t.test("#AssertStrAsPositiveInt", (t) => { - t.strictSame( - astr("---"), - new InvalidScoreFailure(`err (Not an integer -- ---.)`), - "Should reject non-int input", - ); - - t.strictSame( - astr("1.4"), - new InvalidScoreFailure(`err (Not an integer -- 1.4.)`), - "Should reject float input", - ); - - t.strictSame( - astr("NaN"), - new InvalidScoreFailure(`err (Not an integer -- NaN.)`), - "Should reject NaN", - ); - t.strictSame( - astr("-0.3"), - new InvalidScoreFailure(`err (Not an integer -- -0.3.)`), - "Should reject negative float input", - ); - - t.strictSame( - astr("0xFF"), - new InvalidScoreFailure(`err (Not an integer -- 0xFF.)`), - "Should reject hex input", - ); - - t.strictSame( - astr("0b11"), - new InvalidScoreFailure(`err (Not an integer -- 0b11.)`), - "Should reject binary input", - ); - - t.strictSame( - astr("0o77"), - new InvalidScoreFailure(`err (Not an integer -- 0o77.)`), - "Should reject oct input", - ); - - t.strictSame( - astr("--1"), - new InvalidScoreFailure(`err (Not an integer -- --1.)`), - "Should reject double neg input", - ); - - t.strictSame( - astr("12f"), - new InvalidScoreFailure(`err (Not an integer -- 12f.)`), - "Should reject valid parseInt but invalid Number() input", - ); - - t.strictSame( - astr(`${Number.MAX_SAFE_INTEGER.toString()}000`), - - // @warn Counterintuitive errmsg here. It is an int! - new InvalidScoreFailure( - `err (Not an integer -- ${Number.MAX_SAFE_INTEGER.toString()}000.)`, - ), - "Should reject numbers > max_safe_integer.", - ); - - t.strictSame( - astr("-1"), - new InvalidScoreFailure(`err (Was negative -- -1.)`), - "Should reject negative integer input", - ); - - t.strictSame(astr("-0"), 0, "Should allow negative 0 input"); - t.strictSame(astr("0"), 0, "Should allow 0 input"); - t.strictSame(astr("13"), 13, "Should allow valid int input"); - - t.end(); -}); - -t.test("#AssertStrAsPositiveNonZeroInt", (t) => { - t.strictSame( - astrp("---"), - new InvalidScoreFailure(`err (Not an integer -- ---.)`), - "Should reject non-int input", - ); - - t.strictSame( - astrp("1.4"), - new InvalidScoreFailure(`err (Not an integer -- 1.4.)`), - "Should reject float input", - ); - - t.strictSame( - astrp("NaN"), - new InvalidScoreFailure(`err (Not an integer -- NaN.)`), - "Should reject NaN", - ); - t.strictSame( - astrp("-0.3"), - new InvalidScoreFailure(`err (Not an integer -- -0.3.)`), - "Should reject negative float input", - ); - - t.strictSame( - astrp("0xFF"), - new InvalidScoreFailure(`err (Not an integer -- 0xFF.)`), - "Should reject hex input", - ); - - t.strictSame( - astrp("0b11"), - new InvalidScoreFailure(`err (Not an integer -- 0b11.)`), - "Should reject binary input", - ); - - t.strictSame( - astrp("0o77"), - new InvalidScoreFailure(`err (Not an integer -- 0o77.)`), - "Should reject oct input", - ); - - t.strictSame( - astrp("--1"), - new InvalidScoreFailure(`err (Not an integer -- --1.)`), - "Should reject double neg input", - ); - - t.strictSame( - astrp("12f"), - new InvalidScoreFailure(`err (Not an integer -- 12f.)`), - "Should reject valid parseInt but invalid Number() input", - ); - - t.strictSame( - astrp(`${Number.MAX_SAFE_INTEGER.toString()}000`), - - // @warn Counterintuitive errmsg here. It is an int! - new InvalidScoreFailure( - `err (Not an integer -- ${Number.MAX_SAFE_INTEGER.toString()}000.)`, - ), - "Should reject numbers > max_safe_integer.", - ); - - t.strictSame( - astrp("-1"), - new InvalidScoreFailure(`err (Was negative or zero -- -1.)`), - "Should reject negative integer input", - ); - t.strictSame( - astrp("-0"), - new InvalidScoreFailure(`err (Was negative or zero -- 0.)`), - "Should reject negative 0 input", - ); - - t.strictSame( - astrp("0"), - new InvalidScoreFailure(`err (Was negative or zero -- 0.)`), - "Should reject 0 input", - ); - - t.strictSame(astrp("13"), 13, "Should allow valid int input"); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/common/string-asserts.test.ts b/typescript/server/src/lib/score-import/framework/common/string-asserts.test.ts new file mode 100644 index 000000000..0682448ea --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/common/string-asserts.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { InvalidScoreFailure } from "./converter-failures"; +import { AssertStrAsPositiveInt, AssertStrAsPositiveNonZeroInt } from "./string-asserts"; + +function astr(v: string) { + try { + return AssertStrAsPositiveInt(v, "err"); + } catch (e) { + return e; + } +} + +function astrp(v: string) { + try { + return AssertStrAsPositiveNonZeroInt(v, "err"); + } catch (e) { + return e; + } +} + +describe("AssertStrAsPositiveInt", () => { + it("rejects invalid inputs", () => { + expect(astr("---")).toEqual(new InvalidScoreFailure(`err (Not an integer -- ---.)`)); + expect(astr("1.4")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 1.4.)`)); + expect(astr("NaN")).toEqual(new InvalidScoreFailure(`err (Not an integer -- NaN.)`)); + expect(astr("-0.3")).toEqual(new InvalidScoreFailure(`err (Not an integer -- -0.3.)`)); + expect(astr("0xFF")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 0xFF.)`)); + expect(astr("0b11")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 0b11.)`)); + expect(astr("0o77")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 0o77.)`)); + expect(astr("--1")).toEqual(new InvalidScoreFailure(`err (Not an integer -- --1.)`)); + expect(astr("12f")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 12f.)`)); + expect(astr(`${Number.MAX_SAFE_INTEGER.toString()}000`)).toEqual( + new InvalidScoreFailure( + `err (Not an integer -- ${Number.MAX_SAFE_INTEGER.toString()}000.)`, + ), + ); + expect(astr("-1")).toEqual(new InvalidScoreFailure(`err (Was negative -- -1.)`)); + }); + + it("accepts valid values", () => { + // Number("-0") is -0; strict equality still treats it as zero. + expect(astr("-0") === 0).toBe(true); + expect(astr("0")).toBe(0); + expect(astr("13")).toBe(13); + }); +}); + +describe("AssertStrAsPositiveNonZeroInt", () => { + it("rejects invalid inputs", () => { + expect(astrp("---")).toEqual(new InvalidScoreFailure(`err (Not an integer -- ---.)`)); + expect(astrp("1.4")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 1.4.)`)); + expect(astrp("NaN")).toEqual(new InvalidScoreFailure(`err (Not an integer -- NaN.)`)); + expect(astrp("-0.3")).toEqual(new InvalidScoreFailure(`err (Not an integer -- -0.3.)`)); + expect(astrp("0xFF")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 0xFF.)`)); + expect(astrp("0b11")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 0b11.)`)); + expect(astrp("0o77")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 0o77.)`)); + expect(astrp("--1")).toEqual(new InvalidScoreFailure(`err (Not an integer -- --1.)`)); + expect(astrp("12f")).toEqual(new InvalidScoreFailure(`err (Not an integer -- 12f.)`)); + expect(astrp(`${Number.MAX_SAFE_INTEGER.toString()}000`)).toEqual( + new InvalidScoreFailure( + `err (Not an integer -- ${Number.MAX_SAFE_INTEGER.toString()}000.)`, + ), + ); + expect(astrp("-1")).toEqual(new InvalidScoreFailure(`err (Was negative or zero -- -1.)`)); + expect(astrp("-0")).toEqual(new InvalidScoreFailure(`err (Was negative or zero -- 0.)`)); + expect(astrp("0")).toEqual(new InvalidScoreFailure(`err (Was negative or zero -- 0.)`)); + }); + + it("accepts positive non-zero integers", () => { + expect(astrp("13")).toBe(13); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/common/string-asserts.ts b/typescript/server/src/lib/score-import/framework/common/string-asserts.ts index f78086ec2..364a27609 100644 --- a/typescript/server/src/lib/score-import/framework/common/string-asserts.ts +++ b/typescript/server/src/lib/score-import/framework/common/string-asserts.ts @@ -1,19 +1,9 @@ -import { - type Difficulties, - type GameGroup, - GetGamePTConfig, - type GPTString, - type Playtype, -} from "tachi-common"; +import { type Difficulties, FormatGame, GetGameConfig, type V3Game } from "tachi-common"; import { InvalidScoreFailure } from "./converter-failures"; -export function AssertStrAsDifficulty( - strVal: string, - game: GameGroup, - playtype: Playtype, -): Difficulties[GPTString] { - const diffConf = GetGamePTConfig(game, playtype).difficulties; +export function AssertStrAsDifficulty(strVal: string, game: V3Game): Difficulties[V3Game] { + const diffConf = GetGameConfig(game).difficulties; if (diffConf.type === "DYNAMIC") { // lol @@ -26,7 +16,7 @@ export function AssertStrAsDifficulty( } throw new InvalidScoreFailure( - `Invalid Difficulty for ${game} ${playtype} - Expected any of ${diffConf.order.join( + `Invalid Difficulty for ${FormatGame(game)} - Expected any of ${diffConf.order.join( ", ", )} (Got ${strVal})`, ); diff --git a/typescript/server/src/lib/score-import/framework/common/types.ts b/typescript/server/src/lib/score-import/framework/common/types.ts index 64206d025..ed99c36b5 100644 --- a/typescript/server/src/lib/score-import/framework/common/types.ts +++ b/typescript/server/src/lib/score-import/framework/common/types.ts @@ -1,33 +1,32 @@ import type { Mutable } from "#utils/types"; import type { ConfProvidedMetrics, - GPTString, integer, Judgements, - MONGO_ScoreDocument, MongoOptionalMetrics, - Playtype, + ScoreDocument, + V3Game, } from "tachi-common"; import type { MongoExtractMetrics } from "tachi-common/types/metrics"; /** * ScoreData, but it's just the provided metrics (and enumIndexes don't exist). */ -export type DryScoreData = { - judgements: Partial>; - optional: Mutable; -} & MongoExtractMetrics; +export type DryScoreData = { + judgements: Partial>; + optional: Mutable; +} & MongoExtractMetrics; /** * An intermediate score format that will be fully filled out by * HydrateScore. */ -export type DryScore = { - scoreData: DryScoreData; +export type DryScore = { + scoreData: DryScoreData; } & Pick< - MONGO_ScoreDocument, + ScoreDocument, "comment" | "game" | "importType" | "scoreMeta" | "service" | "timeAchieved" >; -export type ScorePlaytypeMap = Partial>>; -export type ChartIDPlaytypeMap = Partial>>; +export type ScoreGameMap = Partial>>; +export type ChartIDGameMap = Partial>>; diff --git a/typescript/server/src/lib/score-import/framework/express-wrapper.ts b/typescript/server/src/lib/score-import/framework/express-wrapper.ts index b5c840de5..fc72b3685 100644 --- a/typescript/server/src/lib/score-import/framework/express-wrapper.ts +++ b/typescript/server/src/lib/score-import/framework/express-wrapper.ts @@ -1,7 +1,7 @@ import type { + ImportDocument, ImportTypes, integer, - MONGO_ImportDocument, SuccessfulAPIResponse, UnsuccessfulAPIResponse, } from "tachi-common"; @@ -16,7 +16,7 @@ import ScoreImportFatalError from "./score-importing/score-import-error"; export interface WrappedAPIResponse { statusCode: number; - body: SuccessfulAPIResponse | UnsuccessfulAPIResponse; + body: SuccessfulAPIResponse | UnsuccessfulAPIResponse; } /** diff --git a/typescript/server/src/lib/score-import/framework/goals/goals.oldtest.ts b/typescript/server/src/lib/score-import/framework/goals/goals.oldtest.ts deleted file mode 100644 index 9f137ee89..000000000 --- a/typescript/server/src/lib/score-import/framework/goals/goals.oldtest.ts +++ /dev/null @@ -1,323 +0,0 @@ -import type { GoalDocument, GoalSubscriptionDocument } from "tachi-common"; - -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf } from "#test-utils/misc.js"; -import ResetDBState from "#test-utils/resets"; -import { - HC511Goal, - HC511UserGoal, - Testing511SPA, - TestingIIDXSPScorePB, -} from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import { ProcessGoal, UpdateGoalsForUser } from "./goals"; - -t.test("#UpdateGoalsForUser", (t) => { - t.beforeEach(ResetDBState); - - const baseGoalDocument: GoalDocument = { - charts: { - type: "single" as const, - data: Testing511SPA.chartID, - }, - game: "iidx", - goalID: "FAKE_GOAL_ID", - playtype: "SP", - name: "get > 1 ex score on some other folder.", - criteria: { - mode: "single", - value: 1, - key: "score", - }, - }; - - const baseGoalSubscriptionDocument: GoalSubscriptionDocument = { - achieved: false, - wasInstantlyAchieved: false, - game: "iidx", - playtype: "SP", - goalID: "FAKE_GOAL_ID", - lastInteraction: null, - outOf: 1, - outOfHuman: "1", - progress: 0, - progressHuman: "0", - timeAchieved: null, - wasAssignedStandalone: false, - userID: 1, - }; - - t.test("Should correctly update goals when user achieves goal.", async (t) => { - await MONGODB_KILL.goals.insert(baseGoalDocument); - - await MONGODB_KILL["goal-subs"].insert(baseGoalSubscriptionDocument); - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const ugMap = new Map([["FAKE_GOAL_ID", baseGoalSubscriptionDocument]]); - - const res = await UpdateGoalsForUser([baseGoalDocument], ugMap, 1, log); - - t.strictSame(res, [ - { - goalID: "FAKE_GOAL_ID", - old: { - progress: 0, - progressHuman: "0", - outOf: 1, - outOfHuman: "1", - achieved: false, - }, - new: { - progress: 1479, - progressHuman: "1479", - outOf: 1, - outOfHuman: "1", - achieved: true, - }, - }, - ]); - - const r = await MONGODB_KILL["goal-subs"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 }); - - t.hasStrict( - r, - { - progress: 1479, - progressHuman: "1479", - outOf: 1, - outOfHuman: "1", - achieved: true, - }, - "Should update goals in the database.", - ); - - t.end(); - }); - - t.test("Should correctly update goals when user does not achieve goal.", async (t) => { - const goal = dmf(baseGoalDocument, { criteria: { value: 2 } }); - - await MONGODB_KILL.goals.insert(goal); - - const goalSub = deepmerge(baseGoalSubscriptionDocument, { - outOf: 2, - outOfHuman: "2", - }) as unknown as GoalSubscriptionDocument; - - await MONGODB_KILL["goal-subs"].insert(goalSub); - - // we dont delete _id here because updategoalsforuser - // depends on usergoal _id - - await MONGODB_KILL["personal-bests"].insert( - dmf(TestingIIDXSPScorePB, { scoreData: { score: 1 } }), - ); - - const ugMap = new Map([["FAKE_GOAL_ID", goalSub]]); - - const res = await UpdateGoalsForUser([goal], ugMap, 1, log); - - t.strictSame(res, [ - { - goalID: "FAKE_GOAL_ID", - old: { - progress: 0, - progressHuman: "0", - outOf: 2, - outOfHuman: "2", - achieved: false, - }, - new: { - progress: 1, - progressHuman: "1", - outOf: 2, - outOfHuman: "2", - achieved: false, - }, - }, - ]); - - const r = await MONGODB_KILL["goal-subs"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 }); - - t.hasStrict( - r, - { - progress: 1, - progressHuman: "1", - outOf: 2, - outOfHuman: "2", - achieved: false, - }, - "Should update goals in the database.", - ); - - t.end(); - }); - - t.test("Should return [] if no data is to be changed.", async (t) => { - const res = await UpdateGoalsForUser([], new Map(), 1, log); - - t.strictSame(res, []); - - t.end(); - }); - - t.test("Should handle (skip) goals if no usergoal is set.", async (t) => { - const res = await UpdateGoalsForUser([baseGoalDocument], new Map(), 1, log); - - t.strictSame(res, []); - - t.end(); - }); - - t.test("Should handle (skip) invalid goals.", async (t) => { - const res = await UpdateGoalsForUser( - [deepmerge(baseGoalDocument, { charts: { type: "INVALID" } })], - new Map([["FAKE_GOAL_ID", baseGoalSubscriptionDocument]]), - 1, - log, - ); - - t.strictSame(res, []); - - t.end(); - }); - - t.end(); -}); - -t.todo("#GetAndUpdateUsersGoals", (t) => { - t.beforeEach(ResetDBState); - - t.end(); -}); - -t.test("#ProcessGoal", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL.goals.insert(HC511Goal); - }); - - t.test("Should process the users goal if a score has changed.", async (t) => { - await MONGODB_KILL["goal-subs"].insert(HC511UserGoal); - - // score is EX HARD CLEAR by default. - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await ProcessGoal(HC511Goal, HC511UserGoal, 1, log); - - t.not(res, undefined, "Should NOT return undefined."); - - t.strictSame( - res?.import, - { - goalID: "mock_goalID", - old: { - progress: null, - progressHuman: "NO DATA", - outOf: 5, - outOfHuman: "HARD CLEAR", - achieved: false, - }, - new: { - progress: 6, - progressHuman: "EX HARD CLEAR (BP: 2)", - outOf: 5, - outOfHuman: "HARD CLEAR", - achieved: true, - }, - }, - "Should return a valid import goal fragment.", - ); - - t.end(); - }); - - t.test("Should unset wasInstantlyAchieved if the goal became unachieved.", async (t) => { - const achievedGoalSub: GoalSubscriptionDocument = { - achieved: true, - game: "iidx", - goalID: "mock_goalID", - lastInteraction: null, - outOf: 5, - outOfHuman: "HARD CLEAR", - playtype: "SP", - progress: 6, - progressHuman: "EX HARD CLEAR", - timeAchieved: 1000, - wasInstantlyAchieved: true, - wasAssignedStandalone: false, - userID: 1, - }; - - await MONGODB_KILL["goal-subs"].insert(achievedGoalSub); - - const res = await ProcessGoal(HC511Goal, achievedGoalSub, 1, log); - - t.not(res, undefined, "Should NOT return undefined."); - - t.hasStrict( - res?.import, - { - goalID: "mock_goalID", - old: { - progress: 6, - outOf: 5, - achieved: true, - }, - new: { - progress: null, - outOf: 5, - achieved: false, - }, - }, - "Should unachieve the goal.", - ); - - t.equal( - res?.bwrite.updateOne.update.$set.wasInstantlyAchieved, - false, - "Goal is to be set as not instantly achieved.", - ); - - t.end(); - }); - - t.test("Should return undefined if there's no score.", async (t) => { - await MONGODB_KILL["goal-subs"].insert(HC511UserGoal); - - const res = await ProcessGoal(HC511Goal, HC511UserGoal, 1, log); - - t.equal(res, undefined, "Should return undefined."); - - t.end(); - }); - - t.test("Should return undefined if the progress has not changed.", async (t) => { - await MONGODB_KILL["goal-subs"].insert(HC511UserGoal); - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const firstUpdate = await ProcessGoal(HC511Goal, HC511UserGoal, 1, log); - - // ignore this one - t.not(firstUpdate, undefined, "Should NOT return undefined."); - - await MONGODB_KILL["goal-subs"].bulkWrite([firstUpdate!.bwrite]); - - const goalSub = await MONGODB_KILL["goal-subs"].findOne({ - userID: 1, - goalID: HC511Goal.goalID, - }); - - const secondUpdate = await ProcessGoal(HC511Goal, goalSub!, 1, log); - - t.equal(secondUpdate, undefined, "Should return undefined."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/goals/goals.test.ts b/typescript/server/src/lib/score-import/framework/goals/goals.test.ts new file mode 100644 index 000000000..bad466a25 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/goals/goals.test.ts @@ -0,0 +1,509 @@ +import type { GoalDocument, GoalSubscriptionDocument } from "tachi-common"; + +import { SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; +import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents"; +import { log } from "#lib/log/log"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { + HC511Goal, + HC511UserGoal, + Testing511Song, + Testing511SPA, + TestingIIDXSPScorePB, +} from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { describe, expect, it } from "vitest"; + +import { ProcessGoal, UpdateGoalsForUser } from "./goals"; + +async function seedIidx511Chart() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +async function insertPbFromIidxDoc(userId: number, doc: typeof TestingIIDXSPScorePB) { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { + ...doc.scoreData, + judgements: doc.scoreData.judgements, + }); + + await DB.insertInto("pb") + .values({ + user_id: userId, + chart_id: doc.chartID, + lens: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(doc.calculatedData), + ranking_value: doc.scoreData.score, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: doc.highlight, + time_achieved: + doc.timeAchieved !== null ? UnixMillisecondsToISO8601(doc.timeAchieved) : null, + }) + .execute(); +} + +describe("UpdateGoalsForUser", () => { + const baseGoalDocument: GoalDocument = { + charts: { + type: "single", + data: Testing511SPA.chartID, + }, + game: "iidx-sp", + goalID: "FAKE_GOAL_ID", + name: "get > 1 ex score on some other folder.", + criteria: { + mode: "single", + value: 1, + key: "score", + }, + }; + + const baseGoalSubscriptionDocument: GoalSubscriptionDocument = { + achieved: false, + wasInstantlyAchieved: false, + game: "iidx-sp", + goalID: "FAKE_GOAL_ID", + lastInteraction: null, + outOf: 1, + outOfHuman: "1", + progress: 0, + progressHuman: "0", + timeAchieved: null, + wasAssignedStandalone: false, + userID: 1, + }; + + it("updates goals when the user achieves the criteria", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + const sub = dmf(baseGoalSubscriptionDocument, { userID: userId }); + + await DB.insertInto("goal") + .values({ + id: baseGoalDocument.goalID, + game: baseGoalDocument.game, + name: baseGoalDocument.name, + charts: baseGoalDocument.charts, + criteria: baseGoalDocument.criteria, + }) + .execute(); + + await DB.insertInto("goal_sub") + .values({ + goal_id: baseGoalDocument.goalID, + user_id: userId, + last_interaction: null, + progress: 0, + progress_human: "0", + out_of: 1, + out_of_human: "1", + achieved: false, + time_achieved: null, + was_instantly_achieved: false, + was_assigned_standalone: false, + }) + .execute(); + + await insertPbFromIidxDoc(userId, TestingIIDXSPScorePB); + + const ugMap = new Map([[baseGoalDocument.goalID, sub]]); + + const res = await UpdateGoalsForUser([baseGoalDocument], ugMap, userId, log); + + expect(res).toEqual([ + { + goalID: "FAKE_GOAL_ID", + old: { + progress: 0, + progressHuman: "0", + outOf: 1, + outOfHuman: "1", + achieved: false, + }, + new: { + progress: 1479, + progressHuman: "1479", + outOf: 1, + outOfHuman: "1", + achieved: true, + }, + }, + ]); + + const r = await DB.selectFrom("goal_sub") + .selectAll() + .where("goal_id", "=", "FAKE_GOAL_ID") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(r).toMatchObject({ + progress: 1479, + progress_human: "1479", + out_of: 1, + out_of_human: "1", + achieved: true, + }); + }); + + it("updates goals when the user has not achieved the criteria", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + + const goal = dmf(baseGoalDocument, { criteria: { value: 2 } }); + const goalSub = dmf(baseGoalSubscriptionDocument, { + userID: userId, + outOf: 2, + outOfHuman: "2", + }); + + await DB.insertInto("goal") + .values({ + id: goal.goalID, + game: goal.game, + name: goal.name, + charts: goal.charts, + criteria: goal.criteria, + }) + .execute(); + + await DB.insertInto("goal_sub") + .values({ + goal_id: goal.goalID, + user_id: userId, + last_interaction: null, + progress: 0, + progress_human: "0", + out_of: 2, + out_of_human: "2", + achieved: false, + time_achieved: null, + was_instantly_achieved: false, + was_assigned_standalone: false, + }) + .execute(); + + await insertPbFromIidxDoc( + userId, + dmf(TestingIIDXSPScorePB, { + scoreData: { ...TestingIIDXSPScorePB.scoreData, score: 1 }, + }), + ); + + const ugMap = new Map([[goal.goalID, goalSub]]); + + const res = await UpdateGoalsForUser([goal], ugMap, userId, log); + + expect(res).toEqual([ + { + goalID: "FAKE_GOAL_ID", + old: { + progress: 0, + progressHuman: "0", + outOf: 2, + outOfHuman: "2", + achieved: false, + }, + new: { + progress: 1, + progressHuman: "1", + outOf: 2, + outOfHuman: "2", + achieved: false, + }, + }, + ]); + + const r = await DB.selectFrom("goal_sub") + .selectAll() + .where("goal_id", "=", "FAKE_GOAL_ID") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(r).toMatchObject({ + progress: 1, + progress_human: "1", + out_of: 2, + out_of_human: "2", + achieved: false, + }); + }); + + it("returns [] when there is nothing to update", async () => { + const { id: userId } = await seedUser(); + const res = await UpdateGoalsForUser([], new Map(), userId, log); + expect(res).toEqual([]); + }); + + it("skips goals when the user has no matching subscription", async () => { + const { id: userId } = await seedUser(); + const res = await UpdateGoalsForUser([baseGoalDocument], new Map(), userId, log); + expect(res).toEqual([]); + }); + + it("skips invalid goal chart types", async () => { + const { id: userId } = await seedUser(); + const badGoal = { + ...baseGoalDocument, + charts: { type: "INVALID", data: Testing511SPA.chartID }, + } as unknown as GoalDocument; + + const sub = dmf(baseGoalSubscriptionDocument, { userID: userId }); + + const res = await UpdateGoalsForUser( + [badGoal], + new Map([[baseGoalDocument.goalID, sub]]), + userId, + log, + ); + + expect(res).toEqual([]); + }); +}); + +describe("ProcessGoal", () => { + it("returns import info when the score changes goal progress", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + + await DB.insertInto("goal") + .values({ + id: HC511Goal.goalID, + game: HC511Goal.game, + name: HC511Goal.name, + charts: HC511Goal.charts, + criteria: HC511Goal.criteria, + }) + .execute(); + + await DB.insertInto("goal_sub") + .values({ + goal_id: HC511Goal.goalID, + user_id: userId, + last_interaction: null, + progress: null, + progress_human: "NO DATA", + out_of: 5, + out_of_human: "HARD CLEAR", + achieved: false, + time_achieved: null, + was_instantly_achieved: false, + was_assigned_standalone: false, + }) + .execute(); + + await insertPbFromIidxDoc(userId, TestingIIDXSPScorePB); + + const sub = dmf(HC511UserGoal, { userID: userId }); + const res = await ProcessGoal(HC511Goal, sub, userId, log); + + expect(res).toBeDefined(); + expect(res?.import).toStrictEqual({ + goalID: "mock_goalID", + old: { + progress: null, + progressHuman: "NO DATA", + outOf: 5, + outOfHuman: "HARD CLEAR", + achieved: false, + }, + new: { + progress: 6, + progressHuman: "EX HARD CLEAR (BP: 2)", + outOf: 5, + outOfHuman: "HARD CLEAR", + achieved: true, + }, + }); + }); + + it("clears wasInstantlyAchieved when the goal becomes unachieved", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + + await DB.insertInto("goal") + .values({ + id: HC511Goal.goalID, + game: HC511Goal.game, + name: HC511Goal.name, + charts: HC511Goal.charts, + criteria: HC511Goal.criteria, + }) + .execute(); + + await DB.insertInto("goal_sub") + .values({ + goal_id: HC511Goal.goalID, + user_id: userId, + last_interaction: null, + progress: 6, + progress_human: "EX HARD CLEAR", + out_of: 5, + out_of_human: "HARD CLEAR", + achieved: true, + time_achieved: UnixMillisecondsToISO8601(1000), + was_instantly_achieved: true, + was_assigned_standalone: false, + }) + .execute(); + + const achievedGoalSub: GoalSubscriptionDocument = { + achieved: true, + game: "iidx-sp", + goalID: "mock_goalID", + lastInteraction: null, + outOf: 5, + outOfHuman: "HARD CLEAR", + progress: 6, + progressHuman: "EX HARD CLEAR", + timeAchieved: 1000, + wasInstantlyAchieved: true, + wasAssignedStandalone: false, + userID: userId, + }; + + const res = await ProcessGoal(HC511Goal, achievedGoalSub, userId, log); + + expect(res).toBeDefined(); + expect(res?.import).toMatchObject({ + goalID: "mock_goalID", + old: { + progress: 6, + outOf: 5, + achieved: true, + }, + new: { + progress: null, + outOf: 5, + achieved: false, + }, + }); + expect(res?.pgUpdate?.set.was_instantly_achieved).toBe(false); + }); + + it("returns undefined when there is no personal best", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + + await DB.insertInto("goal") + .values({ + id: HC511Goal.goalID, + game: HC511Goal.game, + name: HC511Goal.name, + charts: HC511Goal.charts, + criteria: HC511Goal.criteria, + }) + .execute(); + + await DB.insertInto("goal_sub") + .values({ + goal_id: HC511Goal.goalID, + user_id: userId, + last_interaction: null, + progress: null, + progress_human: "NO DATA", + out_of: 5, + out_of_human: "HARD CLEAR", + achieved: false, + time_achieved: null, + was_instantly_achieved: false, + was_assigned_standalone: false, + }) + .execute(); + + const sub = dmf(HC511UserGoal, { userID: userId }); + const res = await ProcessGoal(HC511Goal, sub, userId, log); + + expect(res).toBeUndefined(); + }); + + it("returns undefined when progress is already up to date", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + + await DB.insertInto("goal") + .values({ + id: HC511Goal.goalID, + game: HC511Goal.game, + name: HC511Goal.name, + charts: HC511Goal.charts, + criteria: HC511Goal.criteria, + }) + .execute(); + + await DB.insertInto("goal_sub") + .values({ + goal_id: HC511Goal.goalID, + user_id: userId, + last_interaction: null, + progress: null, + progress_human: "NO DATA", + out_of: 5, + out_of_human: "HARD CLEAR", + achieved: false, + time_achieved: null, + was_instantly_achieved: false, + was_assigned_standalone: false, + }) + .execute(); + + await insertPbFromIidxDoc(userId, TestingIIDXSPScorePB); + + const sub = dmf(HC511UserGoal, { userID: userId }); + const firstUpdate = await ProcessGoal(HC511Goal, sub, userId, log); + expect(firstUpdate).toBeDefined(); + + await DB.updateTable("goal_sub") + .set(firstUpdate!.pgUpdate.set) + .where("goal_id", "=", firstUpdate!.pgUpdate.goalId) + .where("user_id", "=", firstUpdate!.pgUpdate.userId) + .execute(); + + const row = await DB.selectFrom("goal_sub") + .innerJoin("goal", "goal.id", "goal_sub.goal_id") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) + .where("goal_sub.goal_id", "=", HC511Goal.goalID) + .where("goal_sub.user_id", "=", userId) + .executeTakeFirstOrThrow(); + + const goalSub = ToGoalSubscriptionDocument(row); + const secondUpdate = await ProcessGoal(HC511Goal, goalSub, userId, log); + + expect(secondUpdate).toBeUndefined(); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/goals/goals.ts b/typescript/server/src/lib/score-import/framework/goals/goals.ts index db8fd9dfa..beebc036b 100644 --- a/typescript/server/src/lib/score-import/framework/goals/goals.ts +++ b/typescript/server/src/lib/score-import/framework/goals/goals.ts @@ -1,10 +1,4 @@ import type { KtLogger } from "#lib/log/log"; -import type { - GameGroup, - integer, - MONGO_GoalDocument, - MONGO_GoalSubscriptionDocument, -} from "tachi-common"; import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents"; @@ -13,12 +7,18 @@ import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; import DB from "#services/pg/db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { sql } from "kysely"; +import { + type GoalDocument, + type GoalSubscriptionDocument, + type integer, + type V3Game, +} from "tachi-common"; /** * Update a user's progress on all of their set goals. */ export async function GetAndUpdateUsersGoals( - game: GameGroup, + game: V3Game, userID: integer, chartIDs: Set, log: KtLogger, @@ -35,14 +35,14 @@ export async function GetAndUpdateUsersGoals( } export async function UpdateGoalsForUser( - goals: Array, - goalSubsMap: Map, + goals: Array, + goalSubsMap: Map, userID: integer, log: KtLogger, skipMismatch = false, ) { const returns = await Promise.all( - goals.map((goal: MONGO_GoalDocument) => { + goals.map((goal: GoalDocument) => { const goalSub = goalSubsMap.get(goal.goalID); if (!goalSub) { @@ -98,7 +98,11 @@ export async function UpdateGoalsForUser( if (webhookEventContent.length !== 0 && goals[0]) { await EmitWebhookEvent({ type: "goals-achieved/v1", - content: { goals: webhookEventContent, userID, game: goals[0].game }, + content: { + goals: webhookEventContent, + userID, + game: goals[0].game, + }, }); } @@ -112,8 +116,8 @@ export async function UpdateGoalsForUser( * to say (i.e. user didnt raise the goal). */ export async function ProcessGoal( - goal: MONGO_GoalDocument, - goalSub: MONGO_GoalSubscriptionDocument, + goal: GoalDocument, + goalSub: GoalSubscriptionDocument, userID: integer, log: KtLogger, ) { @@ -150,7 +154,7 @@ export async function ProcessGoal( goalID: goal.goalID, old: oldData, new: newData, - playtype: goal.playtype, + game: goal.game, }; } @@ -238,7 +242,7 @@ export async function UpdateGoalsInFolder(folderID: string, log: KtLogger) { const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); - const ugsMap = new Map>(); + const ugsMap = new Map>(); for (const gSub of goalSubs) { const uid = gSub.userID; diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.oldtest.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.oldtest.ts deleted file mode 100644 index 775d77a21..000000000 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.oldtest.ts +++ /dev/null @@ -1,262 +0,0 @@ -import type { BatchManualScore } from "tachi-common"; - -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import deepmerge from "deepmerge"; -import fjsh from "fast-json-stable-hash"; -import t from "tap"; - -import type { BatchManualContext } from "../../import-types/common/batch-manual/types"; -import type { OrphanScoreDocument } from "../../import-types/common/types"; - -import { OrphanScore, ReprocessOrphan } from "./orphans"; - -const batchManualScore: BatchManualScore = { - score: 500, - lamp: "HARD CLEAR", - matchType: "songTitle", - identifier: "5.1.1.", - difficulty: "ANOTHER", -}; - -const batchManualContext: BatchManualContext = { - game: "iidx", - playtype: "SP", - service: "foo", - version: "27", -}; - -t.test("#OrphanScore", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should orphan data and context information.", async (t) => { - const res = await OrphanScore( - "ir/direct-manual", - 1, - batchManualScore, - batchManualContext, - "Example Error Message", - "iidx", - log, - ); - - t.equal(res.success, true); - t.equal( - res.orphanID, - `O${fjsh.hash( - { - importType: "ir/direct-manual", - data: batchManualScore, - context: batchManualContext, - userID: 1, - }, - "sha256", - )}`, - "Should be a checksum of the orphan's contents.", - ); - - const dbCheck = await MONGODB_KILL["orphan-scores"].findOne({ - orphanID: res.orphanID, - }); - - t.hasStrict(dbCheck, { - orphanID: res.orphanID, - userID: 1, - data: batchManualScore, - context: batchManualContext, - importType: "ir/direct-manual", - errMsg: "Example Error Message", - }); - - t.ok( - Math.abs(dbCheck!.timeInserted - Date.now()) < 10_000, - "timeInserted should be decently close to now.", - ); - - t.end(); - }); - - t.test("Should skip orphans that already exist.", async (t) => { - const res1 = await OrphanScore( - "ir/direct-manual", - 1, - batchManualScore, - batchManualContext, - "Example Error Message", - "iidx", - log, - ); - - const res2 = await OrphanScore( - "ir/direct-manual", - 1, - batchManualScore, - batchManualContext, - "Example Error Message", - "iidx", - log, - ); - - t.equal(res2.success, false); - t.equal(res1.orphanID, res2.orphanID); - - const dbCheck = await MONGODB_KILL["orphan-scores"].count(); - - t.equal(dbCheck, 1); - }); - - t.end(); -}); - -t.test("#ReprocessOrphan", (t) => { - t.beforeEach(ResetDBState); - - const orphanDoc: OrphanScoreDocument = { - context: batchManualContext, - data: batchManualScore, - errMsg: "foo", - importType: "ir/direct-manual", - orphanID: "foo", - timeInserted: 0, - game: "iidx", - userID: 1, - }; - - t.test("Should convert and insert an orphan if parents are found", async (t) => { - // this orphan doc is for 5.1.1. SPA, which definitely exists in - // the test DB. - const res = await ReprocessOrphan(orphanDoc, [], log); - - t.hasStrict( - res, - { - success: true, - type: "ScoreImported", - message: - "Imported score Tecc9141bfbc12fcc1ead785c13c4cf15a5c8a04d8d431e208ffe8c9d0df2080d.", - content: { - score: { - game: "iidx", - service: "foo (DIRECT-MANUAL)", - comment: null, - importType: "ir/direct-manual", - timeAchieved: null, - scoreData: { - lamp: "HARD CLEAR", - score: 500, - enumIndexes: { - lamp: 5, - }, - }, - highlight: false, - userID: 1, - songID: 1, - chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", - scoreID: - "Tecc9141bfbc12fcc1ead785c13c4cf15a5c8a04d8d431e208ffe8c9d0df2080d", - playtype: "SP", - isPrimary: true, - }, - }, - }, - "Should successfully import the score.", - ); - - const orphan = await MONGODB_KILL["orphan-scores"].findOne({ - orphanID: orphanDoc.orphanID, - }); - - t.equal(orphan, null, "Should remove the orphan document."); - - t.end(); - }); - - t.test("Should delete the orphan doc and return null if the score is invalid.", async (t) => { - await MONGODB_KILL["orphan-scores"].insert(orphanDoc); - const res = await ReprocessOrphan( - deepmerge(orphanDoc, { - data: { - score: 99999, - }, - }), - [], - log, - ); - - t.equal(res, null); - - const orphan = await MONGODB_KILL["orphan-scores"].findOne({ - orphanID: orphanDoc.orphanID, - }); - - t.equal(orphan, null, "Should remove the orphan document."); - - t.end(); - }); - - t.test( - "Should keep the orphan doc and return false if no parents could be found.", - async (t) => { - await MONGODB_KILL["orphan-scores"].insert(orphanDoc); - const res = await ReprocessOrphan( - deepmerge(orphanDoc, { - data: { - identifier: "NONSENSE CHART TITLE", - }, - }), - [], - log, - ); - - t.equal(res, false); - - const orphan = await MONGODB_KILL["orphan-scores"].findOne({ - orphanID: orphanDoc.orphanID, - }); - - t.not(orphan, null, "Should not remove the orphan document."); - - t.end(); - }, - ); - - t.test( - "Should keep the orphan doc and return false if an internal failure was triggered.", - async (t) => { - await MONGODB_KILL["orphan-scores"].insert(orphanDoc); - const GAZER_SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d"; - - // force an internal failure by removing all bms songs and then - // leaving a valid bms chart to match with. - await MONGODB_KILL.songs.bms.remove({}); - - const res = await ReprocessOrphan( - deepmerge(orphanDoc, { - context: { - game: "bms", - playtype: "7K", - }, - data: { - matchType: "bmsChartHash", - identifier: GAZER_SHA256, - }, - }), - [], - log, - ); - - t.equal(res, false); - - const orphan = await MONGODB_KILL["orphan-scores"].findOne({ - orphanID: orphanDoc.orphanID, - }); - - t.not(orphan, null, "Should not remove the orphan document."); - - t.end(); - }, - ); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.test.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.test.ts index 6dbd6a67a..765d55969 100644 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.test.ts +++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.test.ts @@ -1,3 +1,5 @@ +import type { BatchManualContext } from "#lib/score-import/import-types/common/batch-manual/types"; +import type { OrphanScoreDocument } from "#lib/score-import/import-types/common/types"; import type { BatchManualScore } from "tachi-common"; import { log } from "#lib/log/log"; @@ -8,9 +10,6 @@ import fjsh from "fast-json-stable-hash"; import { sql } from "kysely"; import { describe, expect, it } from "vitest"; -import type { BatchManualContext } from "../../import-types/common/batch-manual/types"; -import type { OrphanScoreDocument } from "../../import-types/common/types"; - import { OrphanScore, ReprocessOrphan } from "./orphans"; const batchManualScore: BatchManualScore = { @@ -22,8 +21,7 @@ const batchManualScore: BatchManualScore = { }; const batchManualContext: BatchManualContext = { - game: "iidx", - playtype: "SP", + game: "iidx-sp", service: "foo", version: "27", }; diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts index 9d4d87efd..b98203a76 100644 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts +++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts @@ -1,23 +1,22 @@ import type { KtLogger } from "#lib/log/log"; -import type { GameGroup, ImportTypes, integer } from "tachi-common"; -import type { OrphanScore as PgOrphanScoreRow } from "tachi-db"; - -import { SELECT_ORPHAN_SCORE } from "#lib/db-formats/orphan-score"; -import DB from "#services/pg/db"; -import { GetBlacklist } from "#utils/queries/blacklist"; -import { GetUserWithID } from "#utils/user"; -import fjsh from "fast-json-stable-hash"; -import { sql } from "kysely"; - import type { ConverterFnReturnOrFailure, ConverterFunction, ImportTypeContextMap, ImportTypeDataMap, OrphanScoreDocument, -} from "../../import-types/common/types"; +} from "#lib/score-import/import-types/common/types"; +import type { GameGroup, ImportTypes, integer } from "tachi-common"; +import type { OrphanScore as PgOrphanScoreRow } from "tachi-db"; + +import { SELECT_ORPHAN_SCORE } from "#lib/db-formats/orphan-score"; +import { Converters } from "#lib/score-import/import-types/converters"; +import DB from "#services/pg/db"; +import { GetBlacklist } from "#utils/queries/blacklist"; +import { GetUserWithID } from "#utils/user"; +import fjsh from "fast-json-stable-hash"; +import { sql } from "kysely"; -import { Converters } from "../../import-types/converters"; import { type ConverterFailure, IsConverterFailure } from "../common/converter-failures"; import { HandlePostImportSteps } from "../score-importing/score-import-main"; import { ProcessSuccessfulConverterReturn } from "../score-importing/score-importing"; diff --git a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.oldtest.ts b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.oldtest.ts deleted file mode 100644 index a54f4e324..000000000 --- a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.oldtest.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { type KtLogger, log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { - BMSGazerChart, - Testing511SPA, - TestingBMS7KScore, - TestingIIDXSPScore, -} from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import { IIDX_GRADES, IIDX_LAMPS } from "tachi-common"; -import t from "tap"; - -import { CreatePBDoc, type PBScoreDocumentNoRank } from "./create-pb-doc"; - -const IIDXScore = TestingIIDXSPScore; - -t.test("#CreatePBDoc", (t) => { - t.beforeEach(ResetDBState); - - const chartID = Testing511SPA.chartID; - - const ExamplePBDoc = { - chartID, - userID: 1, - songID: 1, - - // rankingData -- is not present because it is not added until post-processing. - highlight: false, - isPrimary: true, - timeAchieved: 1619454485988, - game: "iidx", - playtype: "SP", - composedFrom: [ - { name: "Best Score", scoreID: IIDXScore.scoreID }, - { name: "Best Lamp", scoreID: "LAMP_PB_ID" }, - ], - scoreData: { - score: IIDXScore.scoreData.score, - percent: IIDXScore.scoreData.percent, - grade: IIDXScore.scoreData.grade, - judgements: IIDXScore.scoreData.judgements, - lamp: "FULL COMBO", - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - grade: IIDX_GRADES.C, - }, - optional: { bp: 1, enumIndexes: {} }, - }, - calculatedData: { - BPI: null, - ktLampRating: 10, - }, - }; - - t.test( - "(IIDX) Should use the Server Impl Merge FNs to also join the BP PB if necessary.", - async (t) => { - await MONGODB_KILL.scores.remove({}); - await MONGODB_KILL.scores.insert([ - IIDXScore, - deepmerge(IIDXScore, { - scoreData: { - lamp: "FULL COMBO", - score: 0, - percent: 0, - optional: { - bp: 15, - }, - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - }, - }, - scoreID: "LAMP_PB_ID", - }), - deepmerge(IIDXScore, { - scoreData: { - lamp: "CLEAR", - score: 1, - percent: 1, - optional: { - bp: 5, - }, - enumIndexes: { - lamp: IIDX_LAMPS.CLEAR, - }, - }, - scoreID: "BP_PB_ID", - }), - ]); - - const res = await CreatePBDoc("iidx:SP", 1, Testing511SPA, log); - - t.not(res, undefined, "Should actually return something."); - - t.strictSame( - res, - deepmerge(ExamplePBDoc, { - composedFrom: [{ name: "Lowest BP", scoreID: "BP_PB_ID" }], - - scoreData: { - optional: { - bp: 5, - }, - }, - }), - "Should correctly return a merged PBDocument with BP", - ); - - t.end(); - }, - ); - - t.test("Should merge a score and lamp PB into one document.", async (t) => { - const d = deepmerge(IIDXScore, { - scoreData: { - lamp: "FULL COMBO", - score: 0, - percent: 0, - optional: { - bp: 1, - }, - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - }, - }, - calculatedData: { - ktLampRating: 12, - }, - scoreID: "LAMP_PB_ID", - }); - - await MONGODB_KILL.scores.remove({}); - await MONGODB_KILL.scores.insert([IIDXScore, d]); - - const res = await CreatePBDoc("iidx:SP", 1, Testing511SPA, log); - - t.not(res, undefined, "Should actually return something."); - - t.strictSame(res, ExamplePBDoc, "Should correctly return a merged PBDocument"); - - t.end(); - }); - - t.test("Should bail safely if no score exists when one should", async (t) => { - // a work of genius <- zkldi, june 2021 - // a work of stupidity <- zkldi, after this broke, in december 2022. - const fakeLogger = { - warn: () => (warnCalled = true), - } as unknown as KtLogger; - - let warnCalled = false; - - await MONGODB_KILL.scores.remove({}); - - const res = await CreatePBDoc("iidx:SP", 1, Testing511SPA, fakeLogger); - - t.equal(res, undefined, "Should return nothing (and emit a warning)"); - - t.equal(warnCalled, true, "Warn logging should have been called."); - - t.end(); - }); - - t.test("(BMS) Should inherit BP from the best BP score.", async (t) => { - await MONGODB_KILL.scores.remove({}); - await MONGODB_KILL.scores.insert([ - TestingBMS7KScore, - deepmerge(TestingBMS7KScore, { - scoreData: { - lamp: "FULL COMBO", - score: 0, - percent: 0, - optional: { - bp: 15, - }, - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - }, - }, - calculatedData: { - sieglinde: 500, - }, - scoreID: "LAMP_PB_ID", - }), - deepmerge(TestingBMS7KScore, { - scoreData: { - optional: { - bp: 1, - }, - }, - scoreID: "BP_PB_ID", - }), - ]); - - const res = (await CreatePBDoc("bms:7K", 1, BMSGazerChart, log)) as - | PBScoreDocumentNoRank<"bms:7K" | "bms:14K"> - | undefined; - - t.not(res, undefined, "Should actually return something."); - - t.equal( - res?.scoreData.optional.bp, - 1, - "Should select the best BP's BP and not the score PBs.", - ); - - t.strictSame(res?.composedFrom, [ - { name: "Best Score", scoreID: TestingBMS7KScore.scoreID }, - { name: "Best Lamp", scoreID: "LAMP_PB_ID" }, - { name: "Lowest BP", scoreID: "BP_PB_ID" }, - ]); - - t.end(); - }); - - t.test("(BMS) Should inherit graph data from the best lamp.", async (t) => { - await MONGODB_KILL.scores.remove({}); - await MONGODB_KILL.scores.insert([ - TestingBMS7KScore, - deepmerge(TestingBMS7KScore, { - scoreData: { - lamp: "FULL COMBO", - score: 0, - percent: 0, - optional: { - bp: 15, - gauge: 12, - gaugeHistory: [20, 20, 21, 12], - gaugeHistoryEasy: [20, 20, 21, 13], - gaugeHistoryGroove: [20, 20, 21, 14], - gaugeHistoryHard: [20, 20, 21, 15], - }, - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - }, - }, - calculatedData: { - sieglinde: 500, - }, - scoreID: "LAMP_PB_ID", - }), - ]); - - const res = (await CreatePBDoc("bms:7K", 1, BMSGazerChart, log)) as - | PBScoreDocumentNoRank<"bms:7K" | "bms:14K"> - | undefined; - - t.not(res, undefined, "Should actually return something."); - - t.hasStrict( - res?.scoreData.optional, - { - gauge: 12, - gaugeHistory: [20, 20, 21, 12], - gaugeHistoryEasy: [20, 20, 21, 13], - gaugeHistoryGroove: [20, 20, 21, 14], - gaugeHistoryHard: [20, 20, 21, 15], - }, - "Should select the lampPBs gauge data and not the score PBs.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts new file mode 100644 index 000000000..6e3aa078c --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts @@ -0,0 +1,51 @@ +import type { KtLogger } from "#lib/log/log"; + +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import { describe, expect, it, vi } from "vitest"; + +import { CreatePBDoc } from "./create-pb-doc"; + +describe("CreatePBDoc", () => { + it("returns undefined and logs when the user has no scores on the chart", async () => { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); + + const { id: userId } = await seedUser(); + + const warn = vi.fn(); + const fakeLogger = { warn } as unknown as KtLogger; + + const res = await CreatePBDoc("iidx-sp", userId, Testing511SPA, fakeLogger); + + expect(res).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts index dc96deef2..cf79e6814 100644 --- a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -1,46 +1,39 @@ import type { KtLogger } from "#lib/log/log"; -import type { Game } from "tachi-db"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; -import { SELECT_PB_ROW } from "#lib/db-formats/pb"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { type ScoreDocumentJoinRow, SELECT_SCORE_DOCUMENT, ToScoreDocument, } from "#lib/db-formats/score"; -import { GetEveryonesRivalIDs } from "#lib/rivals/rivals"; import DB from "#services/pg/db"; import { DeleteUndefinedProps } from "#utils/misc"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { sql } from "kysely"; import { - type GameGroup, - GamePTToV3, - GetGPTConfig, - type GPTString, + type ChartDocument, + GetGameConfig, type integer, - type MONGO_ChartDocument, - type MONGO_ScoreDocument, - MongoChartLegacyId, - type Playtype, + type ScoreDocument, + type V3Game, } from "tachi-common"; -import type { MONGO_PBScoreDocumentNoRank } from "./upsert-pb-pg"; +import type { PBScoreDocumentNoRank } from "./upsert-pb-pg"; import { CreateScoreCalcData } from "../calculated-data/score"; import { scoreVisibleSql } from "../pg/score-visibility"; import { CreateEnumIndexes } from "../score-importing/derivers"; -export type { MONGO_PBScoreDocumentNoRank }; +export type { PBScoreDocumentNoRank }; async function findBestScoreForPb( - gpt: GPTString, + game: V3Game, userID: integer, - chart: MONGO_ChartDocument, + chart: ChartDocument, asOfTimestamp: number | undefined, -): Promise { - const gptConfig = GetGPTConfig(gpt); - const metricKey = String(gptConfig.defaultMetric); +): Promise { + const gameConfig = GetGameConfig(game); + const metricKey = String(gameConfig.defaultMetric); const sortVal = sql`(score.data::jsonb->>${sql.lit(metricKey)})::double precision`; @@ -75,15 +68,13 @@ async function findBestScoreForPb( * timestamp to constrain the generated PB to only one before the provided time. */ export async function CreatePBDoc( - gpt: GPTString, + game: V3Game, userID: integer, - chart: MONGO_ChartDocument, + chart: ChartDocument, log: KtLogger, asOfTimestamp?: number, ) { - const chartID = MongoChartLegacyId(chart); - - const defaultMetricPB = await findBestScoreForPb(gpt, userID, chart, asOfTimestamp); + const defaultMetricPB = await findBestScoreForPb(game, userID, chart, asOfTimestamp); if (!defaultMetricPB) { if (asOfTimestamp !== undefined) { @@ -92,7 +83,7 @@ export async function CreatePBDoc( log.warn( { - chartID, + chartID: chart.chartID, userID, }, `User ${userID} has no scores on chart, but a PB was attempted to be created?`, @@ -100,9 +91,9 @@ export async function CreatePBDoc( return; } - const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gpt]; + const gptImpl = GAME_IMPLEMENTATIONS[game]; - const pbDoc: MONGO_PBScoreDocumentNoRank = { + const pbDoc: PBScoreDocumentNoRank = { composedFrom: [ { name: gptImpl.defaultMergeRefName, @@ -115,13 +106,13 @@ export async function CreatePBDoc( highlight: defaultMetricPB.highlight, timeAchieved: defaultMetricPB.timeAchieved, game: defaultMetricPB.game, - playtype: defaultMetricPB.playtype, isPrimary: defaultMetricPB.isPrimary, scoreData: defaultMetricPB.scoreData, calculatedData: defaultMetricPB.calculatedData, }; for (const mergeFn of gptImpl.pbMergeFunctions) { + // eslint-disable-next-line no-await-in-loop const ref = await mergeFn( userID, defaultMetricPB.chartID, @@ -136,99 +127,18 @@ export async function CreatePBDoc( DeleteUndefinedProps(pbDoc.scoreData.optional); - const { indexes, optionalIndexes } = CreateEnumIndexes(gpt, pbDoc.scoreData, log); + const { indexes, optionalIndexes } = CreateEnumIndexes(game, pbDoc.scoreData, log); pbDoc.scoreData.enumIndexes = indexes; pbDoc.scoreData.optional.enumIndexes = optionalIndexes; - pbDoc.calculatedData = CreateScoreCalcData(pbDoc.game, pbDoc.scoreData, chart); + pbDoc.calculatedData = CreateScoreCalcData(pbDoc.scoreData, chart); return pbDoc; } -/** - * Persists rival-only ranking hints. Global rank / outOf come from `chart_leaderboard` at read time. - */ -export async function UpdateChartRanking(game: GameGroup, playtype: Playtype, chartID: string) { - const userIds = await getSortedPbUserIdsOnChart(game, playtype, chartID); - - const allRivals = await GetEveryonesRivalIDs(game, playtype); - - const seenUserIDs: Array = []; - - const v3Game = GamePTToV3(game, playtype) as Game; - - const chartRow = await DB.selectFrom("chart") - .select("id") - .where("chart.id", "=", chartID) - .where("chart.game", "=", v3Game) - .executeTakeFirst(); - - if (!chartRow) { - return; - } - - for (const userId of userIds) { - seenUserIDs.push(userId); - - const thisUsersRivals = allRivals[userId]; - - let rivalRank: integer | null = null; - - if (thisUsersRivals && thisUsersRivals.length > 0) { - rivalRank = thisUsersRivals.filter((e) => seenUserIDs.includes(e)).length + 1; - } - - const pbRow = await DB.selectFrom("pb") - .select(SELECT_PB_ROW) - .where("pb.user_id", "=", userId) - .where("pb.chart_id", "=", chartRow.id) - .where("pb.lens", "is", null) - .executeTakeFirst(); - - if (!pbRow) { - continue; - } - - const raw = pbRow.calculated_data; - const cd = - typeof raw === "string" - ? (JSON.parse(raw) as Record) - : ((raw ?? {}) as Record); - delete cd.rank; - delete cd.outOf; - - await DB.updateTable("pb") - .set({ - calculated_data: JSON.stringify({ - ...cd, - rivalRank, - }), - }) - .where("row_id", "=", pbRow.row_id) - .execute(); - } -} - -async function getSortedPbUserIdsOnChart(game: GameGroup, playtype: Playtype, chartID: string) { - const v3Game = GamePTToV3(game, playtype) as Game; - - const rows = await DB.selectFrom("pb") - .innerJoin("chart", "chart.id", "pb.chart_id") - .innerJoin("song", "song.id", "chart.song_id") - .select("pb.user_id") - .where((eb) => eb.or([eb("chart.id", "=", chartID), eb("chart.legacy_id", "=", chartID)])) - .where("chart.game", "=", v3Game) - .orderBy("pb.ranking_value", "desc") - .orderBy(sql`pb.ranking_value_tb1 DESC NULLS LAST`) - .orderBy(sql`pb.ranking_value_tb2 DESC NULLS LAST`) - .orderBy(sql`pb.ranking_value_tb3 DESC NULLS LAST`) - .orderBy(sql`pb.ranking_value_tb4 DESC NULLS LAST`) - .orderBy(sql`pb.ranking_value_tb5 DESC NULLS LAST`) - .orderBy("pb.time_achieved", "asc") - .execute(); - - return rows.map((r) => r.user_id); +export async function UpdateChartRanking(_game: V3Game, _chartID: string) { + // nothing. rivals don't go in calculated data, thanks claude. } export { upsertPbFromMongoDoc } from "./upsert-pb-pg"; diff --git a/typescript/server/src/lib/score-import/framework/pb/process-pbs.oldtest.ts b/typescript/server/src/lib/score-import/framework/pb/process-pbs.oldtest.ts deleted file mode 100644 index ed76cf23e..000000000 --- a/typescript/server/src/lib/score-import/framework/pb/process-pbs.oldtest.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; -import crypto from "crypto"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import { ProcessPBs } from "./process-pbs"; - -t.test("#ProcessPBs", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should successfully insert a pb into the score-pb database", async (t) => { - await MONGODB_KILL["personal-bests"].remove({}); - - // scores on 511 SPA are pre-loaded into the database - await ProcessPBs("iidx", "SP", 1, new Set([Testing511SPA.chartID]), log); - - const pbs = await MONGODB_KILL["personal-bests"].find({}); - - t.equal(pbs.length, 1, "Should match the amount of PBs inserted into the DB."); - - t.end(); - }); - - t.test("Should successfully insert multiple pbs into the score-pb database", async (t) => { - await MONGODB_KILL["personal-bests"].remove({}); - - await MONGODB_KILL.charts.iidx.insert([ - // @ts-expect-error lol - deepmerge(Testing511SPA, { - chartID: "test1", - songID: 2, - }), - // @ts-expect-error lol - deepmerge(Testing511SPA, { - chartID: "test2", - songID: 3, - }), - // @ts-expect-error lol - deepmerge(Testing511SPA, { - chartID: "test3", - songID: 4, - }), - ]); - - await MONGODB_KILL.scores.insert([ - // @ts-expect-error lol - deepmerge(TestingIIDXSPScore, { - chartID: "test1", - scoreID: crypto.randomBytes(20).toString("hex"), - }), - - // @ts-expect-error lol - deepmerge(TestingIIDXSPScore, { - chartID: "test2", - scoreID: crypto.randomBytes(20).toString("hex"), - }), - - // @ts-expect-error lol - deepmerge(TestingIIDXSPScore, { - chartID: "test3", - scoreID: crypto.randomBytes(20).toString("hex"), - }), - ]); - - await ProcessPBs( - "iidx", - "SP", - 1, - new Set(["test1", "test2", "test3", Testing511SPA.chartID]), - log, - ); - - const pbs = await MONGODB_KILL["personal-bests"].find({}); - - t.equal(pbs.length, 4, "Should match the amount of PBs inserted into the DB."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/pb/process-pbs.test.ts b/typescript/server/src/lib/score-import/framework/pb/process-pbs.test.ts new file mode 100644 index 000000000..738b32e7e --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/pb/process-pbs.test.ts @@ -0,0 +1,185 @@ +import { log } from "#lib/log/log"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { mkFakeScoreIIDXSP } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { randomBytes } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { ProcessPBs } from "./process-pbs"; + +async function seedIidx511Chart() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +async function insertIidxScoreRow(opts: { + chartId: string; + scoreId: string; + timeMs: number; + userId: number; +}) { + const doc = mkFakeScoreIIDXSP({ + userID: opts.userId, + scoreID: opts.scoreId, + chartID: opts.chartId, + scoreData: TestingIIDXSPScore.scoreData, + calculatedData: TestingIIDXSPScore.calculatedData, + timeAchieved: opts.timeMs, + timeAdded: opts.timeMs, + }); + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", doc.scoreData); + const ts = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: opts.userId, + chart_id: opts.chartId, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(doc.calculatedData), + meta: JSON.stringify(doc.scoreMeta), + time_achieved: ts, + time_added: ts, + highlight: false, + comment: null, + committed: true, + }) + .execute(); +} + +async function seedExtraChart(chartId: string, songLegacy: number) { + const songId = `s-${chartId}`; + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: songLegacy, + game_group: "iidx", + title: "T", + artist: "A", + search_terms: [], + alt_titles: [], + data: { displayVersion: "1", genre: "TEST" }, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: songId, + difficulty: "ANOTHER", + level: "10", + level_num: 10, + is_primary: true, + versions: [], + data: Testing511SPA.data, + }) + .execute(); +} + +describe("ProcessPBs", () => { + it("inserts one pb when a score exists on the chart", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + + await insertIidxScoreRow({ + userId, + scoreId: "pb-one", + chartId: Testing511SPA.chartID, + timeMs: 1000, + }); + + await DB.deleteFrom("pb").where("user_id", "=", userId).execute(); + + await ProcessPBs("iidx-sp", userId, new Set([Testing511SPA.chartID]), log); + + const pbs = await DB.selectFrom("pb") + .select("row_id") + .where("user_id", "=", userId) + .execute(); + expect(pbs).toHaveLength(1); + }); + + it("inserts multiple pbs across charts", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + + const extra = ["test1", "test2", "test3"] as const; + + for (let i = 0; i < extra.length; i++) { + await seedExtraChart(extra[i]!, 2 + i); + } + + await insertIidxScoreRow({ + userId, + scoreId: randomBytes(10).toString("hex"), + chartId: "test1", + timeMs: 1000, + }); + await insertIidxScoreRow({ + userId, + scoreId: randomBytes(10).toString("hex"), + chartId: "test2", + timeMs: 2000, + }); + await insertIidxScoreRow({ + userId, + scoreId: randomBytes(10).toString("hex"), + chartId: "test3", + timeMs: 3000, + }); + await insertIidxScoreRow({ + userId, + scoreId: randomBytes(10).toString("hex"), + chartId: Testing511SPA.chartID, + timeMs: 4000, + }); + + await DB.deleteFrom("pb").where("user_id", "=", userId).execute(); + + await ProcessPBs("iidx-sp", userId, new Set([Testing511SPA.chartID, ...extra]), log); + + const pbs = await DB.selectFrom("pb") + .select("row_id") + .where("user_id", "=", userId) + .execute(); + expect(pbs).toHaveLength(4); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/pb/process-pbs.ts b/typescript/server/src/lib/score-import/framework/pb/process-pbs.ts index 688381593..763ad8417 100644 --- a/typescript/server/src/lib/score-import/framework/pb/process-pbs.ts +++ b/typescript/server/src/lib/score-import/framework/pb/process-pbs.ts @@ -1,25 +1,17 @@ import type { KtLogger } from "#lib/log/log"; -import type { Game } from "tachi-db"; import DB from "#services/pg/db"; import { GetChartForIDGuaranteed } from "#utils/db"; -import { - type GameGroup, - GamePTToV3, - GetGPTString, - type integer, - type Playtype, -} from "tachi-common"; +import { type integer, type V3Game } from "tachi-common"; -import { CreatePBDoc, type MONGO_PBScoreDocumentNoRank, UpdateChartRanking } from "./create-pb-doc"; +import { CreatePBDoc, type PBScoreDocumentNoRank, UpdateChartRanking } from "./create-pb-doc"; import { upsertPbFromMongoDoc } from "./upsert-pb-pg"; /** * Process, recalculate and update a users PBs for this set of chartIDs. */ export async function ProcessPBs( - game: GameGroup, - playtype: Playtype, + game: V3Game, userID: integer, chartIDs: Set, log: KtLogger, @@ -28,21 +20,17 @@ export async function ProcessPBs( return; } - const gpt = GetGPTString(game, playtype); - const promises = []; for (const chartID of chartIDs) { promises.push( - GetChartForIDGuaranteed(game, chartID).then((chart) => - CreatePBDoc(gpt, userID, chart, log), - ), + GetChartForIDGuaranteed(chartID).then((chart) => CreatePBDoc(game, userID, chart, log)), ); } const pbDocsReturn = await Promise.all(promises); - const pbDocs: Array = []; + const pbDocs: Array = []; for (const doc of pbDocsReturn) { if (!doc) { @@ -63,7 +51,7 @@ export async function ProcessPBs( } }); - await Promise.all(pbDocs.map((e) => UpdateChartRanking(game, playtype, e.chartID))); + await Promise.all(pbDocs.map((doc) => UpdateChartRanking(doc.game, doc.chartID))); } /** @@ -71,22 +59,19 @@ export async function ProcessPBs( * (Postgres `score` / `chart` tables). Used after bulk PB removal so rankings stay coherent. */ export async function RecalculatePbsForChartsFromPostgresScores( - game: GameGroup, - playtype: Playtype, - chartLegacyIds: ReadonlyArray, + game: V3Game, + chartIDs: ReadonlyArray, log: KtLogger, ): Promise { - if (chartLegacyIds.length === 0) { + if (chartIDs.length === 0) { return; } - const v3Game = GamePTToV3(game, playtype) as Game; - const rows = await DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") - .select(["score.user_id", "chart.legacy_id"]) - .where("chart.legacy_id", "in", [...chartLegacyIds]) - .where("chart.game", "=", v3Game) + .select(["score.user_id", "chart.id"]) + .where("chart.id", "in", [...chartIDs]) + .where("chart.game", "=", game) .execute(); const byUser = new Map>(); @@ -99,10 +84,10 @@ export async function RecalculatePbsForChartsFromPostgresScores( byUser.set(r.user_id, set); } - set.add(r.legacy_id); + set.add(r.id); } for (const [uid, cids] of byUser) { - await ProcessPBs(game, playtype, uid, cids, log); + await ProcessPBs(game, uid, cids, log); } } diff --git a/typescript/server/src/lib/score-import/framework/pb/rederive-scores.ts b/typescript/server/src/lib/score-import/framework/pb/rederive-scores.ts index 40dd79dec..e11970eec 100644 --- a/typescript/server/src/lib/score-import/framework/pb/rederive-scores.ts +++ b/typescript/server/src/lib/score-import/framework/pb/rederive-scores.ts @@ -1,6 +1,6 @@ import type { KtLogger } from "#lib/log/log"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; import { type ScoreDocumentJoinRow, @@ -9,7 +9,6 @@ import { } from "#lib/db-formats/score"; import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; -import { GetGPTString, type V3Game, V3ToGamePT } from "tachi-common"; const BATCH_SIZE = 500; @@ -35,9 +34,7 @@ export async function rederiveScoresForChart(chartId: string, log: KtLogger): Pr } const chart = ToChartDocument(chartRow); - const { game, playtype } = V3ToGamePT(chartRow.chart_game as V3Game); - const gpt = GetGPTString(game, playtype); - const impl = GPT_SERVER_IMPLEMENTATIONS[gpt]; + const impl = GAME_IMPLEMENTATIONS[chart.game]; let totalUpdated = 0; let offset = 0; @@ -74,7 +71,7 @@ export async function rederiveScoresForChart(chartId: string, log: KtLogger): Pr const scoreCalcs = impl.scoreCalcs as any; const calculatedData = scoreCalcs(newScoreData, derivedData, chart); - const { derived } = mongoScoreDataToPg(gpt, newScoreData as any); + const { derived } = mongoScoreDataToPg(chart.game, newScoreData as any); // eslint-disable-next-line no-await-in-loop await DB.updateTable("score") diff --git a/typescript/server/src/lib/score-import/framework/pb/upsert-pb-pg.ts b/typescript/server/src/lib/score-import/framework/pb/upsert-pb-pg.ts index 25c802be6..9eaf65df9 100644 --- a/typescript/server/src/lib/score-import/framework/pb/upsert-pb-pg.ts +++ b/typescript/server/src/lib/score-import/framework/pb/upsert-pb-pg.ts @@ -1,13 +1,13 @@ import type { Kysely } from "kysely"; import type { Database } from "tachi-db"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import { UnixMillisecondsToISO8601 } from "#utils/time"; -import { GetGPTString, type GPTString, type MONGO_PBScoreDocument } from "tachi-common"; +import { type PBScoreDocument, type V3Game } from "tachi-common"; -export type MONGO_PBScoreDocumentNoRank = Omit< - MONGO_PBScoreDocument, +export type PBScoreDocumentNoRank = Omit< + PBScoreDocument, "rankingData" >; @@ -16,12 +16,12 @@ export type MONGO_PBScoreDocumentNoRank = Omi */ export async function upsertPbFromMongoDoc( db: Kysely, - pbDoc: MONGO_PBScoreDocumentNoRank, + pbDoc: PBScoreDocumentNoRank, ): Promise { - const gpt = GetGPTString(pbDoc.game, pbDoc.playtype); - const { data, derived, judgements } = mongoScoreDataToPg(gpt, pbDoc.scoreData); + const game = pbDoc.game; + const { data, derived, judgements } = mongoScoreDataToPg(game, pbDoc.scoreData); const judgementsJson = JSON.stringify(judgements); - const ranking = GPT_SERVER_IMPLEMENTATIONS[gpt].pbRankingValues(pbDoc as never); + const ranking = GAME_IMPLEMENTATIONS[game].pbRankingValues(pbDoc as never); const calc = { ...(pbDoc.calculatedData as Record) }; delete calc.rank; @@ -96,13 +96,15 @@ export async function upsertPbFromMongoDoc( await db.deleteFrom("pb_composed_from").where("pb_id", "=", pbId).execute(); - for (const ref of pbDoc.composedFrom) { + if (pbDoc.composedFrom.length > 0) { await db .insertInto("pb_composed_from") - .values({ - pb_id: pbId, - score_id: ref.scoreID, - }) + .values( + pbDoc.composedFrom.map((ref) => ({ + pb_id: pbId, + score_id: ref.scoreID, + })), + ) .execute(); } } diff --git a/typescript/server/src/lib/score-import/framework/pg/finalize-import-pg.ts b/typescript/server/src/lib/score-import/framework/pg/finalize-import-pg.ts index 6232bff0d..2f06b2ee9 100644 --- a/typescript/server/src/lib/score-import/framework/pg/finalize-import-pg.ts +++ b/typescript/server/src/lib/score-import/framework/pg/finalize-import-pg.ts @@ -1,17 +1,16 @@ import type { Kysely } from "kysely"; -import type { Database, Game } from "tachi-db"; +import type { Database } from "tachi-db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { type ClassDelta, type GameGroup, - GamePTToV3, type GoalImportInfo, type ImportTypes, type integer, - type Playtype, type QuestImportInfo, type SessionInfoReturn, + type V3Game, } from "tachi-common"; type ImportErrRow = { message: string; type: string }; @@ -38,11 +37,12 @@ export async function finalizeImportToPostgres( createdSessions: Array; errors: Array; gameGroup: GameGroup; + games: Array; goalInfo: Array; - importId: string; + importID: string; importType: ImportTypes; - playtypes: Array; questInfo: Array; + scoreCount: number; service: string; timeFinishedMs: number; timeStartedMs: number; @@ -52,20 +52,21 @@ export async function finalizeImportToPostgres( }, ): Promise { const { - importId, + importID, gameGroup, importType, userIntent, service, timeStartedMs, timeFinishedMs, - playtypes, + games, errors, classDeltas, createdSessions, goalInfo, questInfo, timing, + scoreCount, } = args; const timeStarted = UnixMillisecondsToISO8601(timeStartedMs); @@ -82,111 +83,119 @@ export async function finalizeImportToPostgres( user_intent: userIntent, service, }) - .where("id", "=", importId) + .where("id", "=", importID) .execute(); await db .updateTable("score") .set({ committed: true }) - .where("import_id", "=", importId) + .where("import_id", "=", importID) .where("committed", "=", false) .execute(); - await db.deleteFrom("import_game").where("id", "=", importId).execute(); + await db.deleteFrom("import_game").where("id", "=", importID).execute(); - for (const pt of playtypes) { - const v3Game = GamePTToV3(gameGroup, pt) as Game; - - await db.insertInto("import_game").values({ id: importId, game: v3Game }).execute(); + if (games.length > 0) { + await db + .insertInto("import_game") + .values(games.map((game) => ({ id: importID, game }))) + .execute(); } - await db.deleteFrom("import_error").where("import_id", "=", importId).execute(); - - for (const err of errors) { + await db.deleteFrom("import_error").where("import_id", "=", importID).execute(); + if (errors.length > 0) { await db .insertInto("import_error") - .values({ - import_id: importId, - type: err.type, - message: err.message, - }) + .values( + errors.map((err) => ({ + import_id: importID, + type: err.type, + message: err.message, + })), + ) .execute(); } - await db.deleteFrom("import_class").where("import_id", "=", importId).execute(); - - for (const d of classDeltas) { - const v3Game = GamePTToV3(d.game, d.playtype) as Game; + await db.deleteFrom("import_class").where("import_id", "=", importID).execute(); + if (classDeltas.length > 0) { await db .insertInto("import_class") - .values({ - import_id: importId, - game: v3Game, - set: d.set as string, - prev: d.old, - new: d.new, - }) + .values( + classDeltas.map((d) => ({ + import_id: importID, + game: d.game, + set: d.set as string, + prev: d.old, + new: d.new, + })), + ) .execute(); } - await db.deleteFrom("import_session").where("import_id", "=", importId).execute(); + await db.deleteFrom("import_session").where("import_id", "=", importID).execute(); - for (const s of createdSessions) { + if (createdSessions.length > 0) { await db .insertInto("import_session") - .values({ - import_id: importId, - session_id: s.sessionID, - type: s.type.toLowerCase() as "appended" | "created", - }) + .values( + createdSessions.map((s) => ({ + import_id: importID, + session_id: s.sessionID, + type: s.type.toLowerCase() as "appended" | "created", + })), + ) .execute(); } - await db.deleteFrom("import_goal").where("import_id", "=", importId).execute(); + await db.deleteFrom("import_goal").where("import_id", "=", importID).execute(); - for (const g of goalInfo) { + if (goalInfo.length > 0) { await db .insertInto("import_goal") - .values({ - import_id: importId, - goal_id: g.goalID, - prev_achieved: g.old.achieved, - prev_out_of: g.old.outOf, - prev_out_of_human: g.old.outOfHuman, - prev_progress: g.old.progress, - prev_progress_human: g.old.progressHuman, - new_achieved: g.new.achieved, - new_out_of: g.new.outOf, - new_out_of_human: g.new.outOfHuman, - new_progress: g.new.progress, - new_progress_human: g.new.progressHuman, - }) + .values( + goalInfo.map((g) => ({ + import_id: importID, + goal_id: g.goalID, + prev_achieved: g.old.achieved, + prev_out_of: g.old.outOf, + prev_out_of_human: g.old.outOfHuman, + prev_progress: g.old.progress, + prev_progress_human: g.old.progressHuman, + new_achieved: g.new.achieved, + new_out_of: g.new.outOf, + new_out_of_human: g.new.outOfHuman, + new_progress: g.new.progress, + new_progress_human: g.new.progressHuman, + })), + ) .execute(); } - await db.deleteFrom("import_quest").where("import_id", "=", importId).execute(); + await db.deleteFrom("import_quest").where("import_id", "=", importID).execute(); - for (const q of questInfo) { + if (questInfo.length > 0) { await db .insertInto("import_quest") - .values({ - import_id: importId, - quest_id: q.questID, - prev_achieved: q.old.achieved, - prev_progress: q.old.progress, - new_achieved: q.new.achieved, - new_progress: q.new.progress, - }) + .values( + questInfo.map((q) => ({ + import_id: importID, + quest_id: q.questID, + prev_achieved: q.old.achieved, + prev_progress: q.old.progress, + new_achieved: q.new.achieved, + new_progress: q.new.progress, + })), + ) .execute(); } - const n = Math.max(1, playtypes.length); + const n = Math.max(1, scoreCount); await db .insertInto("import_timing") .values({ - id: importId, + id: importID, timestamp: tsNow, import_secs_avg: timing.importMs / n, import_parse_secs_avg: timing.importParseMs / n, diff --git a/typescript/server/src/lib/score-import/framework/pg/mongo-score-to-pg.ts b/typescript/server/src/lib/score-import/framework/pg/mongo-score-to-pg.ts index 2e5c2cd91..56e4f9b32 100644 --- a/typescript/server/src/lib/score-import/framework/pg/mongo-score-to-pg.ts +++ b/typescript/server/src/lib/score-import/framework/pg/mongo-score-to-pg.ts @@ -2,14 +2,14 @@ import type { NewScore } from "tachi-db"; import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import { UnixMillisecondsToISO8601 } from "#utils/time"; -import { GetGPTString, GPTStringToV3, type MONGO_ScoreDocument } from "tachi-common"; +import { type ScoreDocument } from "tachi-common"; /** * Maps a hydrated Mongo-shaped score document to a Postgres `score` insert row. * `chartIdPg` must be `chart.id` (FK to `chart.id`). The score document's `chartID` may be a legacy id string. */ export function mongoScoreDocumentToNewScoreRow( - score: MONGO_ScoreDocument, + score: ScoreDocument, chartIdPg: string, opts: { committed: boolean; @@ -17,14 +17,14 @@ export function mongoScoreDocumentToNewScoreRow( sessionId: string | null; }, ): NewScore { - const gpt = GetGPTString(score.game, score.playtype); - const { data, derived, judgements } = mongoScoreDataToPg(gpt, score.scoreData); + const game = score.game; + const { data, derived, judgements } = mongoScoreDataToPg(game, score.scoreData); return { id: score.scoreID, user_id: score.userID, chart_id: chartIdPg, - game: GPTStringToV3(gpt), + game, session_id: opts.sessionId, import_id: opts.importId, data: JSON.stringify(data), diff --git a/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.oldtest.ts b/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.oldtest.ts deleted file mode 100644 index bcce5274e..000000000 --- a/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.oldtest.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { GITADORA_COLOURS, type UserGameStats } from "tachi-common"; -import t from "tap"; - -import { CalculateUGPTClasses, ProcessClassDeltas } from "./classes"; - -t.test("#CalculateUGPTClasses", (t) => { - t.test("Should produce an empty object by default", async (t) => { - const res = await CalculateUGPTClasses("iidx", "SP", 1, {}, null, log); - - t.strictSame(res, {}); - - t.end(); - }); - - t.test("Should call and merge the ClassHandler", async (t) => { - const res = await CalculateUGPTClasses("iidx", "SP", 1, {}, () => ({ dan: "DAN_2" }), log); - - t.strictSame(res, { dan: "DAN_2" }); - - t.end(); - }); - - t.test("Should call static handlers if there is one", async (t) => { - const res = await CalculateUGPTClasses( - "gitadora", - "Dora", - 1, - { - naiveSkill: 9000, - }, - null, - log, - ); - - t.strictSame(res, { colour: "RAINBOW" }); - - t.end(); - }); - - t.end(); -}); - -t.test("#ProcessClassDeltas", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return improved classes from null", async (t) => { - const res = await ProcessClassDeltas("iidx", "SP", { dan: "KAIDEN" }, null, 1, log); - - t.strictSame(res, [ - { - game: "iidx", - set: "dan", - playtype: "SP", - old: null, - new: "KAIDEN", - }, - ]); - - t.end(); - }); - - t.test("Should return improved classes from null class", async (t) => { - const res = await ProcessClassDeltas( - "iidx", - "SP", - { dan: "KAIDEN" }, - { classes: {} } as UserGameStats, - 1, - log, - ); - - t.strictSame(res, [ - { - game: "iidx", - set: "dan", - playtype: "SP", - old: null, - new: "KAIDEN", - }, - ]); - - t.end(); - }); - - t.test("Should return improved classes", async (t) => { - const res = await ProcessClassDeltas( - "iidx", - "SP", - { dan: "KAIDEN" }, - { classes: { dan: "CHUUDEN" } } as unknown as UserGameStats, - 1, - log, - ); - - t.strictSame(res, [ - { - game: "iidx", - set: "dan", - playtype: "SP", - old: "CHUUDEN", - new: "KAIDEN", - }, - ]); - - t.end(); - }); - - t.test("Should not return identical classes", async (t) => { - const res = await ProcessClassDeltas( - "iidx", - "SP", - { dan: "KAIDEN" }, - { classes: { dan: "KAIDEN" } } as unknown as UserGameStats, - 1, - log, - ); - - t.strictSame(res, []); - - t.end(); - }); - - t.test("Should not return worse classes if the class isn't downgradable", async (t) => { - const res = await ProcessClassDeltas( - "iidx", - "SP", - { dan: "DAN_10" }, - { classes: { dan: "KAIDEN" } } as unknown as UserGameStats, - 1, - log, - ); - - t.strictSame(res, []); - - t.end(); - }); - - t.test("Should return worse classes if the class is downgradable", async (t) => { - const res = await ProcessClassDeltas( - "sdvx", - "Single", - { vfClass: "DANDELION_I" }, - { classes: { vfClass: "DANDELION_II" } } as unknown as UserGameStats, - 1, - log, - ); - - t.strictSame(res, [ - { - game: "sdvx", - set: "vfClass", - playtype: "Single", - old: "DANDELION_II", - new: "DANDELION_I", - }, - ]); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.test.ts b/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.test.ts new file mode 100644 index 000000000..ce2aaccc3 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.test.ts @@ -0,0 +1,138 @@ +import { log } from "#lib/log/log"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { type UserGameStats } from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { CalculateUGPTClasses, ProcessClassDeltas } from "./classes"; + +describe("CalculateUGPTClasses", () => { + it("produces an empty object when there are no derived or custom classes", async () => { + const res = await CalculateUGPTClasses("iidx-sp", 1, {}, null, log); + + expect(res).toEqual({}); + }); + + it("merges classes from the ClassProvider", async () => { + const res = await CalculateUGPTClasses( + "iidx-sp", + 1, + {}, + async () => ({ dan: "DAN_2" }), + log, + ); + + expect(res).toEqual({ dan: "DAN_2" }); + }); + + it("applies static derived handlers when present", async () => { + const res = await CalculateUGPTClasses("gitadora-dora", 1, { naiveSkill: 9000 }, null, log); + + expect(res).toEqual({ colour: "RAINBOW" }); + }); +}); + +describe("ProcessClassDeltas", () => { + let userId: number; + + beforeEach(async () => { + ({ id: userId } = await seedUser({ + username: "class_delta_tester", + withCredential: true, + withSettings: true, + })); + }); + + it("returns improved classes from null user stats", async () => { + const res = await ProcessClassDeltas("iidx-sp", { dan: "KAIDEN" }, null, userId, log); + + expect(res).toEqual([ + { + game: "iidx-sp", + set: "dan", + old: null, + new: "KAIDEN", + }, + ]); + }); + + it("returns improved classes when existing stats have no classes", async () => { + const res = await ProcessClassDeltas( + "iidx-sp", + { dan: "KAIDEN" }, + { classes: {} } as UserGameStats, + userId, + log, + ); + + expect(res).toEqual([ + { + game: "iidx-sp", + set: "dan", + old: null, + new: "KAIDEN", + }, + ]); + }); + + it("returns improved classes when the new value ranks higher", async () => { + const res = await ProcessClassDeltas( + "iidx-sp", + { dan: "KAIDEN" }, + { classes: { dan: "CHUUDEN" } } as unknown as UserGameStats, + userId, + log, + ); + + expect(res).toEqual([ + { + game: "iidx-sp", + set: "dan", + old: "CHUUDEN", + new: "KAIDEN", + }, + ]); + }); + + it("returns no deltas when classes are unchanged", async () => { + const res = await ProcessClassDeltas( + "iidx-sp", + { dan: "KAIDEN" }, + { classes: { dan: "KAIDEN" } } as unknown as UserGameStats, + userId, + log, + ); + + expect(res).toEqual([]); + }); + + it("does not downgrade provided classes", async () => { + const res = await ProcessClassDeltas( + "iidx-sp", + { dan: "DAN_10" }, + { classes: { dan: "KAIDEN" } } as unknown as UserGameStats, + userId, + log, + ); + + expect(res).toEqual([]); + }); + + it("allows downgrade for downgradable derived classes", async () => { + const res = await ProcessClassDeltas( + "sdvx", + { vfClass: "DANDELION_I" }, + { classes: { vfClass: "DANDELION_II" } } as unknown as UserGameStats, + userId, + log, + ); + + expect(res).toEqual([ + { + game: "sdvx", + set: "vfClass", + old: "DANDELION_II", + new: "DANDELION_I", + }, + ]); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.ts b/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.ts index dd67dbea9..eb71ff920 100644 --- a/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.ts +++ b/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.ts @@ -3,21 +3,17 @@ import type { KtLogger } from "#lib/log/log"; import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; import DB from "#services/pg/db"; import { ReturnClassIfGreater } from "#utils/class"; -import { UnixMillisecondsToISO8601 } from "#utils/time.js"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; import deepmerge from "deepmerge"; import { type AnyClasses, type ClassDelta, type Classes, type ExtractedClasses, - type GameGroup, - GamePTToV3, - GetGamePTConfig, - GetGPTString, - type GPTString, + GetGameConfig, type integer, - type MONGO_UserGameStats, - type Playtype, + type UserGameStats, + type V3Game, } from "tachi-common"; import type { ClassProvider } from "../calculated-data/types"; @@ -46,23 +42,20 @@ import { CalculateDerivedClasses } from "../calculated-data/profile-classes"; * defaults. */ export async function CalculateUGPTClasses( - game: GameGroup, - playtype: Playtype, + game: V3Game, userID: integer, ratings: Record, - ClassProvider: ClassProvider | null, + ClassProvider: ClassProvider | null, log: KtLogger, -): Promise { - const gptString = GetGPTString(game, playtype); - +): Promise { // Derive all classes first. - let classes = CalculateDerivedClasses(gptString, ratings); + let classes = CalculateDerivedClasses(game, ratings); // If this import method is providing us classes, merge those with the // other classes we have. if (ClassProvider) { log.debug(`Calling custom class handler.`); - const customClasses = (await ClassProvider(gptString, userID, ratings, log)) ?? {}; + const customClasses = (await ClassProvider(game, userID, ratings, log)) ?? {}; classes = deepmerge(customClasses, classes); } @@ -83,23 +76,20 @@ export async function CalculateUGPTClasses( * This function emits webhook events and inserts classachieved documents into the DB! */ export async function ProcessClassDeltas( - game: GameGroup, - playtype: Playtype, + game: V3Game, classes: AnyClasses, - userGameStats: MONGO_UserGameStats | null, + userGameStats: UserGameStats | null, userID: integer, log: KtLogger, ): Promise> { - const gptString = GetGPTString(game, playtype); - const deltas: Array = []; const achievementOps = []; - const gptConfig = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); for (const s of Object.keys(classes)) { - const classSet = s as Classes[GPTString]; + const classSet = s as Classes[V3Game]; const classVal = classes[classSet]; if (classVal === undefined || classVal === null) { @@ -107,10 +97,10 @@ export async function ProcessClassDeltas( continue; } - const classConfig = gptConfig.classes[classSet]!; + const classConfig = gameConfig.classes[classSet]!; try { - const isGreater = ReturnClassIfGreater(gptString, classSet, classVal, userGameStats); + const isGreater = ReturnClassIfGreater(game, classSet, classVal, userGameStats); // if this was worse, and this class is PROVIDED (i.e. it's a dan) // then don't do anything @@ -126,7 +116,6 @@ export async function ProcessClassDeltas( delta = { game, set: classSet, - playtype, old: null, new: classVal, }; @@ -134,7 +123,6 @@ export async function ProcessClassDeltas( delta = { game, set: classSet, - playtype, old: userGameStats!.classes[classSet]!, new: classVal, }; @@ -144,7 +132,13 @@ export async function ProcessClassDeltas( if (isGreater !== false) { void EmitWebhookEvent({ type: "class-update/v1", - content: { userID, ...delta }, + content: { + userID, + game, + set: delta.set, + old: delta.old, + new: delta.new, + }, }); achievementOps.push({ @@ -153,7 +147,6 @@ export async function ProcessClassDeltas( classOldValue: delta.old, classValue: delta.new, game, - playtype, timeAchieved: Date.now(), }); } @@ -172,7 +165,7 @@ export async function ProcessClassDeltas( class_prev_value: op.classOldValue ?? "", class_set: op.classSet, class_value: op.classValue, - game: GamePTToV3(op.game, op.playtype), + game, timestamp: UnixMillisecondsToISO8601(op.timeAchieved), user_id: op.userID, })), diff --git a/typescript/server/src/lib/score-import/framework/quests/quests.oldtest.ts b/typescript/server/src/lib/score-import/framework/quests/quests.oldtest.ts deleted file mode 100644 index 01008615c..000000000 --- a/typescript/server/src/lib/score-import/framework/quests/quests.oldtest.ts +++ /dev/null @@ -1,5 +0,0 @@ -import t from "tap"; - -t.todo("#UpdateUsersQuests", (t) => { - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/quests/quests.test.ts b/typescript/server/src/lib/score-import/framework/quests/quests.test.ts new file mode 100644 index 000000000..74359ac3c --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/quests/quests.test.ts @@ -0,0 +1,5 @@ +import { describe, it } from "vitest"; + +describe("quests framework (ported from quests.oldtest.ts)", () => { + it.todo("UpdateUsersQuests"); +}); diff --git a/typescript/server/src/lib/score-import/framework/quests/quests.ts b/typescript/server/src/lib/score-import/framework/quests/quests.ts index df60ff88d..89626f4e2 100644 --- a/typescript/server/src/lib/score-import/framework/quests/quests.ts +++ b/typescript/server/src/lib/score-import/framework/quests/quests.ts @@ -7,39 +7,35 @@ import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; import DB from "#services/pg/db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { - type GameGroup, - GamePTToV3, type GoalImportInfo, type integer, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, - type Playtype, + type QuestDocument, type QuestImportInfo, + type QuestSubscriptionDocument, + type V3Game, } from "tachi-common"; export async function UpdateUsersQuests( importGoalInfo: Array, - game: GameGroup, - playtypes: Array, + game: V3Game, userID: integer, log: KtLogger, ) { const goalIDs = importGoalInfo.map((e) => e.goalID); - const { quests, questSubs } = await GetRelevantQuests(goalIDs, game, playtypes, userID, log); + const { quests, questSubs } = await GetRelevantQuests(goalIDs, game, userID, log); return UpdateQuestsForUser(quests, questSubs, game, userID, log); } export async function UpdateQuestsForUser( - quests: Array, - questSubs: Array, - - game: GameGroup, + quests: Array, + questSubs: Array, + game: V3Game, userID: integer, log: KtLogger, ) { - const questSubMap = new Map(); + const questSubMap = new Map(); for (const um of questSubs) { questSubMap.set(um.questID, um); @@ -95,7 +91,6 @@ export async function UpdateQuestsForUser( userID, ...questInfo, game, - playtype: quest.playtype, }, }); @@ -115,23 +110,21 @@ export async function UpdateQuestsForUser( async function GetRelevantQuests( goalIDs: Array, - game: GameGroup, - playtypes: Array, + game: V3Game, userID: integer, log: KtLogger, ) { if (goalIDs.length === 0) { - return { quests: [] as Array, questSubs: [] }; + return { quests: [] as Array, questSubs: [] }; } - const v3Games = playtypes.map((pt) => GamePTToV3(game, pt)); const goalIdSet = new Set(goalIDs); const questSubRows = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", userID) - .where("quest.game", "in", v3Games) + .where("quest.game", "=", game) .execute(); log.debug(`Found ${questSubRows.length} quest-subs.`); diff --git a/typescript/server/src/lib/score-import/framework/score-import.oldtest.ts b/typescript/server/src/lib/score-import/framework/score-import.oldtest.ts deleted file mode 100644 index e3aab1052..000000000 --- a/typescript/server/src/lib/score-import/framework/score-import.oldtest.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { CDNRetrieve } from "#lib/cdn/cdn"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { FakeSmallBatchManual } from "#test-utils/test-data"; -import t from "tap"; - -import { MakeScoreImport } from "./score-import"; - -t.test("#MakeScoreImport", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should successfully import basic things.", async (t) => { - const res = await MakeScoreImport({ - importID: "mockImportID", - importType: "ir/direct-manual", - parserArguments: [FakeSmallBatchManual, false], - userID: 1, - userIntent: true, - }); - - t.hasStrict(res, { - importID: "mockImportID", - }); - - const dbRes = await MONGODB_KILL.imports.findOne( - { importID: "mockImportID" }, - { projection: { _id: 0 } }, - ); - - t.strictSame(res, dbRes, "Import in the database should match the import returned"); - - try { - const cdnRes = await CDNRetrieve("/score-import-input/mockImportID").then((r) => - JSON.parse(r.toString("utf-8")), - ); - - t.strictSame( - cdnRes, - [ - { - meta: { game: "iidx", playtype: "SP", service: "foobar" }, - scores: [ - { - score: 500, - lamp: "HARD CLEAR", - matchType: "songTitle", - identifier: "5.1.1.", - difficulty: "ANOTHER", - }, - ], - }, - false, - ], - "Should store import-input on the CDN.", - ); - } catch (err) { - t.fail(err as any); - } - - const importTrack = await MONGODB_KILL["import-trackers"].findOne({ - importID: "mockImportID", - }); - - t.equal( - importTrack, - null, - "There should be no import-tracker defined in the database, as the import 'mockImportID' was successful.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/score-import.test.ts b/typescript/server/src/lib/score-import/framework/score-import.test.ts new file mode 100644 index 000000000..a5650d040 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/score-import.test.ts @@ -0,0 +1,112 @@ +import { seedUser } from "#actions/test-utils/api-tokens"; +import { CDNRetrieve } from "#lib/cdn/cdn"; +import { LoadImportDocumentById } from "#lib/db-formats/import-document"; +import DB from "#services/pg/db"; +import { FakeSmallBatchManual, Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import { Sleep } from "#utils/misc"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { MakeScoreImport } from "./score-import"; + +async function seed511Chart() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +describe("MakeScoreImport (ported from score-import.oldtest.ts)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "score-import-mks@example.com", + withCredential: true, + withSettings: true, + }); + await seed511Chart(); + }); + + it("imports BATCH-MANUAL and matches LoadImportDocumentById", async () => { + const res = await MakeScoreImport({ + importID: "mockImportID", + importType: "ir/direct-manual", + parserArguments: [FakeSmallBatchManual, false], + userID: 1, + userIntent: true, + }); + + expect(res.importID).toBe("mockImportID"); + + const dbRes = await LoadImportDocumentById("mockImportID"); + expect(dbRes).toBeDefined(); + expect(dbRes?.importID).toBe("mockImportID"); + + const tracker = await DB.selectFrom("import_tracker") + .select("import_id") + .where("import_id", "=", "mockImportID") + .executeTakeFirst(); + + expect(tracker).toBeUndefined(); + + expect(res).toEqual(dbRes); + }); + + it.skipIf(!process.env.TACHI_CDN_SAVE_LOCATION_BUCKET)( + "stores import-input on CDN when TACHI_CDN_SAVE_LOCATION_BUCKET is set", + async () => { + await MakeScoreImport({ + importID: "mockImportID_cdn", + importType: "ir/direct-manual", + parserArguments: [FakeSmallBatchManual, false], + userID: 1, + userIntent: true, + }); + + await Sleep(800); + + const cdnRes = await CDNRetrieve("/score-import-input/mockImportID_cdn").then((r) => + JSON.parse(r.toString("utf-8")), + ); + + expect(cdnRes).toEqual([ + { + meta: { game: "iidx", playtype: "SP", service: "foobar" }, + scores: [ + { + score: 500, + lamp: "HARD CLEAR", + matchType: "songTitle", + identifier: "5.1.1.", + difficulty: "ANOTHER", + }, + ], + }, + false, + ]); + }, + ); +}); diff --git a/typescript/server/src/lib/score-import/framework/score-import.ts b/typescript/server/src/lib/score-import/framework/score-import.ts index 29ec14de4..6162dd20c 100644 --- a/typescript/server/src/lib/score-import/framework/score-import.ts +++ b/typescript/server/src/lib/score-import/framework/score-import.ts @@ -1,4 +1,4 @@ -import type { ImportTypes, integer, MONGO_ImportDocument } from "tachi-common"; +import type { ImportDocument, ImportTypes, integer } from "tachi-common"; import { JOB_RETRY_COUNT } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; @@ -32,15 +32,15 @@ import { */ export async function MakeScoreImport( jobData: ScoreImportJobData, -): Promise { +): Promise { await StartTrackingImport(jobData); try { - const MONGO_ImportDocument = await MakeScoreImportInner(jobData); + const ImportDocument = await MakeScoreImportInner(jobData); await EndTrackingImport(jobData.importID); - return MONGO_ImportDocument; + return ImportDocument; } catch (e) { const err = e as Error | ScoreImportFatalError; @@ -56,7 +56,7 @@ export async function MakeScoreImport( */ async function MakeScoreImportInner( jobData: ScoreImportJobData, -): Promise { +): Promise { if (ServerConfig.USE_EXTERNAL_SCORE_IMPORT_WORKER && process.env.IS_JOB === undefined) { let timesAttempted = 1; @@ -77,7 +77,7 @@ async function MakeScoreImportInner( if (data.success) { await EndTrackingImport(jobData.importID); - return data.MONGO_ImportDocument; + return data.ImportDocument; } else if (data.statusCode !== 409) { throw new ScoreImportFatalError(data.statusCode, data.description); } diff --git a/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts b/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts index 9dbbe3804..de7659c92 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts @@ -1,15 +1,15 @@ import type { KtLogger } from "#lib/log/log"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { - GetGPTConfig, - type GPTString, + type ChartDocument, + GetGameConfig, type integer, - type MONGO_ChartDocument, - type MONGO_ScoreData, type MongoDerivedMetrics, type OptionalEnumIndexes, + type ScoreData, type ScoreEnumIndexes, + type V3Game, } from "tachi-common"; import type { DryScore, DryScoreData } from "../common/types"; @@ -20,26 +20,25 @@ import { InternalFailure } from "../common/converter-failures"; * Given the providedMetrics and chart this score is on, derive the rest of the metrics * we want to store. */ -function DeriveMetrics( - gpt: GPT, - metrics: DryScoreData, - chart: MONGO_ChartDocument, +function DeriveMetrics( + metrics: DryScoreData, + chart: ChartDocument, ) { - return GPT_SERVER_IMPLEMENTATIONS[gpt].scoreDeriver( - metrics as MONGO_ScoreData, + return GAME_IMPLEMENTATIONS[chart.game].scoreDeriver( + metrics as ScoreData, chart, - ) as MongoDerivedMetrics[GPT]; + ) as MongoDerivedMetrics[TGame]; } -export function CreateEnumIndexes(gpt: GPT, metrics: any, log: KtLogger) { - const gptConfig = GetGPTConfig(gpt); +export function CreateEnumIndexes(game: TGame, metrics: any, log: KtLogger) { + const gameConfig = GetGameConfig(game); const indexes: Record = {}; const optionalIndexes: Record = {}; for (const [key, conf] of [ - ...Object.entries(gptConfig.providedMetrics), - ...Object.entries(gptConfig.derivedMetrics), + ...Object.entries(gameConfig.providedMetrics), + ...Object.entries(gameConfig.derivedMetrics), ]) { if (conf.type !== "ENUM") { continue; @@ -50,11 +49,11 @@ export function CreateEnumIndexes(gpt: GPT, metrics: any, if (index === -1) { log.error( { metrics, key, conf }, - `Got an invalid enum value of ${metrics[key]} for ${gpt} ${key} on DryScore. Can't add indexes?`, + `Got an invalid enum value of ${metrics[key]} for ${game} ${key} on DryScore. Can't add indexes?`, ); throw new InternalFailure( - `Got an invalid enum value of ${metrics[key]} for ${gpt} ${key} on DryScore. Can't add indexes?`, + `Got an invalid enum value of ${metrics[key]} for ${game} ${key} on DryScore. Can't add indexes?`, ); } @@ -62,8 +61,8 @@ export function CreateEnumIndexes(gpt: GPT, metrics: any, } for (const [key, conf] of [ - ...Object.entries(gptConfig.providedMetrics), - ...Object.entries(gptConfig.derivedMetrics), + ...Object.entries(gameConfig.providedMetrics), + ...Object.entries(gameConfig.derivedMetrics), ]) { if (conf.type !== "ENUM") { continue; @@ -79,11 +78,11 @@ export function CreateEnumIndexes(gpt: GPT, metrics: any, if (index === -1) { log.error( { metrics, key, conf }, - `Got an invalid enum value of ${metrics.optional[key]} for ${gpt} optional.${key} on DryScore. Can't add indexes?`, + `Got an invalid enum value of ${metrics.optional[key]} for ${game} optional.${key} on DryScore. Can't add indexes?`, ); throw new InternalFailure( - `Got an invalid enum value of ${metrics.optional[key]} for ${gpt} optional.${key} on DryScore. Can't add indexes?`, + `Got an invalid enum value of ${metrics.optional[key]} for ${game} optional.${key} on DryScore. Can't add indexes?`, ); } @@ -91,29 +90,28 @@ export function CreateEnumIndexes(gpt: GPT, metrics: any, } return { - indexes: indexes as ScoreEnumIndexes, - optionalIndexes: optionalIndexes as OptionalEnumIndexes, + indexes: indexes as ScoreEnumIndexes, + optionalIndexes: optionalIndexes as OptionalEnumIndexes, }; } /** * Return a full piece of scoreData. */ -export function CreateFullScoreData( - gpt: GPT, - dryScoreData: DryScore["scoreData"], - chart: MONGO_ChartDocument, +export function CreateFullScoreData( + dryScoreData: DryScore["scoreData"], + chart: ChartDocument, log: KtLogger, ) { - const derivedMetrics = DeriveMetrics(gpt, dryScoreData, chart); + const derivedMetrics = DeriveMetrics(dryScoreData, chart); const scoreData = { ...dryScoreData, ...derivedMetrics, - } as unknown as MONGO_ScoreData; + } as unknown as ScoreData; // ^ hacky force-cast because these types are *really* unstable. - const { indexes, optionalIndexes } = CreateEnumIndexes(gpt, scoreData, log); + const { indexes, optionalIndexes } = CreateEnumIndexes(chart.game, scoreData, log); // again, silly hacks aorund typesafety here because to be honest // this stuff is more generic than TS really should ever have to implement. diff --git a/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.oldtest.ts b/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.oldtest.ts deleted file mode 100644 index 154db9c93..000000000 --- a/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.oldtest.ts +++ /dev/null @@ -1,5 +0,0 @@ -import t from "tap"; - -t.todo("#HydrateScore", (t) => { - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.ts b/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.ts index d5cd50adc..163e45f80 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.ts @@ -1,12 +1,10 @@ import type { KtLogger } from "#lib/log/log"; import { - GetGPTString, + type ChartDocument, type integer, - type MONGO_ChartDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, - MongoChartLegacyId, + type ScoreDocument, + type SongDocument, } from "tachi-common"; import type { DryScore } from "../common/types"; @@ -22,18 +20,16 @@ import { CreateFullScoreData } from "./derivers"; export function HydrateScore( userID: integer, dryScore: DryScore, - chart: MONGO_ChartDocument, - song: MONGO_SongDocument, + chart: ChartDocument, + song: SongDocument, scoreID: string, log: KtLogger, -): MONGO_ScoreDocument { - const gpt = GetGPTString(dryScore.game, chart.playtype); +): ScoreDocument { + const scoreData = CreateFullScoreData(dryScore.scoreData, chart, log); - const scoreData = CreateFullScoreData(gpt, dryScore.scoreData, chart, log); + const calculatedData = CreateScoreCalcData(scoreData, chart); - const calculatedData = CreateScoreCalcData(dryScore.game, scoreData, chart); - - const score: MONGO_ScoreDocument = { + const score: ScoreDocument = { ...dryScore, // then push our new score data. @@ -45,9 +41,8 @@ export function HydrateScore( userID, calculatedData, songID: song.id, - chartID: MongoChartLegacyId(chart), + chartID: chart.chartID, scoreID, - playtype: chart.playtype, isPrimary: chart.isPrimary, }; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/insert-score.oldtest.ts b/typescript/server/src/lib/score-import/framework/score-importing/insert-score.oldtest.ts deleted file mode 100644 index 2666dc247..000000000 --- a/typescript/server/src/lib/score-import/framework/score-importing/insert-score.oldtest.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { ScoreDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -import { InsertQueue, QueueScoreInsert } from "./insert-score"; - -// these two get the same tests, because they're too closely linked -t.test("#QueueScoreInsert, #InsertQueue", async (t) => { - t.beforeEach(ResetDBState); - - // empty scoreDB after - t.afterEach(async () => { - await MONGODB_KILL.scores.remove({}); - }); - - t.test("Single Queue Test", async (t) => { - // fake score doc - const res = await QueueScoreInsert({ - scoreID: "foo", - userID: 1, - } as unknown as ScoreDocument); - - t.equal( - res, - true, - "QueueScoreInsert should not insert a score when the queue is not full.", - ); - - // this is the best way to get the size of the queue - const flushSize = await InsertQueue(1); - - t.equal(flushSize, 1, "QueueScoreInsert should append the score to the queue."); - - const dbRes = await MONGODB_KILL.scores.find({ - scoreID: "foo", - }); - - t.equal( - dbRes.length, - 1, - "InsertQueue should insert all 1 members of the queue into the database.", - ); - - t.end(); - }); - - // flush queue just incase former test fails. - let r = await InsertQueue(1); - - t.test("Queue Overflow Test", async (t) => { - for (let i = 0; i < 499; i++) { - // eslint-disable-next-line no-await-in-loop - await QueueScoreInsert({ - scoreID: i, - chartID: "test", - userID: 1, - } as unknown as ScoreDocument); - } - - const overflowRes = await QueueScoreInsert({ - scoreID: "foo", - chartID: "test", - userID: 1, - } as unknown as ScoreDocument); - - t.equal( - overflowRes, - 500, - "Appending 500 items to the queue should result in them being inserted.", - ); - - const flushRes = await InsertQueue(1); - - t.equal(flushRes, 0, "The queue should now be empty."); - - const dbRes = await MONGODB_KILL.scores.find({ - scoreID: { $exists: true }, - chartID: "test", - }); - - t.equal( - dbRes.length, - 500, - "InsertQueue should insert all 500 members of the queue into the database.", - ); - - t.end(); - }); - - // flush queue just incase former test fails. - r = await InsertQueue(1); - - t.equal(r, 0, "Queue should be empty after test."); - - t.test("Queue Dedupe Test", async (t) => { - await QueueScoreInsert({ - scoreID: 1, - chartID: "foo", - userID: 1, - } as unknown as ScoreDocument); - const r2 = await QueueScoreInsert({ - scoreID: 1, - chartID: "foo", - userID: 1, - } as unknown as ScoreDocument); - - t.equal(r2, null, "Should return null when a duplicate scoreID is submitted"); - - const flushRes = await InsertQueue(1); - - t.equal(flushRes, 1, "Should flush 1 score document"); - - const dbRes = await MONGODB_KILL.scores.find({ - chartID: "foo", - }); - - t.equal(dbRes.length, 1, "Should only insert one document"); - }); - - t.test("Should give separate users separate queues.", async (t) => { - await QueueScoreInsert({ - scoreID: "1", - chartID: "foo", - userID: 1, - } as unknown as ScoreDocument); - await QueueScoreInsert({ - scoreID: "2", - chartID: "foo", - userID: 2, - } as unknown as ScoreDocument); - - const r1 = await InsertQueue(1); - - t.equal(r1, 1, "Queue for userID 1 should have length 1."); - - const r2 = await InsertQueue(2); - - t.equal(r2, 1, "Queue for userID 2 should also have length 1."); - - t.end(); - }); - - t.test("Should not throw if InsertQueue is called on an empty queue.", async (t) => { - try { - await InsertQueue(1); - t.pass("Did not throw when inserting an empty queue."); - } catch (err) { - t.fail((err as Error).message); - } - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/insert-score.test.ts b/typescript/server/src/lib/score-import/framework/score-importing/insert-score.test.ts new file mode 100644 index 000000000..a9ec7d00e --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/score-importing/insert-score.test.ts @@ -0,0 +1,168 @@ +import DB from "#services/pg/db"; +import { mkFakeScoreIIDXSP } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import { afterEach, describe, expect, it } from "vitest"; + +import { InsertQueue, QueueScoreInsert } from "./insert-score"; + +async function seedIidx511Chart() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +afterEach(async () => { + await DB.deleteFrom("score").execute(); +}); + +describe("QueueScoreInsert and InsertQueue", () => { + it("queues one score and flushes into Postgres", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + const chart = Testing511SPA; + + const doc = mkFakeScoreIIDXSP({ + userID: userId, + scoreID: "foo", + chartID: chart.chartID, + }); + + const q = QueueScoreInsert(doc, chart, null, true); + expect(q).toBe(true); + + const flushSize = await InsertQueue(userId); + expect(flushSize).toBe(1); + + const rows = await DB.selectFrom("score").select("id").where("id", "=", "foo").execute(); + expect(rows).toHaveLength(1); + }); + + it("auto-flushes when the queue reaches 500 items", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + const chart = Testing511SPA; + + for (let i = 0; i < 499; i++) { + const doc = mkFakeScoreIIDXSP({ + userID: userId, + scoreID: `s-${i}`, + chartID: chart.chartID, + }); + expect(QueueScoreInsert(doc, chart, null, true)).toBe(true); + } + + const overflowRes = await QueueScoreInsert( + mkFakeScoreIIDXSP({ + userID: userId, + scoreID: "foo", + chartID: chart.chartID, + }), + chart, + null, + true, + ); + + expect(overflowRes).toBe(500); + + const flushRes = await InsertQueue(userId); + expect(flushRes).toBe(0); + + const n = await DB.selectFrom("score") + .select("id") + .where("chart_id", "=", chart.chartID) + .execute(); + expect(n).toHaveLength(500); + }); + + it("returns null when the same scoreID is queued twice", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + const chart = Testing511SPA; + + const doc = mkFakeScoreIIDXSP({ + userID: userId, + scoreID: "dup", + chartID: chart.chartID, + }); + + expect(QueueScoreInsert(doc, chart, null, true)).toBe(true); + expect( + QueueScoreInsert( + mkFakeScoreIIDXSP({ + userID: userId, + scoreID: "dup", + chartID: chart.chartID, + }), + chart, + null, + true, + ), + ).toBe(null); + + expect(await InsertQueue(userId)).toBe(1); + + const rows = await DB.selectFrom("score") + .select("id") + .where("chart_id", "=", chart.chartID) + .execute(); + expect(rows).toHaveLength(1); + }); + + it("keeps separate queues per user", async () => { + await seedIidx511Chart(); + const { id: u1 } = await seedUser({ username: `isq_u1_${Date.now()}` }); + const { id: u2 } = await seedUser({ username: `isq_u2_${Date.now()}` }); + const chart = Testing511SPA; + + expect( + QueueScoreInsert( + mkFakeScoreIIDXSP({ userID: u1, scoreID: "1", chartID: chart.chartID }), + chart, + null, + true, + ), + ).toBe(true); + expect( + QueueScoreInsert( + mkFakeScoreIIDXSP({ userID: u2, scoreID: "2", chartID: chart.chartID }), + chart, + null, + true, + ), + ).toBe(true); + + expect(await InsertQueue(u1)).toBe(1); + expect(await InsertQueue(u2)).toBe(1); + }); + + it("does not throw when flushing an empty queue", async () => { + const { id: userId } = await seedUser({ username: `isq_empty_${Date.now()}` }); + await expect(InsertQueue(userId)).resolves.toBe(0); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/insert-score.ts b/typescript/server/src/lib/score-import/framework/score-importing/insert-score.ts index bdc6dddcc..618a14d01 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/insert-score.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/insert-score.ts @@ -1,4 +1,4 @@ -import type { integer, MONGO_ChartDocument, MONGO_ScoreDocument } from "tachi-common"; +import type { ChartDocument, integer, ScoreDocument } from "tachi-common"; import { log } from "#lib/log/log"; import { mongoScoreDocumentToNewScoreRow } from "#lib/score-import/framework/pg/mongo-score-to-pg"; @@ -7,7 +7,7 @@ import DB from "#services/pg/db"; const MAX_PIPELINE_LENGTH = 500; interface PendingInsert { - score: MONGO_ScoreDocument; + score: ScoreDocument; chartIdPg: string; committed: boolean; importId: string | null; @@ -86,8 +86,8 @@ export async function InsertQueue(userID: integer) { * the score provided is already loaded. */ export function QueueScoreInsert( - score: MONGO_ScoreDocument, - chart: MONGO_ChartDocument, + score: ScoreDocument, + chart: ChartDocument, importId: string | null, committed: boolean, ) { diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-id.oldtest.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-id.oldtest.ts deleted file mode 100644 index fcd339d4e..000000000 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-id.oldtest.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { dmf } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { - Testing511SPA, - TestingIIDXSPDryScore, - TestingSDVXSingleDryScore, -} from "#test-utils/test-data"; -import t from "tap"; - -import type { DryScoreData } from "../common/types"; - -import { CreateScoreID, GetWithScoreID } from "./score-id"; - -t.test("#GetWithScoreID", async (t) => { - await ResetDBState(); - - const sc = await GetWithScoreID("TESTING_SCORE_ID"); - - t.not(sc, null, "Return a score for a valid score id"); - - const nl = await GetWithScoreID("INVALID SCORE ID"); - - t.equal(nl, null, "Should return null for score IDs it cannot resolve"); - - t.end(); -}); - -t.test("#CreateScoreID", (t) => { - const scoreID = CreateScoreID("iidx:SP", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); - - t.match( - scoreID, - /^T[0-9a-f]{40}/u, - "Should return an T followed by 40 characters of lowercase hex as scoreID.", - ); - - t.not( - scoreID, - CreateScoreID("iidx:SP", 2, TestingIIDXSPDryScore, Testing511SPA.chartID), - "Hash should be affected by userID.", - ); - - t.equal( - scoreID, - CreateScoreID("iidx:SP", 1, TestingIIDXSPDryScore, Testing511SPA.chartID), - "ScoreIDs should be consistently generatable", - ); - - t.equal( - scoreID, - CreateScoreID( - "iidx:SP", - 1, - dmf(TestingIIDXSPDryScore, { - scoreData: { - optional: { - bp: 293, - }, - judgements: { - bad: 129, - }, - } as DryScoreData<"iidx:SP">, - }), - Testing511SPA.chartID, - ), - "ScoreIDs should only be affected by score metrics.", - ); - - t.not( - scoreID, - CreateScoreID( - "iidx:SP", - 1, - dmf(TestingIIDXSPDryScore, { - scoreData: { - score: 0, - } as DryScoreData<"iidx:SP">, - }), - Testing511SPA.chartID, - ), - "ScoreIDs should not produce the same value if a provided metric is different.", - ); - - const sdvxScoreID = CreateScoreID( - "sdvx:Single", - 1, - TestingSDVXSingleDryScore, - Testing511SPA.chartID, - ); - - t.not( - sdvxScoreID, - CreateScoreID( - "sdvx:Single", - 1, - dmf(TestingSDVXSingleDryScore, { - scoreData: { optional: { exScore: 1 } }, - }), - Testing511SPA.chartID, - ), - "Hash should be affected by specific optionalMetrics.", - ); - - t.not( - CreateScoreID( - "sdvx:Single", - 1, - dmf(TestingSDVXSingleDryScore, { - scoreData: { optional: { exScore: 1 } }, - }), - Testing511SPA.chartID, - ), - CreateScoreID( - "sdvx:Single", - 1, - dmf(TestingSDVXSingleDryScore, { - scoreData: { optional: { exScore: 100 } }, - }), - Testing511SPA.chartID, - ), - "Hash should be affected by specific optionalMetrics.", - ); - - t.equal( - CreateScoreID( - "sdvx:Single", - 1, - dmf(TestingSDVXSingleDryScore, { - scoreData: { optional: { exScore: 1, fast: 18 } }, - }), - Testing511SPA.chartID, - ), - CreateScoreID( - "sdvx:Single", - 1, - dmf(TestingSDVXSingleDryScore, { - scoreData: { optional: { exScore: 1 } }, - }), - Testing511SPA.chartID, - ), - "Hash should not be affected by irrelevant optionalMetrics.", - ); - - t.equal( - CreateScoreID( - "sdvx:Single", - 1, - dmf(TestingSDVXSingleDryScore, { - scoreData: { optional: { exScore: undefined } }, - }), - Testing511SPA.chartID, - ), - CreateScoreID( - "sdvx:Single", - 1, - dmf(TestingSDVXSingleDryScore, { - scoreData: { optional: { exScore: null } }, - }), - Testing511SPA.chartID, - ), - "Null and Undefined should be the same for optional metrics", - ); - - t.end(); -}); - -t.test("#ScoreID Canary", (t) => { - const scoreID = CreateScoreID("iidx:SP", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); - - t.equal( - scoreID, - "T5d669c4d5d6ca80761e87698acd77c51d2bed95b64ab76e65952dbca7c26bc81", - "ScoreID **SHOULD BE DETERMINISTIC**. A change to the ScoreID algorithm is **a major breaking change.**", - ); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts new file mode 100644 index 000000000..fd9be2f49 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts @@ -0,0 +1,163 @@ +import { dmf } from "#test-utils/misc"; +import { + Testing511SPA, + TestingIIDXSPDryScore, + TestingSDVXSingleDryScore, +} from "#test-utils/test-data"; +import { describe, expect, it } from "vitest"; + +import type { DryScoreData } from "../common/types"; + +import { CreateScoreID } from "./score-id"; + +describe("CreateScoreID", () => { + it("returns a T-prefixed 40-hex score id", () => { + const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + + expect(scoreID).toMatch(/^T[0-9a-f]{40}/u); + }); + + it("varies with user id", () => { + const a = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + const b = CreateScoreID("iidx-sp", 2, TestingIIDXSPDryScore, Testing511SPA.chartID); + + expect(a).not.toBe(b); + }); + + it("is stable for the same inputs", () => { + const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + + expect(scoreID).toBe( + CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID), + ); + }); + + it("only incorporates score metrics that affect the checksum", () => { + const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + + expect(scoreID).toBe( + CreateScoreID( + "iidx-sp", + 1, + dmf(TestingIIDXSPDryScore, { + scoreData: { + optional: { + bp: 293, + }, + judgements: { + bad: 129, + }, + } as DryScoreData<"iidx-sp">, + }), + Testing511SPA.chartID, + ), + ); + }); + + it("changes when a provided metric changes", () => { + const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + + expect(scoreID).not.toBe( + CreateScoreID( + "iidx-sp", + 1, + dmf(TestingIIDXSPDryScore, { + scoreData: { + score: 0, + } as DryScoreData<"iidx-sp">, + }), + Testing511SPA.chartID, + ), + ); + }); + + it("incorporates optional metrics that are part of the score id", () => { + const sdvxScoreID = CreateScoreID( + "sdvx", + 1, + TestingSDVXSingleDryScore, + Testing511SPA.chartID, + ); + + expect(sdvxScoreID).not.toBe( + CreateScoreID( + "sdvx", + 1, + dmf(TestingSDVXSingleDryScore, { + scoreData: { optional: { exScore: 1 } }, + }), + Testing511SPA.chartID, + ), + ); + + expect( + CreateScoreID( + "sdvx", + 1, + dmf(TestingSDVXSingleDryScore, { + scoreData: { optional: { exScore: 1 } }, + }), + Testing511SPA.chartID, + ), + ).not.toBe( + CreateScoreID( + "sdvx", + 1, + dmf(TestingSDVXSingleDryScore, { + scoreData: { optional: { exScore: 100 } }, + }), + Testing511SPA.chartID, + ), + ); + }); + + it("ignores optional metrics that are not part of the score id", () => { + expect( + CreateScoreID( + "sdvx", + 1, + dmf(TestingSDVXSingleDryScore, { + scoreData: { optional: { exScore: 1, fast: 18 } }, + }), + Testing511SPA.chartID, + ), + ).toBe( + CreateScoreID( + "sdvx", + 1, + dmf(TestingSDVXSingleDryScore, { + scoreData: { optional: { exScore: 1 } }, + }), + Testing511SPA.chartID, + ), + ); + }); + + it("treats null and undefined the same for optional metrics in the score id", () => { + expect( + CreateScoreID( + "sdvx", + 1, + dmf(TestingSDVXSingleDryScore, { + scoreData: { optional: { exScore: undefined } }, + }), + Testing511SPA.chartID, + ), + ).toBe( + CreateScoreID( + "sdvx", + 1, + dmf(TestingSDVXSingleDryScore, { + scoreData: { optional: { exScore: null } }, + }), + Testing511SPA.chartID, + ), + ); + }); + + it("is deterministic (canary — changing the algorithm is a breaking change)", () => { + const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + + expect(scoreID).toBe("T5d669c4d5d6ca80761e87698acd77c51d2bed95b64ab76e65952dbca7c26bc81"); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts index 28639e05a..802385be2 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts @@ -2,11 +2,11 @@ import type { KtLogger } from "#lib/log/log"; import fjsh from "fast-json-stable-hash"; import { - GetGPTConfig, - type GPTString, + GetGameConfig, type integer, type MongoOptionalMetrics, type MongoProvidedMetrics, + type V3Game, } from "tachi-common"; import type { DryScore } from "../common/types"; @@ -16,7 +16,7 @@ import type { DryScore } from "../common/types"; * This is used to deduplicate repeated scores. */ export function CreateScoreID( - gptString: GPTString, + game: V3Game, userID: integer, dryScore: DryScore, chartID: string, @@ -24,18 +24,18 @@ export function CreateScoreID( ) { const elements: Record = { userID, chartID }; - const gptConfig = GetGPTConfig(gptString); + const gameConfig = GetGameConfig(game); - for (const m of Object.keys(gptConfig.providedMetrics)) { - const metric = m as keyof MongoProvidedMetrics[GPTString]; + for (const m of Object.keys(gameConfig.providedMetrics)) { + const metric = m as keyof MongoProvidedMetrics[V3Game]; elements[metric] = dryScore.scoreData[metric]; } // Also include optional metrics in the checksum if they should be // part of the scoreID. - for (const [m, conf] of Object.entries(gptConfig.optionalMetrics)) { - const metric = m as keyof MongoOptionalMetrics[GPTString]; + for (const [m, conf] of Object.entries(gameConfig.optionalMetrics)) { + const metric = m as keyof MongoOptionalMetrics[V3Game]; if (conf.partOfScoreID) { elements[`optional.${metric}`] = dryScore.scoreData.optional[metric] ?? null; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-import-error.oldtest.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-import-error.oldtest.ts deleted file mode 100644 index 151412f45..000000000 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-import-error.oldtest.ts +++ /dev/null @@ -1,12 +0,0 @@ -import t from "tap"; - -import ScoreImportFatalError from "./score-import-error"; - -t.test("#new ScoreImportFatalError()", (t) => { - const err = new ScoreImportFatalError(500, "error message"); - - t.equal(err.statusCode, 500, "Should store the status code passed to it."); - t.equal(err.message, "error message", "Should store description as the error message."); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-import-error.test.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-import-error.test.ts new file mode 100644 index 000000000..d82c9c56a --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-import-error.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import ScoreImportFatalError from "./score-import-error"; + +describe("ScoreImportFatalError", () => { + it("stores status code and message", () => { + const err = new ScoreImportFatalError(500, "error message"); + + expect(err.statusCode).toBe(500); + expect(err.message).toBe("error message"); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts index ef95a3504..314caae8f 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts @@ -1,4 +1,8 @@ import type { KtLogger } from "#lib/log/log"; +import type { + ConverterFunction, + ImportInputParser, +} from "#lib/score-import/import-types/common/types"; import type { ScoreImportJob } from "#lib/score-import/worker/types"; import { LoadImportDocumentById } from "#lib/db-formats/import-document"; @@ -9,6 +13,7 @@ import { ensureImportStub, } from "#lib/score-import/framework/pg/ensure-import-stub"; import { finalizeImportToPostgres } from "#lib/score-import/framework/pg/finalize-import-pg"; +import { Converters } from "#lib/score-import/import-types/converters"; import { observeScoreImportDuration } from "#server/prometheus"; import DB from "#services/pg/db"; import { GetMillisecondsSince } from "#utils/misc"; @@ -16,18 +21,18 @@ import { GetUserWithID } from "#utils/user"; import { type GameGroup, GetGameGroupConfig, + type GoalImportInfo, type ImportProcessingInfo, type ImportTypes, type integer, - type MONGO_UserDocument, - type Playtype, + type QuestImportInfo, + type UserDocument, + type V3Game, } from "tachi-common"; -import type { ConverterFunction, ImportInputParser } from "../../import-types/common/types"; import type { ClassProvider } from "../calculated-data/types"; -import type { ChartIDPlaytypeMap, ScorePlaytypeMap } from "../common/types"; +import type { ChartIDGameMap, ScoreGameMap } from "../common/types"; -import { Converters } from "../../import-types/converters"; import { InternalFailure } from "../common/converter-failures"; import { CreateScoreLogger } from "../common/import-logger"; import { GetAndUpdateUsersGoals } from "../goals/goals"; @@ -48,7 +53,7 @@ export default async function ScoreImportMain( userID: integer, userIntent: boolean, importType: ImportTypes, - InputParser: ImportInputParser, + InputParser: ImportInputParser, importID: string, providedLogger?: KtLogger, job?: ScoreImportJob, @@ -100,7 +105,7 @@ export default async function ScoreImportMain( const { iterable, context, - game, + gameGroup: game, classProvider: classProvider, } = await InputParser(log); @@ -169,7 +174,7 @@ export default async function ScoreImportMain( } const { - playtypes, + games, scoreIDs, errors, sessionInfo, @@ -203,7 +208,7 @@ export default async function ScoreImportMain( // Create and Save an import document to the database, and finish everything up! await DB.transaction().execute(async (trx) => { await finalizeImportToPostgres(trx, { - importId: importID, + importID: importID, userId: user.id, gameGroup: game, importType, @@ -211,7 +216,8 @@ export default async function ScoreImportMain( service: "Unknown", timeStartedMs: timeStarted, timeFinishedMs: timeFinished, - playtypes, + games, + scoreCount: scoreIDs.length, errors, classDeltas, createdSessions: sessionInfo, @@ -255,10 +261,10 @@ export default async function ScoreImportMain( */ export async function HandlePostImportSteps( importInfo: Array, - user: MONGO_UserDocument, - importType: ImportTypes, - game: GameGroup, - classProvider: ClassProvider | null, + user: UserDocument, + _importType: ImportTypes, + gameGroup: GameGroup, + classProvider: ClassProvider | null, log: KtLogger, job: ScoreImportJob | undefined, _importId: string, @@ -267,7 +273,7 @@ export async function HandlePostImportSteps( // ImportInfo is a relatively complex structure. We need some information from it for subsequent steps // such as the list of chartIDs involved in this import. const importParseTimeStart = process.hrtime.bigint(); - const { scorePlaytypeMap, errors, scoreIDs, chartIDs } = ParseImportInfo(importInfo); + const { scoreGameMap, errors, scoreIDs, chartIDs } = ParseImportInfo(importInfo); const importParseTime = GetMillisecondsSince(importParseTimeStart); const importParseTimeRel = importParseTime / Math.max(1, importInfo.length); @@ -280,7 +286,7 @@ export async function HandlePostImportSteps( // We create (or update existing) sessions here. This uses the aforementioned parsed import info // to determine what goes where. const sessionTimeStart = process.hrtime.bigint(); - const sessionInfo = await CreateSessions(user.id, game, scorePlaytypeMap, log); + const sessionInfo = await CreateSessions(user.id, scoreGameMap, log); const sessionTime = GetMillisecondsSince(sessionTimeStart); const sessionTimeRel = sessionTime / Math.max(1, sessionInfo.length); @@ -289,7 +295,7 @@ export async function HandlePostImportSteps( void SetJobProgress(job, "Processing scores and updating PBs."); - const playtypes = Object.keys(scorePlaytypeMap) as Array; + const games = Object.keys(scoreGameMap) as Array; // --- 5. PersonalBests --- // We want to keep an updated reference of a users best score on a given chart. @@ -299,15 +305,15 @@ export async function HandlePostImportSteps( // processing PBs is a playtype-specific action. As such, we need to split chartIDs // accordingly - const chartIDsSeparatedByPlaytype: ChartIDPlaytypeMap = {}; + const chartIDsSeparatedByGame: ChartIDGameMap = {}; - for (const [playtype, scores] of Object.entries(scorePlaytypeMap)) { - chartIDsSeparatedByPlaytype[playtype as Playtype] = new Set(scores.map((e) => e.chartID)); + for (const [game, scores] of Object.entries(scoreGameMap)) { + chartIDsSeparatedByGame[game as V3Game] = new Set(scores.map((e) => e.chartID)); } await Promise.all( - Object.entries(chartIDsSeparatedByPlaytype).map(([playtype, cids]) => - ProcessPBs(game, playtype as Playtype, user.id, cids, log), + Object.entries(chartIDsSeparatedByGame).map(([game, chartIDs]) => + ProcessPBs(game as V3Game, user.id, chartIDs, log), ), ); @@ -321,7 +327,7 @@ export async function HandlePostImportSteps( void SetJobProgress(job, "Updating profile statistics."); const ugsTimeStart = process.hrtime.bigint(); - const classDeltas = await UpdateUsersGameStats(game, playtypes, user.id, classProvider, log); + const classDeltas = await UpdateUsersGameStats(gameGroup, user.id, classProvider, log); const ugsTime = GetMillisecondsSince(ugsTimeStart); @@ -330,7 +336,11 @@ export async function HandlePostImportSteps( void SetJobProgress(job, "Updating Goals."); const goalTimeStart = process.hrtime.bigint(); - const goalInfo = await GetAndUpdateUsersGoals(game, user.id, chartIDs, log); + const goalInfo: Array = []; + for (const game of GetGameGroupConfig(gameGroup).games) { + // eslint-disable-next-line no-await-in-loop + goalInfo.push(...(await GetAndUpdateUsersGoals(game, user.id, chartIDs, log))); + } const goalTime = GetMillisecondsSince(goalTimeStart); @@ -339,7 +349,14 @@ export async function HandlePostImportSteps( void SetJobProgress(job, "Updating Quests."); const questTimeStart = process.hrtime.bigint(); - const questInfo = await UpdateUsersQuests(goalInfo, game, playtypes, user.id, log); + const questInfo: Array = []; + for (const game of GetGameGroupConfig(gameGroup).games) { + // TODO(zk): Goal and quest "evaluation" can go - it should be automatic in the db + // by marking things as stale/dirty/whatever. + + // eslint-disable-next-line no-await-in-loop + questInfo.push(...(await UpdateUsersQuests(goalInfo, game, user.id, log))); + } const questTime = GetMillisecondsSince(questTimeStart); @@ -349,7 +366,7 @@ export async function HandlePostImportSteps( classDeltas, questInfo, goalInfo, - playtypes, + games, scoreIDs, errors, sessionInfo, @@ -373,23 +390,15 @@ export async function HandlePostImportSteps( * @returns A flattened array of ClassDeltas */ async function UpdateUsersGameStats( - game: GameGroup, - modifiedPlaytypes: Array, + gameGroup: GameGroup, userID: integer, - classProvider: ClassProvider | null, + classProvider: ClassProvider | null, log: KtLogger, ) { const promises = []; - // Instead of using the provided playtypes, run the classProvider on all - // playtypes. This should only happen if a classProvider is provided, and is - // a hack fix for things like #480. - const allPlaytypes = GetGameGroupConfig(game).playtypes; - - const playtypes = classProvider ? allPlaytypes : modifiedPlaytypes; - - for (const pt of playtypes) { - promises.push(UpdateUsersGamePlaytypeStats(game, pt, userID, classProvider, log)); + for (const game of GetGameGroupConfig(gameGroup).games) { + promises.push(UpdateUsersGamePlaytypeStats(game, userID, classProvider, log)); } const r = await Promise.all(promises); @@ -405,7 +414,7 @@ async function UpdateUsersGameStats( * on their playtype. */ function ParseImportInfo(importInfo: Array) { - const scorePlaytypeMap: ScorePlaytypeMap = {}; + const scoreGameMap: ScoreGameMap = {}; const scoreIDs = []; const errors = []; @@ -416,17 +425,19 @@ function ParseImportInfo(importInfo: Array) { scoreIDs.push(info.content.score.scoreID); chartIDs.add(info.content.score.chartID); - if (scorePlaytypeMap[info.content.score.playtype]) { - scorePlaytypeMap[info.content.score.playtype]!.push(info.content.score); + const v3Game = info.content.score.game; + + if (scoreGameMap[v3Game]) { + scoreGameMap[v3Game]!.push(info.content.score); } else { - scorePlaytypeMap[info.content.score.playtype] = [info.content.score]; + scoreGameMap[v3Game] = [info.content.score]; } } else { errors.push({ type: info.type, message: info.message }); } } - return { scoreIDs, errors, scorePlaytypeMap, chartIDs }; + return { scoreIDs, errors, scoreGameMap: scoreGameMap, chartIDs }; } function SetJobProgress(job: ScoreImportJob | undefined, description: string) { diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.oldtest.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.oldtest.ts deleted file mode 100644 index fa681eb3c..000000000 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.oldtest.ts +++ /dev/null @@ -1,9 +0,0 @@ -import t from "tap"; - -t.todo("#ImportAllIterableData", (t) => { - t.end(); -}); - -t.todo("#ImportIterableDatapoint", (t) => { - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.test.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.test.ts new file mode 100644 index 000000000..1a019abee --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "vitest"; + +describe("score-importing helpers (ported from score-importing.oldtest.ts)", () => { + it.todo("ImportAllIterableData"); + it.todo("ImportIterableDatapoint"); +}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts index a229af1c6..4dea17405 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts @@ -1,3 +1,7 @@ +import type { + ConverterFnSuccessReturn, + ConverterFunction, +} from "#lib/score-import/import-types/common/types"; import type { ScoreImportJob } from "#lib/score-import/worker/types"; import { AppendLogCtx, type KtLogger } from "#lib/log/log"; @@ -5,18 +9,15 @@ import { mongoScoreDocumentToNewScoreRow } from "#lib/score-import/framework/pg/ import DB from "#services/pg/db"; import { ClassToObject } from "#utils/misc"; import { + type ChartDocument, type GameGroup, - GetGPTString, type ImportProcessingInfo, type ImportTypes, type integer, - type MONGO_ChartDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, - MongoChartLegacyId, + type ScoreDocument, + type SongDocument, } from "tachi-common"; -import type { ConverterFnSuccessReturn, ConverterFunction } from "../../import-types/common/types"; import type { DryScore } from "../common/types"; import { @@ -344,23 +345,17 @@ export async function ProcessSuccessfulConverterReturn( async function HydrateCheckAndInsertScore( userID: integer, dryScore: DryScore, - chart: MONGO_ChartDocument, - song: MONGO_SongDocument, + chart: ChartDocument, + song: SongDocument, blacklist: Array, importLog: KtLogger, importId: string | null, force = false, directCommit = false, -): Promise { - const gptString = GetGPTString(dryScore.game, chart.playtype); +): Promise { + const game = dryScore.game; - const scoreID = CreateScoreID( - gptString, - userID, - dryScore, - MongoChartLegacyId(chart), - importLog, - ); + const scoreID = CreateScoreID(game, userID, dryScore, chart.chartID, importLog); // sub-context thelog so the below logs are more accurate const log = AppendLogCtx(scoreID, importLog); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/validate-score.oldtest.ts b/typescript/server/src/lib/score-import/framework/score-importing/validate-score.oldtest.ts deleted file mode 100644 index 97037f60f..000000000 --- a/typescript/server/src/lib/score-import/framework/score-importing/validate-score.oldtest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ONE_DAY } from "#lib/constants/time"; -import { Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; -import t from "tap"; - -import { ValidateScore } from "./validate-score"; - -t.test("#ValidateScore", (t) => { - t.test("Should reject scores in the future", (t) => { - t.throws( - () => { - ValidateScore( - { ...TestingIIDXSPScore, timeAchieved: Date.now() + ONE_DAY * 2 }, - Testing511SPA, - ); - }, - { - message: "Invalid timestamp: score happens in the future.", - }, - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/validate-score.test.ts b/typescript/server/src/lib/score-import/framework/score-importing/validate-score.test.ts new file mode 100644 index 000000000..657a98f17 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/score-importing/validate-score.test.ts @@ -0,0 +1,24 @@ +import { ONE_DAY } from "#lib/constants/time"; +import { InvalidScoreFailure } from "#lib/score-import/framework/common/converter-failures"; +import { Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; +import { describe, expect, it } from "vitest"; + +import { ValidateScore } from "./validate-score"; + +describe("ValidateScore", () => { + it("rejects scores in the future", () => { + expect(() => + ValidateScore( + { ...TestingIIDXSPScore, timeAchieved: Date.now() + ONE_DAY * 2 }, + Testing511SPA, + ), + ).toThrow(InvalidScoreFailure); + + expect(() => + ValidateScore( + { ...TestingIIDXSPScore, timeAchieved: Date.now() + ONE_DAY * 2 }, + Testing511SPA, + ), + ).toThrow("Invalid timestamp: score happens in the future."); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/validate-score.ts b/typescript/server/src/lib/score-import/framework/score-importing/validate-score.ts index f51402938..9ed92a1c1 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/validate-score.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/validate-score.ts @@ -1,15 +1,9 @@ import type { ConfScoreMetric } from "tachi-common/types/metrics"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { RunValidators } from "#game-implementations/games/_common"; import { ONE_HOUR } from "#lib/constants/time"; -import { - GetGPTConfig, - GetGPTString, - type GPTString, - type MONGO_ChartDocument, - type MONGO_ScoreDocument, -} from "tachi-common"; +import { type ChartDocument, GetGameConfig, type ScoreDocument, type V3Game } from "tachi-common"; import { InvalidScoreFailure } from "../common/converter-failures"; @@ -18,7 +12,7 @@ import { InvalidScoreFailure } from "../common/converter-failures"; * * @returns nothing. This will throw an InvalidScoreFailure on error. */ -export function ValidateScore(score: MONGO_ScoreDocument, chart: MONGO_ChartDocument): void { +export function ValidateScore(score: ScoreDocument, chart: ChartDocument): void { const leniency = ONE_HOUR * 24; if (score.timeAchieved !== null && score.timeAchieved > Date.now() + leniency) { @@ -28,17 +22,17 @@ export function ValidateScore(score: MONGO_ScoreDocument, chart: MONGO_ChartDocu ValidateScoreGameSpecific(score, chart); } -function ValidateScoreGameSpecific(score: MONGO_ScoreDocument, chart: MONGO_ChartDocument): void { - const gptString = GetGPTString(score.game, score.playtype); - const gptConfig = GetGPTConfig(gptString); - const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gptString]; +function ValidateScoreGameSpecific(score: ScoreDocument, chart: ChartDocument): void { + const game = score.game; + const gameConfig = GetGameConfig(game); + const impl = GAME_IMPLEMENTATIONS[game]; const errs: Array = []; ValidateMetrics( errs, - gptConfig.providedMetrics, - gptString, + gameConfig.providedMetrics, + game, score, chart, // @ts-expect-error ughhh @@ -46,8 +40,8 @@ function ValidateScoreGameSpecific(score: MONGO_ScoreDocument, chart: MONGO_Char ); ValidateMetrics( errs, - gptConfig.derivedMetrics, - gptString, + gameConfig.derivedMetrics, + game, score, chart, // @ts-expect-error ughhh @@ -56,8 +50,8 @@ function ValidateScoreGameSpecific(score: MONGO_ScoreDocument, chart: MONGO_Char ValidateMetrics( errs, - gptConfig.optionalMetrics, - gptString, + gameConfig.optionalMetrics, + game, score, chart, // @ts-expect-error ughhh @@ -65,7 +59,7 @@ function ValidateScoreGameSpecific(score: MONGO_ScoreDocument, chart: MONGO_Char true, ); - const moreErrors = RunValidators(gptImpl.scoreValidators as any, score, chart); + const moreErrors = RunValidators(impl.scoreValidators as any, score, chart); if (moreErrors) { errs.push(...moreErrors); @@ -82,13 +76,13 @@ ${errs.join("\n")}`); function ValidateMetrics( errs: Array, metrics: Record, - gptString: GPTString, - score: MONGO_ScoreDocument, - chart: MONGO_ChartDocument, - valueGetter: (s: MONGO_ScoreDocument, metric: string) => any, + game: V3Game, + score: ScoreDocument, + chart: ChartDocument, + valueGetter: (s: ScoreDocument, metric: string) => any, optional?: boolean, ) { - const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gptString]; + const impl = GAME_IMPLEMENTATIONS[game]; for (const [metric, conf] of Object.entries(metrics)) { const scoreVal: any = valueGetter(score, metric); @@ -126,7 +120,7 @@ function ValidateMetrics( if (conf.chartDependentMax) { // @ts-expect-error hack, this is fine. don't worry. - err = gptImpl.chartSpecificValidators[metric](scoreVal, chart); + err = impl.chartSpecificValidators[metric](scoreVal, chart); } else { err = conf.validate(scoreVal); } diff --git a/typescript/server/src/lib/score-import/framework/sessions/name-generation.oldtest.ts b/typescript/server/src/lib/score-import/framework/sessions/name-generation.oldtest.ts deleted file mode 100644 index 2266d6e81..000000000 --- a/typescript/server/src/lib/score-import/framework/sessions/name-generation.oldtest.ts +++ /dev/null @@ -1,11 +0,0 @@ -import t from "tap"; - -import { GenerateRandomSessionName } from "./name-generation"; - -t.test("#GenerateRandomSessionName", (t) => { - const res = GenerateRandomSessionName(); - - t.type(res, "string", "Should return a string."); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/sessions/name-generation.test.ts b/typescript/server/src/lib/score-import/framework/sessions/name-generation.test.ts new file mode 100644 index 000000000..0ebcb38d7 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/sessions/name-generation.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { GenerateRandomSessionName } from "./name-generation"; + +describe("GenerateRandomSessionName", () => { + it("returns a string", () => { + const res = GenerateRandomSessionName(); + + expect(typeof res).toBe("string"); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/sessions/sessions.oldtest.ts b/typescript/server/src/lib/score-import/framework/sessions/sessions.oldtest.ts deleted file mode 100644 index 38c51b545..000000000 --- a/typescript/server/src/lib/score-import/framework/sessions/sessions.oldtest.ts +++ /dev/null @@ -1,368 +0,0 @@ -import type { ScoreDocument, SessionDocument, UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { TestingIIDXSPScore, TestingIIDXSPScorePB } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import { CreateScoreLogger } from "../common/import-logger"; -import { CreateSessions, LoadScoresIntoSessions } from "./sessions"; - -const log = CreateScoreLogger( - { username: "test_zkldi", id: 1 } as UserDocument, - "foo", - "ir/direct-manual", -); - -t.test("#CreateSessions", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.sessions.remove({})); - - t.test("Should compose sessions from one timestamped score provided.", async (t) => { - const res = await CreateSessions(1, "iidx", { SP: [TestingIIDXSPScore] }, log); - - t.match(res, [ - { - sessionID: /^Q[a-f0-9]{40}$/u, - type: "Created", - }, - ]); - - t.equal(res.length, 1); - - const session = await MONGODB_KILL.sessions.findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - const sessionCount = await MONGODB_KILL.sessions.count({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(sessionCount, 1); - - t.hasStrict(session, { - userID: 1, - // name: "adjective1 adjective2 noun1", - sessionID: res[0]?.sessionID, - desc: null, - game: "iidx", - playtype: "SP", - highlight: false, - scoreIDs: ["TESTING_SCORE_ID"], - - // timeInserted: 1622289329729, - timeStarted: 1619454485988, - timeEnded: 1619454485988, - calculatedData: {}, - }); - - t.end(); - }); - - t.test("Should not compose sessions from untimestamped scores.", async (t) => { - const res = await CreateSessions( - 1, - "iidx", - { SP: [deepmerge(TestingIIDXSPScore, { timeAchieved: null })] }, - log, - ); - - t.strictSame(res, []); - - const sessionCount = await MONGODB_KILL.sessions.count({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(sessionCount, 0); - - t.end(); - }); - - t.test("Should compose sessions for multiple playtypes.", async (t) => { - const res = await CreateSessions( - 1, - "iidx", - { DP: [deepmerge(TestingIIDXSPScore, { playtype: "DP" })], SP: [TestingIIDXSPScore] }, - log, - ); - - t.equal(res.length, 2); - - const sessionCount = await MONGODB_KILL.sessions.count({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(sessionCount, 1); - - const sessionCountDP = await MONGODB_KILL.sessions.count({ - userID: 1, - game: "iidx", - playtype: "DP", - }); - - t.equal(sessionCountDP, 1); - - t.end(); - }); - t.end(); -}); - -// just an arbitrary start point for our sessions as an offset -const start = 1619454485988; - -t.test("#LoadScoresIntoSessions", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.sessions.remove({})); - - t.test("Should create sessions from scores.", async (t) => { - const res = await LoadScoresIntoSessions( - 1, - [null, start, start + 1000, start + 2000, start + 3000].map((e, i) => - deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` }), - ) as Array, - "iidx", - "SP", - log, - ); - - t.match(res, [ - { - sessionID: /^Q[a-f0-9]{40}$/u, - type: "Created", - }, - ]); - - const sessions = await MONGODB_KILL.sessions.find({ - game: "iidx", - playtype: "SP", - userID: 1, - }); - - t.equal(sessions.length, 1); - - t.strictSame(sessions[0]?.scoreIDs, [ - "SCORE_ID_1", - "SCORE_ID_2", - "SCORE_ID_3", - "SCORE_ID_4", - ]); - - t.end(); - }); - - t.test("Should sort scores before creating sessions.", async (t) => { - const res = await LoadScoresIntoSessions( - 1, - [null, start, start - 4000, start + 2000, start - 3000, null, start + 1000].map( - (e, i) => - deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` }), - ) as Array, - "iidx", - "SP", - log, - ); - - t.match(res, [ - { - sessionID: /^Q[a-f0-9]{40}$/u, - type: "Created", - }, - ]); - - const sessions = await MONGODB_KILL.sessions.find({ - game: "iidx", - playtype: "SP", - userID: 1, - }); - - t.equal(sessions.length, 1); - - t.strictSame(sessions[0]?.scoreIDs, [ - "SCORE_ID_2", - "SCORE_ID_4", - "SCORE_ID_1", - "SCORE_ID_6", - "SCORE_ID_3", - ]); - - t.end(); - }); - - const TWO_HOURS = 1000 * 60 * 60 * 2; - - t.test("Should create multiple sessions if scores are far apart.", async (t) => { - const res = await LoadScoresIntoSessions( - 1, - [ - null, - start, - start + 1000, - start + 2000, - start + TWO_HOURS + 4000, - start + TWO_HOURS + 5000, - start + TWO_HOURS + 6000, - ].map((e, i) => - deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` }), - ) as Array, - "iidx", - "SP", - log, - ); - - t.match(res, [ - { - sessionID: /^Q[a-f0-9]{40}$/u, - type: "Created", - }, - { - sessionID: /^Q[a-f0-9]{40}$/u, - type: "Created", - }, - ]); - - const sessions = await MONGODB_KILL.sessions.find({ - game: "iidx", - playtype: "SP", - userID: 1, - }); - - t.equal(sessions.length, 2); - - t.strictSame(sessions[0]?.scoreIDs, ["SCORE_ID_1", "SCORE_ID_2", "SCORE_ID_3"]); - t.strictSame(sessions[1]?.scoreIDs, ["SCORE_ID_4", "SCORE_ID_5", "SCORE_ID_6"]); - - t.end(); - }); - - t.test("Should append to existing sessions.", async (t) => { - await MONGODB_KILL.sessions.insert({ - sessionID: "EXAMPLE_SESSION_ID", - userID: 1, - game: "iidx", - playtype: "SP", - timeStarted: start, - timeEnded: start, - scoreIDs: ["EXAMPLE_SCORE_ID"], - } as SessionDocument); - - const res = await LoadScoresIntoSessions( - 1, - [null, start, start + 1000, start + 2000].map((e, i) => - deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` }), - ) as Array, - "iidx", - "SP", - log, - ); - - t.strictSame(res, [ - { - sessionID: "EXAMPLE_SESSION_ID", - type: "Appended", - }, - ]); - - const sessions = await MONGODB_KILL.sessions.find({ - game: "iidx", - playtype: "SP", - userID: 1, - }); - - t.equal(sessions.length, 1); - - t.strictSame(sessions[0]?.scoreIDs, [ - "EXAMPLE_SCORE_ID", - "SCORE_ID_1", - "SCORE_ID_2", - "SCORE_ID_3", - ]); - - t.equal(sessions[0]?.timeEnded, start + 2000); - - t.end(); - }); - - t.test("Should change the timeStarted of existing sessions if prepending.", async (t) => { - await MONGODB_KILL.sessions.insert({ - sessionID: "EXAMPLE_SESSION_ID", - userID: 1, - game: "iidx", - playtype: "SP", - timeStarted: start, - timeEnded: start, - scoreIDs: ["EXAMPLE_SCORE_ID"], - } as SessionDocument); - - const res = await LoadScoresIntoSessions( - 1, - [null, start, start - 1000, start - 2000].map((e, i) => - deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` }), - ) as Array, - "iidx", - "SP", - log, - ); - - t.strictSame(res, [ - { - sessionID: "EXAMPLE_SESSION_ID", - type: "Appended", - }, - ]); - - const sessions = await MONGODB_KILL.sessions.find({ - game: "iidx", - playtype: "SP", - userID: 1, - }); - - t.equal(sessions.length, 1); - - t.strictSame(sessions[0]?.scoreIDs, [ - "EXAMPLE_SCORE_ID", - "SCORE_ID_3", - "SCORE_ID_2", - "SCORE_ID_1", - ]); - - t.equal(sessions[0]?.timeStarted, start - 2000); - - t.end(); - }); - - t.test("Should calculate pbDifferences if a scorePB exists.", async (t) => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await LoadScoresIntoSessions(1, [TestingIIDXSPScore], "iidx", "SP", log); - - t.match(res, [ - { - sessionID: /^Q[a-f0-9]{40}$/u, - type: "Created", - }, - ]); - - const sessions = await MONGODB_KILL.sessions.find({ - game: "iidx", - playtype: "SP", - userID: 1, - }); - - t.equal(sessions.length, 1); - - t.strictSame(sessions[0]?.scoreIDs, ["TESTING_SCORE_ID"]); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/sessions/sessions.test.ts b/typescript/server/src/lib/score-import/framework/sessions/sessions.test.ts new file mode 100644 index 000000000..2762172e9 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/sessions/sessions.test.ts @@ -0,0 +1,137 @@ +import type { UserDocument } from "tachi-common"; + +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import deepmerge from "deepmerge"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { CreateScoreLogger } from "../common/import-logger"; +import { CreateSessions } from "./sessions"; + +const scoreLogger = CreateScoreLogger( + { username: "test_zkldi", id: 1 } as UserDocument, + "foo", + "ir/direct-manual", +); + +async function seed511() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +async function insertIidxScoreDoc(doc: typeof TestingIIDXSPScore) { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", doc.scoreData); + const ta = + doc.timeAchieved !== null && doc.timeAchieved !== undefined + ? UnixMillisecondsToISO8601(doc.timeAchieved) + : null; + const tAdded = + typeof doc.timeAdded === "number" + ? UnixMillisecondsToISO8601(doc.timeAdded) + : UnixMillisecondsToISO8601(Date.now()); + + await DB.insertInto("score") + .values({ + id: doc.scoreID, + user_id: doc.userID, + chart_id: doc.chartID, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(doc.calculatedData ?? {}), + meta: JSON.stringify(doc.scoreMeta ?? {}), + time_achieved: ta, + time_added: tAdded, + highlight: doc.highlight, + comment: doc.comment, + }) + .execute(); +} + +describe("CreateSessions (ported from sessions.oldtest.ts)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "sessions-fw@example.com", + withCredential: true, + withSettings: true, + }); + await seed511(); + }); + + it("composes sessions from one timestamped score", async () => { + await insertIidxScoreDoc(TestingIIDXSPScore); + + const res = await CreateSessions( + 1, + { + "iidx-sp": [TestingIIDXSPScore], + }, + scoreLogger, + ); + + expect(res.length).toBe(1); + expect(res[0]).toMatchObject({ type: "Created" }); + + const count = await DB.selectFrom("session") + .select((eb) => eb.fn.countAll().as("c")) + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirst(); + + expect(Number(count?.c)).toBe(1); + }); + + it("does not compose sessions from untimestamped scores", async () => { + const noTs = deepmerge(TestingIIDXSPScore, { timeAchieved: null }); + await insertIidxScoreDoc(noTs); + + const res = await CreateSessions( + 1, + { + "iidx-sp": [noTs], + }, + scoreLogger, + ); + + expect(res).toEqual([]); + + const c = await DB.selectFrom("session") + .select((eb) => eb.fn.countAll().as("n")) + .where("user_id", "=", 1) + .executeTakeFirst(); + expect(Number(c?.n)).toBe(0); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/sessions/sessions.ts b/typescript/server/src/lib/score-import/framework/sessions/sessions.ts index a7bb529ba..1ac26a25c 100644 --- a/typescript/server/src/lib/score-import/framework/sessions/sessions.ts +++ b/typescript/server/src/lib/score-import/framework/sessions/sessions.ts @@ -1,5 +1,3 @@ -import type { Game } from "tachi-db"; - import { ONE_HOUR } from "#lib/constants/time"; import { LoadScoreDocumentById } from "#lib/db-formats/score"; import { LoadSessionDocumentById, SELECT_SESSION_DOCUMENT } from "#lib/db-formats/session"; @@ -8,47 +6,32 @@ import DB from "#services/pg/db"; import { GetChartForIDGuaranteed } from "#utils/db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import crypto from "crypto"; -import { sql } from "kysely"; import { - type GameGroup, - GamePTToV3, - GetGamePTConfig, - GetGPTString, + GetGameConfig, GetScoreMetricConf, GetScoreMetrics, type integer, - type MONGO_ScoreDocument, - type MONGO_SessionDocument, - type Playtype, + type ScoreDocument, + type SessionDocument, type SessionInfoReturn, type SessionScoreInfo, + type V3Game, } from "tachi-common"; -import type { ScorePlaytypeMap } from "../common/types"; +import type { ScoreGameMap } from "../common/types"; import { CreateSessionCalcData } from "../calculated-data/session"; -import { CreatePBDoc, type MONGO_PBScoreDocumentNoRank } from "../pb/create-pb-doc"; +import { CreatePBDoc, type PBScoreDocumentNoRank } from "../pb/create-pb-doc"; import { GenerateRandomSessionName } from "./name-generation"; const TWO_HOURS = ONE_HOUR * 2; -export async function CreateSessions( - userID: integer, - game: GameGroup, - scorePtMap: ScorePlaytypeMap, - log: KtLogger, -) { +export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap, log: KtLogger) { const allSessionInfo = []; - for (const [playtype, scores] of Object.entries(scorePtMap)) { + for (const [game, scores] of Object.entries(scoreGameMap)) { // eslint-disable-next-line no-await-in-loop - const sessionInfo = await LoadScoresIntoSessions( - userID, - scores, - game, - playtype as Playtype, - log, - ); + const sessionInfo = await LoadScoresIntoSessions(userID, scores, game as V3Game, log); allSessionInfo.push(...sessionInfo); } @@ -61,8 +44,8 @@ export async function CreateSessions( * as a SessionScoreInfo object. */ function ScoreToSessionScoreInfo( - score: MONGO_ScoreDocument, - previousPB: MONGO_PBScoreDocumentNoRank | undefined, + score: ScoreDocument, + previousPB: PBScoreDocumentNoRank | undefined, ): SessionScoreInfo { if (!previousPB) { return { @@ -71,14 +54,14 @@ function ScoreToSessionScoreInfo( }; } - const gptConfig = GetGamePTConfig(score.game, score.playtype); + const gameConfig = GetGameConfig(score.game); const deltas: Record = {}; - const scoreMetrics = GetScoreMetrics(gptConfig, ["DECIMAL", "ENUM", "INTEGER"]); + const scoreMetrics = GetScoreMetrics(gameConfig, ["DECIMAL", "ENUM", "INTEGER"]); for (const metric of scoreMetrics) { - const conf = GetScoreMetricConf(gptConfig, metric)!; + const conf = GetScoreMetricConf(gameConfig, metric)!; if (conf.type === "ENUM") { deltas[metric] = @@ -102,7 +85,7 @@ function ScoreToSessionScoreInfo( * time. */ export async function GetSessionScoreInfo( - session: MONGO_SessionDocument, + session: SessionDocument, ): Promise> { const scores = await DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") @@ -113,7 +96,7 @@ export async function GetSessionScoreInfo( const scoreIds = scores.map((r) => r.id); - const gptString = GetGPTString(session.game, session.playtype); + const game = session.game; const promises = []; @@ -124,9 +107,9 @@ export async function GetSessionScoreInfo( return null; } - return GetChartForIDGuaranteed(scoreDoc.game, scoreDoc.chartID).then((chart) => - CreatePBDoc(gptString, session.userID, chart, log, session.timeStarted).then( - (pb) => ScoreToSessionScoreInfo(scoreDoc, pb), + return GetChartForIDGuaranteed(scoreDoc.chartID).then((chart) => + CreatePBDoc(game, session.userID, chart, log, session.timeStarted).then((pb) => + ScoreToSessionScoreInfo(scoreDoc, pb), ), ); }), @@ -145,17 +128,14 @@ export function CreateSessionID() { } function UpdateExistingSession( - existingSession: MONGO_SessionDocument, + existingSession: SessionDocument, newScoreIDs: Array, - oldScores: Array, - newScores: Array, + oldScores: Array, + newScores: Array, ) { const allScores = [...oldScores, ...newScores]; - const calculatedData = CreateSessionCalcData( - GetGPTString(existingSession.game, existingSession.playtype), - allScores, - ); + const calculatedData = CreateSessionCalcData(existingSession.game, allScores); existingSession.calculatedData = calculatedData; existingSession.scoreIDs = [...existingSession.scoreIDs, ...newScoreIDs]; @@ -174,13 +154,12 @@ function UpdateExistingSession( function CreateSession( userID: integer, scoreIDs: Array, - groupScores: Array, - game: GameGroup, - playtype: Playtype, -): MONGO_SessionDocument { + groupScores: Array, + game: V3Game, +): SessionDocument { const name = GenerateRandomSessionName(); - const calculatedData = CreateSessionCalcData(GetGPTString(game, playtype), groupScores); + const calculatedData = CreateSessionCalcData(game, groupScores); return { userID, @@ -188,7 +167,6 @@ function CreateSession( sessionID: CreateSessionID(), desc: null, game, - playtype, highlight: false, scoreIDs, timeInserted: Date.now(), @@ -200,15 +178,12 @@ function CreateSession( export async function LoadScoresIntoSessions( userID: integer, - importScores: Array, - game: GameGroup, - playtype: Playtype, + importScores: Array, + game: V3Game, baseLog: KtLogger, ): Promise> { const log = AppendLogCtx("Session Generation", baseLog); - const v3Game = GamePTToV3(game, playtype) as Game; - const timestampedScores = []; for (const score of importScores) { @@ -234,8 +209,8 @@ export async function LoadScoresIntoSessions( // The "Score Groups" for the array of scores provided. // This contains scores split on 2hr margins, which allows for more optimised // session db requests. - const sessionScoreGroups: Array> = []; - let curGroup: Array = []; + const sessionScoreGroups: Array> = []; + let curGroup: Array = []; let lastTimestamp = 0; for (const score of timestampedScores) { @@ -279,7 +254,7 @@ export async function LoadScoresIntoSessions( const nearbySession = await DB.selectFrom("session") .select(SELECT_SESSION_DOCUMENT) .where("session.user_id", "=", userID) - .where("session.game", "=", v3Game) + .where("session.game", "=", game) .where((eb) => eb.or([ eb.and([ @@ -298,7 +273,7 @@ export async function LoadScoresIntoSessions( if (nearbySession) { log.debug( - `Found nearby session for ${userID} (${game} ${playtype}) around ${startOfGroup} ${endOfGroup}.`, + `Found nearby session for ${userID} (${game}) around ${startOfGroup} ${endOfGroup}.`, ); const mongoSession = await LoadSessionDocumentById(nearbySession.id); @@ -329,10 +304,10 @@ export async function LoadScoresIntoSessions( .execute(); } else { log.debug( - `Creating new session for ${userID} (${game} ${playtype}) around ${startOfGroup} ${endOfGroup}.`, + `Creating new session for ${userID} (${game}) around ${startOfGroup} ${endOfGroup}.`, ); - const session = CreateSession(userID, scoreIDs, groupScores, game, playtype); + const session = CreateSession(userID, scoreIDs, groupScores, game); infoReturn = { sessionID: session.sessionID, type: "Created" }; @@ -342,7 +317,7 @@ export async function LoadScoresIntoSessions( .values({ id: session.sessionID, user_id: userID, - game: v3Game, + game, name: session.name, description: session.desc, time_inserted: now, @@ -366,12 +341,12 @@ export async function LoadScoresIntoSessions( return sessionInfoReturns; } -async function loadScoresByIds(ids: Array): Promise> { +async function loadScoresByIds(ids: Array): Promise> { if (ids.length === 0) { return []; } - const out: Array = []; + const out: Array = []; for (const id of ids) { // eslint-disable-next-line no-await-in-loop diff --git a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.oldtest.ts b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.oldtest.ts deleted file mode 100644 index ae5f43edf..000000000 --- a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.oldtest.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { TestingIIDXSPScorePB } from "#test-utils/test-data"; -import crypto from "crypto"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import { UpdateUsersGamePlaytypeStats } from "./update-ugpt-stats"; - -// more of an integration test -t.test("#UpdateUsersGamePlaytypeStats", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - }); - - t.test( - "Should create new UserGameStats and UserGameSettings if the user has none", - async (t) => { - await MONGODB_KILL["game-stats"].remove({}); - await MONGODB_KILL["game-settings"].remove({}); - - const res = await UpdateUsersGamePlaytypeStats("iidx", "SP", 1, null, log); - - t.strictSame(res, [], "Should return an empty object"); - - const gs = await MONGODB_KILL["game-stats"].findOne(); - - t.hasStrict( - gs, - { - game: "iidx", - playtype: "SP", - userID: 1, - ratings: { ktLampRating: 0 }, - classes: {}, - }, - "Should insert an appropriate game-stats object", - ); - - const settings = await MONGODB_KILL["game-settings"].findOne(); - - t.hasStrict(settings, { - game: "iidx", - playtype: "SP", - userID: 1, - preferences: {}, - }); - - t.end(); - }, - ); - - t.test("Should update UserGameStats if the user has one", async (t) => { - await MONGODB_KILL["game-stats"].remove({}); - - await MONGODB_KILL["game-stats"].insert({ - game: "iidx", - playtype: "SP", - userID: 1, - ratings: { ktLampRating: 0 }, - classes: {}, - }); - - // insert some mock scores - const ratings = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - - await MONGODB_KILL["personal-bests"].insert( - ratings.map((e) => - deepmerge(TestingIIDXSPScorePB, { - chartID: crypto.randomBytes(20).toString("hex"), - calculatedData: { - ktLampRating: e, - }, - }), - ), - ); - - const res = await UpdateUsersGamePlaytypeStats("iidx", "SP", 1, null, log); - - t.strictSame(res, [], "Should return an empty object"); - - const gs = await MONGODB_KILL["game-stats"].findOne(); - - t.hasStrict( - gs, - { - game: "iidx", - playtype: "SP", - userID: 1, - ratings: { ktLampRating: ratings.reduce((a, e) => a + e, 0) / 20 }, - classes: {}, - }, - "Should update the game-stats object", - ); - - t.end(); - }); - - t.test("Should return class deltas", async (t) => { - await MONGODB_KILL["game-stats"].remove({}); - - await MONGODB_KILL["game-stats"].insert({ - game: "iidx", - playtype: "SP", - userID: 1, - ratings: { ktLampRating: 0 }, - - classes: {}, - }); - - const res = await UpdateUsersGamePlaytypeStats( - "iidx", - "SP", - 1, - () => ({ dan: "KAIDEN" }), - log, - ); - - t.strictSame( - res, - [ - { - game: "iidx", - set: "dan", - playtype: "SP", - old: null, - new: "KAIDEN", - }, - ], - "Should return the class delta", - ); - - const gs = await MONGODB_KILL["game-stats"].findOne(); - - t.hasStrict( - gs, - { - game: "iidx", - playtype: "SP", - userID: 1, - ratings: { ktLampRating: 0 }, - classes: { - dan: "KAIDEN", - }, - }, - "Should update the game-stats object", - ); - - t.end(); - }); - - t.test("Should return updated class deltas", async (t) => { - await MONGODB_KILL["game-stats"].remove({}); - - await MONGODB_KILL["game-stats"].insert({ - game: "iidx", - playtype: "SP", - userID: 1, - ratings: { ktLampRating: 0 }, - classes: { - dan: "CHUUDEN", - }, - }); - - const res = await UpdateUsersGamePlaytypeStats( - "iidx", - "SP", - 1, - () => ({ dan: "KAIDEN" }), - log, - ); - - t.strictSame( - res, - [ - { - game: "iidx", - set: "dan", - playtype: "SP", - old: "CHUUDEN", - new: "KAIDEN", - }, - ], - "Should return the updated class delta", - ); - - const gs = await MONGODB_KILL["game-stats"].findOne(); - - t.hasStrict( - gs, - { - game: "iidx", - playtype: "SP", - userID: 1, - ratings: { ktLampRating: 0 }, - classes: { - dan: "KAIDEN", - }, - }, - "Should update the game-stats object", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.test.ts b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.test.ts new file mode 100644 index 000000000..d50b69776 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.test.ts @@ -0,0 +1,269 @@ +import { seedUser } from "#actions/test-utils/api-tokens"; +import { log } from "#lib/log/log"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { + Testing511Song, + Testing511SPA, + TestingIIDXSPScore, + TestingIIDXSPScorePB, +} from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import crypto from "crypto"; +import deepmerge from "deepmerge"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { UpdateUsersGamePlaytypeStats } from "./update-ugpt-stats"; + +async function seedIidx511ForPb() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +async function insertScoreFromTesting(opts: { userId: number }) { + const { data, derived, judgements } = mongoScoreDataToPg( + "iidx-sp", + TestingIIDXSPScore.scoreData, + ); + const t = UnixMillisecondsToISO8601(TestingIIDXSPScore.timeAchieved ?? Date.now()); + + await DB.insertInto("score") + .values({ + id: TestingIIDXSPScore.scoreID, + user_id: opts.userId, + chart_id: TestingIIDXSPScore.chartID, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(TestingIIDXSPScore.calculatedData ?? {}), + meta: JSON.stringify(TestingIIDXSPScore.scoreMeta ?? {}), + time_achieved: t, + time_added: t, + highlight: TestingIIDXSPScore.highlight, + comment: null, + }) + .execute(); +} + +async function insertPbFromTesting(opts: { pb: typeof TestingIIDXSPScorePB; userId: number }) { + const { pb } = opts; + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", pb.scoreData); + + await DB.insertInto("pb") + .values({ + user_id: opts.userId, + chart_id: pb.chartID, + lens: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(pb.calculatedData), + ranking_value: pb.scoreData.score, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: pb.highlight, + time_achieved: pb.timeAchieved ? UnixMillisecondsToISO8601(pb.timeAchieved) : null, + }) + .execute(); +} + +describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts)", () => { + beforeEach(async () => { + await seedUser({ + username: "ugpt_stats_u", + email: "ugptstats@example.com", + withCredential: true, + withSettings: true, + }); + await seedIidx511ForPb(); + await insertScoreFromTesting({ userId: 1 }); + await insertPbFromTesting({ userId: 1, pb: TestingIIDXSPScorePB }); + }); + + it("creates game_profile and game_settings when the user has none", async () => { + const res = await UpdateUsersGamePlaytypeStats("iidx-sp", 1, null, log); + + expect(res).toEqual([]); + + const gp = await DB.selectFrom("game_profile") + .selectAll() + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + + const ratings = typeof gp.ratings === "string" ? JSON.parse(gp.ratings) : gp.ratings; + expect(ratings).toMatchObject({ ktLampRating: expect.any(Number) }); + + const settings = await DB.selectFrom("game_settings") + .selectAll() + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirst(); + expect(settings).toBeDefined(); + }); + + it("updates ratings when game_profile already exists", async () => { + const ratings = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + for (const e of ratings) { + const fakeChart = crypto.randomBytes(20).toString("hex"); + await DB.insertInto("song") + .values({ + id: `s_${fakeChart}`, + legacy_id: 50_000 + e, + game_group: "iidx", + title: "T", + artist: "A", + search_terms: [], + alt_titles: [], + data: {}, + fts_document: "", + }) + .execute(); + await DB.insertInto("chart") + .values({ + id: fakeChart, + legacy_id: fakeChart, + game: "iidx-sp", + song_id: `s_${fakeChart}`, + difficulty: "ANOTHER", + level: "10", + level_num: 10, + is_primary: true, + versions: [], + data: { inGameID: 1000, notecount: 786 }, + }) + .execute(); + + await insertPbFromTesting({ + userId: 1, + pb: deepmerge(TestingIIDXSPScorePB, { + chartID: fakeChart, + calculatedData: { + ktLampRating: e, + BPI: 10.1, + }, + }), + }); + } + + await UpdateUsersGamePlaytypeStats("iidx-sp", 1, null, log); + + const gp = await DB.selectFrom("game_profile") + .select("ratings") + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + + const r = typeof gp.ratings === "string" ? JSON.parse(gp.ratings) : gp.ratings; + expect((r as { ktLampRating: number }).ktLampRating).toBe( + ratings.reduce((a, e) => a + e, 0) / 20, + ); + }); + + it("returns class deltas from the class provider", async () => { + await DB.insertInto("game_profile") + .values({ + user_id: 1, + game: "iidx-sp", + ratings: JSON.stringify({ ktLampRating: 0 }), + classes: JSON.stringify({}), + }) + .execute(); + + const res = await UpdateUsersGamePlaytypeStats( + "iidx-sp", + 1, + () => ({ dan: "KAIDEN" }), + log, + ); + + expect(res).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + game: "iidx-sp", + set: "dan", + old: null, + new: "KAIDEN", + }), + ]), + ); + + const gp = await DB.selectFrom("game_profile") + .select("classes") + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + const cls = typeof gp.classes === "string" ? JSON.parse(gp.classes) : gp.classes; + expect((cls as { dan: string }).dan).toBe("KAIDEN"); + }); + + it("returns updated class deltas when a previous dan exists", async () => { + await DB.insertInto("game_profile") + .values({ + user_id: 1, + game: "iidx-sp", + ratings: JSON.stringify({ ktLampRating: 0 }), + classes: JSON.stringify({ dan: "CHUUDEN" }), + }) + .execute(); + + const res = await UpdateUsersGamePlaytypeStats( + "iidx-sp", + 1, + () => ({ dan: "KAIDEN" }), + log, + ); + + expect(res).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + game: "iidx-sp", + set: "dan", + old: "CHUUDEN", + new: "KAIDEN", + }), + ]), + ); + + const gp = await DB.selectFrom("game_profile") + .select("classes") + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + const cls = typeof gp.classes === "string" ? JSON.parse(gp.classes) : gp.classes; + expect((cls as { dan: string }).dan).toBe("KAIDEN"); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.ts b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.ts index 681af4b6d..585425ac3 100644 --- a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.ts +++ b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.ts @@ -3,13 +3,7 @@ import type { KtLogger } from "#lib/log/log"; import { CreateGameSettings } from "#lib/game-settings/create-game-settings"; import DB from "#services/pg/db"; import { loadUserGameStats } from "#utils/class"; -import { - type ClassDelta, - type GameGroup, - GamePTToV3, - type integer, - type Playtype, -} from "tachi-common"; +import { type ClassDelta, type integer, type V3Game } from "tachi-common"; import type { ClassProvider } from "../calculated-data/types"; @@ -17,36 +11,33 @@ import { CalculateProfileRatings } from "../calculated-data/profile"; import { CalculateUGPTClasses, ProcessClassDeltas } from "../profile-calculated-data/classes"; export async function UpdateUsersGamePlaytypeStats( - game: GameGroup, - playtype: Playtype, + game: V3Game, userID: integer, - classProvider: ClassProvider | null, + classProvider: ClassProvider | null, log: KtLogger, ): Promise> { log.debug(`Calculating Ratings...`); - const ratings = await CalculateProfileRatings(game, playtype, userID); - - const v3Game = GamePTToV3(game, playtype); + const ratings = await CalculateProfileRatings(game, userID); // Attempt to find a users game stats if one already exists. If one doesn't exist, // this is this players first import for this game! - const userGameStats = await loadUserGameStats(userID, game, playtype); + const userGameStats = await loadUserGameStats(userID, game); log.debug(`Calculating UGSClasses...`); - const classes = await CalculateUGPTClasses(game, playtype, userID, ratings, classProvider, log); + const classes = await CalculateUGPTClasses(game, userID, ratings, classProvider, log); log.debug(`Finished Calculating UGSClasses`); log.debug(`Calculating Class Deltas...`); - const deltas = await ProcessClassDeltas(game, playtype, classes, userGameStats, userID, log); + const deltas = await ProcessClassDeltas(game, classes, userGameStats, userID, log); log.debug(`Had ${deltas.length} deltas.`); if (userGameStats) { - log.debug(`Updated player gamestats for ${game} (${playtype})`); + log.debug(`Updated player gamestats for ${game}`); const nextClasses: Record = { ...userGameStats.classes, @@ -62,13 +53,13 @@ export async function UpdateUsersGamePlaytypeStats( classes: JSON.stringify(nextClasses), }) .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .execute(); } else { const hasAnyScores = await DB.selectFrom("score") .select("id") .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst(); if (!hasAnyScores) { @@ -76,23 +67,22 @@ export async function UpdateUsersGamePlaytypeStats( { userID, game, - playtype, }, "Not creating new game stats for user with no scores.", ); return deltas; } - log.info(`Created new gamestats for ${game} (${playtype})`); + log.info(`Created new gamestats for ${game}`); await DB.insertInto("game_profile") .values({ user_id: userID, - game: v3Game, + game, ratings: JSON.stringify(ratings), classes: JSON.stringify(classes), }) .execute(); - await CreateGameSettings(userID, game, playtype); + await CreateGameSettings(userID, game); } return deltas; diff --git a/typescript/server/src/lib/score-import/import-types/api/eag-iidx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/eag-iidx/parser.ts index 0740c625f..f09e7d4a7 100644 --- a/typescript/server/src/lib/score-import/import-types/api/eag-iidx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/eag-iidx/parser.ts @@ -1,10 +1,9 @@ import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; +import { ParseKaiIIDX } from "#lib/score-import/import-types/common/api-kai/iidx/parser"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; -import { ParseKaiIIDX } from "../../common/api-kai/iidx/parser"; - export async function ParseEagIIDX(userID: integer, log: KtLogger) { const authDoc = await GetKaiAuthGuaranteed(userID, "EAG", log); diff --git a/typescript/server/src/lib/score-import/import-types/api/eag-sdvx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/eag-sdvx/parser.ts index 80a2ad004..41b8f6d0b 100644 --- a/typescript/server/src/lib/score-import/import-types/api/eag-sdvx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/eag-sdvx/parser.ts @@ -1,10 +1,9 @@ import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; +import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; -import { ParseKaiSDVX } from "../../common/api-kai/sdvx/parser"; - export async function ParseEagSDVX(userID: integer, log: KtLogger) { const authDoc = await GetKaiAuthGuaranteed(userID, "EAG", log); diff --git a/typescript/server/src/lib/score-import/import-types/api/flo-iidx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/flo-iidx/parser.ts index 48ba8ab94..a72bc7513 100644 --- a/typescript/server/src/lib/score-import/import-types/api/flo-iidx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/flo-iidx/parser.ts @@ -1,10 +1,9 @@ import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; +import { ParseKaiIIDX } from "#lib/score-import/import-types/common/api-kai/iidx/parser"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; -import { ParseKaiIIDX } from "../../common/api-kai/iidx/parser"; - export async function ParseFloIIDX(userID: integer, log: KtLogger) { const authDoc = await GetKaiAuthGuaranteed(userID, "FLO", log); diff --git a/typescript/server/src/lib/score-import/import-types/api/flo-sdvx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/flo-sdvx/parser.ts index 97e2b8dca..bb0e3d856 100644 --- a/typescript/server/src/lib/score-import/import-types/api/flo-sdvx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/flo-sdvx/parser.ts @@ -1,10 +1,9 @@ import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; +import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; -import { ParseKaiSDVX } from "../../common/api-kai/sdvx/parser"; - export async function ParseFloSDVX(userID: integer, log: KtLogger) { const authDoc = await GetKaiAuthGuaranteed(userID, "FLO", log); diff --git a/typescript/server/src/lib/score-import/import-types/api/min-sdvx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/min-sdvx/parser.ts index 7a9b9897e..a9c168495 100644 --- a/typescript/server/src/lib/score-import/import-types/api/min-sdvx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/min-sdvx/parser.ts @@ -1,10 +1,9 @@ import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; +import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; -import { ParseKaiSDVX } from "../../common/api-kai/sdvx/parser"; - export async function ParseMinSDVX(userID: integer, log: KtLogger) { const authDoc = await GetKaiAuthGuaranteed(userID, "MIN", log); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.oldtest.ts deleted file mode 100644 index bfe94ac19..000000000 --- a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.oldtest.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; -import { - ChunithmClearStatus, - ChunithmComboStatus, - ChunithmFullChainStatus, - ChunithmLevel, - ChunithmScoreRank, -} from "#proto/generated/chunithm/common_pb"; -import { dmf } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingChunithmChartConverter, TestingChunithmSongConverter } from "#test-utils/test-data"; -import t from "tap"; - -import type { MytChunithmScore } from "./types"; - -import ConvertAPIMytChunithm from "./converter"; - -const parsedScore: MytChunithmScore = { - playlogApiId: "346907fc-ba1a-4ff9-a5a3-37a62b5f2e6c", - info: { - musicId: 956, - level: ChunithmLevel.CHUNITHM_LEVEL_MASTER, - score: 1001715, - scoreRank: ChunithmScoreRank.CHUNITHM_SCORE_RANK_SS, - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_NONE, - fullChainStatus: ChunithmFullChainStatus.CHUNITHM_FULL_CHAIN_STATUS_NONE, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_CLEAR, - track: 2, - isNewRecord: false, - userPlayDate: "2024-02-05T00:00:00.000Z", - }, - judge: { - judgeHeaven: 300, - judgeCritical: 1159, - judgeJustice: 37, - judgeAttack: 4, - judgeMiss: 10, - maxCombo: 493, - }, -}; - -t.test("#ConvertAPIMytChunithm", (t) => { - t.beforeEach(ResetDBState); - - function convert(modifier: any = {}) { - return ConvertAPIMytChunithm(dmf(parsedScore, modifier), {}, "api/myt-chunithm", log); - } - - t.test("Should return a dryScore on valid input.", async (t) => { - const res = await convert(); - - t.strictSame(res, { - song: TestingChunithmSongConverter, - chart: TestingChunithmChartConverter, - dryScore: { - service: "MYT", - game: "chunithm", - scoreMeta: {}, - timeAchieved: ParseDateFromString("2024-02-05T00:00:00.000Z"), - comment: null, - importType: "api/myt-chunithm", - scoreData: { - score: 1001715, - clearLamp: "CLEAR", - noteLamp: "NONE", - judgements: { - jcrit: 1459, - justice: 37, - attack: 4, - miss: 10, - }, - optional: { - maxCombo: 493, - }, - }, - }, - }); - t.end(); - }); - - t.test("Should reject WORLD'S END charts", (t) => { - t.rejects( - () => - convert({ - info: { - musicId: 8032, - level: ChunithmLevel.CHUNITHM_LEVEL_WORLDS_END, - }, - }), - { - message: /WORLD'S END charts are not supported/u, - }, - ); - t.end(); - }); - - t.test("Should reject unspecified difficulty", (t) => { - t.rejects( - () => - convert({ - info: { - level: ChunithmLevel.CHUNITHM_LEVEL_UNSPECIFIED, - }, - }), - { - message: /Can't process a score with unspecified difficulty/u, - }, - ); - t.end(); - }); - - t.test("note lamp", async (t) => { - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_ALL_JUSTICE_CRITICAL, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_FAILED, - }, - }), - { - dryScore: { scoreData: { noteLamp: "ALL JUSTICE CRITICAL", clearLamp: "FAILED" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_ALL_JUSTICE, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_FAILED, - }, - }), - { - dryScore: { scoreData: { noteLamp: "ALL JUSTICE", clearLamp: "FAILED" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_FULL_COMBO, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_FAILED, - }, - }), - { - dryScore: { scoreData: { noteLamp: "FULL COMBO", clearLamp: "FAILED" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_NONE, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_HARD, - }, - }), - { - dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "HARD" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_NONE, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_ABSOLUTE, - }, - }), - { - dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "BRAVE" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_NONE, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_ABSOLUTE_PLUS, - }, - }), - { - dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "ABSOLUTE" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_NONE, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_CATASTROPHY, - }, - }), - { - dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "CATASTROPHY" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_NONE, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_CLEAR, - }, - }), - { - dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "CLEAR" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_NONE, - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_FAILED, - }, - }), - { - dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "FAILED" } }, - }, - ); - t.end(); - }); - - t.test("Should reject unspecified clear status", (t) => { - t.rejects( - () => - convert({ - info: { - clearStatus: ChunithmClearStatus.CHUNITHM_CLEAR_STATUS_UNSPECIFIED, - }, - }), - { - message: /Can't process a score with an invalid clear status/u, - }, - ); - t.end(); - }); - - t.test("Should reject unspecified combo status", (t) => { - t.rejects( - () => - convert({ - info: { - comboStatus: ChunithmComboStatus.CHUNITHM_COMBO_STATUS_UNSPECIFIED, - }, - }), - { - message: /Can't process a score with an invalid combo status/u, - }, - ); - t.end(); - }); - - t.test("Should throw on missing chart", (t) => { - t.rejects(() => convert({ info: { musicId: 999999 } }), { - message: /Can't find chart with id 999999 and difficulty MASTER/u, - }); - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.test.ts b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.test.ts new file mode 100644 index 000000000..dc01804b1 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.test.ts @@ -0,0 +1,273 @@ +import type { DeepPartial } from "#utils/types"; + +import { log } from "#lib/log/log"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import { + ChunithmClearStatus, + ChunithmComboStatus, + ChunithmFullChainStatus, + ChunithmLevel, + ChunithmScoreRank, +} from "#proto/generated/chunithm/common_pb"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingChunithmChartConverter, TestingChunithmSongConverter } from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { MytChunithmScore } from "./types"; + +import ConvertAPIMytChunithm from "./converter"; + +const parsedScore = { + playlogApiId: "346907fc-ba1a-4ff9-a5a3-37a62b5f2e6c", + info: { + musicId: 956, + level: ChunithmLevel.MASTER, + score: 1001715, + scoreRank: ChunithmScoreRank.SS, + comboStatus: ChunithmComboStatus.NONE, + fullChainStatus: ChunithmFullChainStatus.NONE, + clearStatus: ChunithmClearStatus.CLEAR, + track: 2, + isNewRecord: false, + userPlayDate: "2024-02-05T00:00:00.000Z", + }, + judge: { + judgeHeaven: 300, + judgeCritical: 1159, + judgeJustice: 37, + judgeAttack: 4, + judgeMiss: 10, + maxCombo: 493, + }, +} as MytChunithmScore; + +async function seedKillyJokerMaster() { + await DB.insertInto("song") + .values({ + id: TestingChunithmSongConverter.id, + legacy_id: 956, + game_group: "chunithm", + title: TestingChunithmSongConverter.title, + artist: TestingChunithmSongConverter.artist, + search_terms: TestingChunithmSongConverter.searchTerms, + alt_titles: TestingChunithmSongConverter.altTitles, + data: TestingChunithmSongConverter.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingChunithmChartConverter.chartID, + legacy_id: TestingChunithmChartConverter.chartID, + game: "chunithm", + song_id: TestingChunithmSongConverter.id, + difficulty: TestingChunithmChartConverter.difficulty, + level: TestingChunithmChartConverter.level, + level_num: TestingChunithmChartConverter.levelNum, + is_primary: TestingChunithmChartConverter.isPrimary, + versions: TestingChunithmChartConverter.versions, + data: TestingChunithmChartConverter.data, + }) + .execute(); +} + +describe("ConvertAPIMytChunithm", () => { + beforeEach(seedKillyJokerMaster); + + function convert(modifier: DeepPartial = {}) { + return ConvertAPIMytChunithm(dmf(parsedScore, modifier), {}, "api/myt-chunithm", log); + } + + it("returns song, chart, and dryScore for valid input", async () => { + const res = await convert(); + + expect(res).toStrictEqual({ + song: TestingChunithmSongConverter, + chart: TestingChunithmChartConverter, + dryScore: { + service: "MYT", + game: "chunithm", + scoreMeta: {}, + timeAchieved: ParseDateFromString("2024-02-05T00:00:00.000Z"), + comment: null, + importType: "api/myt-chunithm", + scoreData: { + score: 1001715, + clearLamp: "CLEAR", + noteLamp: "NONE", + judgements: { + jcrit: 1459, + justice: 37, + attack: 4, + miss: 10, + }, + optional: { + maxCombo: 493, + }, + }, + }, + }); + }); + + it("rejects WORLD'S END charts", async () => { + await expect( + convert({ + info: { + musicId: 8032, + level: ChunithmLevel.WORLDS_END, + }, + }), + ).rejects.toMatchObject({ + failureType: "SkipScore", + message: /WORLD'S END charts are not supported/u, + }); + }); + + it("rejects unspecified difficulty", async () => { + await expect( + convert({ + info: { + level: ChunithmLevel.UNSPECIFIED, + }, + }), + ).rejects.toMatchObject({ + message: expect.stringMatching( + /Can't process a score with unspecified difficulty/u, + ) as string, + }); + }); + + it("maps note and clear lamps from proto enums", async () => { + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.ALL_JUSTICE_CRITICAL, + clearStatus: ChunithmClearStatus.FAILED, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "ALL JUSTICE CRITICAL", clearLamp: "FAILED" } }, + }); + + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.ALL_JUSTICE, + clearStatus: ChunithmClearStatus.FAILED, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "ALL JUSTICE", clearLamp: "FAILED" } }, + }); + + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.FULL_COMBO, + clearStatus: ChunithmClearStatus.FAILED, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "FULL COMBO", clearLamp: "FAILED" } }, + }); + + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.NONE, + clearStatus: ChunithmClearStatus.HARD, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "HARD" } }, + }); + + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.NONE, + clearStatus: ChunithmClearStatus.ABSOLUTE, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "BRAVE" } }, + }); + + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.NONE, + clearStatus: ChunithmClearStatus.ABSOLUTE_PLUS, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "ABSOLUTE" } }, + }); + + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.NONE, + clearStatus: ChunithmClearStatus.CATASTROPHY, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "CATASTROPHY" } }, + }); + + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.NONE, + clearStatus: ChunithmClearStatus.CLEAR, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "CLEAR" } }, + }); + + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.NONE, + clearStatus: ChunithmClearStatus.FAILED, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "NONE", clearLamp: "FAILED" } }, + }); + }); + + it("rejects unspecified clear status", async () => { + await expect( + convert({ + info: { + clearStatus: ChunithmClearStatus.UNSPECIFIED, + }, + }), + ).rejects.toMatchObject({ + message: /Can't process a score with an invalid clear status/u, + }); + }); + + it("rejects unspecified combo status", async () => { + await expect( + convert({ + info: { + comboStatus: ChunithmComboStatus.UNSPECIFIED, + }, + }), + ).rejects.toMatchObject({ + message: /Can't process a score with an invalid combo status/u, + }); + }); + + it("throws when no chart matches musicId and difficulty", async () => { + await expect(convert({ info: { musicId: 999999 } })).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Can't find chart with id 999999 and difficulty MASTER/u, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.ts b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.ts index 5aa7771ba..6476e41d5 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; import { @@ -16,7 +17,6 @@ import { import { FindChartOnInGameID } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { MytChunithmScore } from "./types"; const DIFFICULTIES = { @@ -79,7 +79,7 @@ const ConvertAPIMytChunithm: ConverterFunction = throw new InvalidScoreFailure("Can't process a score with an invalid combo status"); } - const chart = await FindChartOnInGameID("chunithm", data.info.musicId, "Single", difficulty); + const chart = await FindChartOnInGameID("chunithm", data.info.musicId, difficulty); if (chart === null) { throw new SongOrChartNotFoundFailure( @@ -90,14 +90,14 @@ const ConvertAPIMytChunithm: ConverterFunction = ); } - const song = await FindSongOnID("chunithm", chart.songID); + const song = await FindSongOnID("chunithm", chart.song.id); if (song === null) { - log.error({ chart }, `Song/chart desync: ${chart.songID} for chart ${chart.chartID}`); - throw new InternalFailure(`Song/chart desync: ${chart.songID} for chart ${chart.chartID}`); + log.error({ chart }, `Song/chart desync: ${chart.song.id} for chart ${chart.chartID}`); + throw new InternalFailure(`Song/chart desync: ${chart.song.id} for chart ${chart.chartID}`); } - const dryScore: DryScore<"chunithm:Single"> = { + const dryScore: DryScore<"chunithm"> = { service: "MYT", game: "chunithm", scoreMeta: {}, diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/parser.ts index 4b9f6b2db..a61aa4bd5 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/parser.ts @@ -1,17 +1,19 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; -import type { integer } from "tachi-common"; +import type { GamesForGroup, integer } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { + CreateMytTransport, + FetchMytTitleAPIID, +} from "#lib/score-import/import-types/common/api-myt/traverse-api"; import { ChunithmUser, GetPlaylogRequestSchema } from "#proto/generated/chunithm/user_pb"; import { create } from "@bufbuild/protobuf"; import { ConnectError, createClient } from "@connectrpc/connect"; -import type { ParserFunctionReturns } from "../../common/types"; import type { MytChunithmScore } from "./types"; -import { CreateMytTransport, FetchMytTitleAPIID } from "../../common/api-myt/traverse-api"; - async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable { const profileApiId = await FetchMytTitleAPIID(userID, "chunithm", log); const client = createClient(ChunithmUser, CreateMytTransport()); @@ -38,14 +40,14 @@ async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable> { - return { +): Promise> { + return Promise.resolve({ iterable: streamPlaylog(userID, log), context: {}, classProvider: null, - game: "chunithm", - }; + gameGroup: "chunithm", + }); } diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.oldtest.ts deleted file mode 100644 index 33b67ca46..000000000 --- a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.oldtest.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; -import { - MaimaiComboStatus, - MaimaiLevel, - MaimaiScoreRank, - MaimaiSyncStatus, -} from "#proto/generated/maimai/common_pb"; -import { dmf } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingMaimaiDXChartConverter, TestingMaimaiDXSongConverter } from "#test-utils/test-data"; -import t from "tap"; - -import type { MytMaimaiDxScore } from "./types"; - -import ConvertAPIMytMaimaiDx from "./converter"; - -const parsedScore: MytMaimaiDxScore = { - playlogApiId: "6071c489-6ab9-4674-a443-f88b603fa596", - info: { - musicId: 11294, - level: MaimaiLevel.MAIMAI_LEVEL_EXPERT, - achievement: 990562, - deluxscore: 1825, - scoreRank: MaimaiScoreRank.MAIMAI_SCORE_RANK_SS, - comboStatus: MaimaiComboStatus.MAIMAI_COMBO_STATUS_NONE, - syncStatus: MaimaiSyncStatus.MAIMAI_SYNC_STATUS_NONE, - isClear: true, - isAchieveNewRecord: true, - isDeluxscoreNewRecord: true, - track: 1, - userPlayDate: "2022-11-03T04:21:05.000+09:00", - }, - judge: { - judgeCriticalPerfect: 10, - judgePerfect: 656, - judgeGreat: 19, - judgeGood: 1, - judgeMiss: 8, - maxCombo: 279, - fastCount: 5, - lateCount: 8, - }, -}; - -t.test("#ConvertAPIMytMaimaiDx", (t) => { - t.beforeEach(ResetDBState); - - function convert(modifier: any = {}) { - return ConvertAPIMytMaimaiDx(dmf(parsedScore, modifier), {}, "api/myt-maimaidx", log); - } - - t.test("Should return a dryScore on valid input.", async (t) => { - const res = await convert(); - - t.strictSame(res, { - song: TestingMaimaiDXSongConverter, - chart: TestingMaimaiDXChartConverter, - dryScore: { - service: "MYT", - game: "maimaidx", - scoreMeta: {}, - timeAchieved: ParseDateFromString("2022-11-03T04:21:05.000+09:00"), - comment: null, - importType: "api/myt-maimaidx", - scoreData: { - percent: 99.0562, - lamp: "CLEAR", - judgements: { - pcrit: 10, - perfect: 656, - great: 19, - good: 1, - miss: 8, - }, - optional: { - fast: 5, - slow: 8, - maxCombo: 279, - }, - }, - }, - }); - t.end(); - }); - - t.test("Should reject Utage charts", (t) => { - t.rejects( - () => - convert({ - info: { - musicId: 8032, - level: MaimaiLevel.MAIMAI_LEVEL_UTAGE, - }, - }), - { - message: /Utage charts are not supported/u, - }, - ); - t.end(); - }); - - t.test("Should reject unspecified difficulty", (t) => { - t.rejects( - () => - convert({ - info: { - level: MaimaiLevel.MAIMAI_LEVEL_UNSPECIFIED, - }, - }), - { - message: /Can't process a score with unspecified difficulty/u, - }, - ); - t.end(); - }); - - t.test("lamp", async (t) => { - t.hasStrict( - await convert({ - info: { - comboStatus: MaimaiComboStatus.MAIMAI_COMBO_STATUS_ALL_PERFECT_PLUS, - isClear: true, - }, - }), - { - dryScore: { scoreData: { lamp: "ALL PERFECT+" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: MaimaiComboStatus.MAIMAI_COMBO_STATUS_ALL_PERFECT, - isClear: true, - }, - }), - { - dryScore: { scoreData: { lamp: "ALL PERFECT" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: MaimaiComboStatus.MAIMAI_COMBO_STATUS_FULL_COMBO_PLUS, - isClear: true, - }, - }), - { - dryScore: { scoreData: { lamp: "FULL COMBO+" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: MaimaiComboStatus.MAIMAI_COMBO_STATUS_FULL_COMBO, - isClear: true, - }, - }), - { - dryScore: { scoreData: { lamp: "FULL COMBO" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: MaimaiComboStatus.MAIMAI_COMBO_STATUS_NONE, - isClear: true, - }, - }), - { - dryScore: { scoreData: { lamp: "CLEAR" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: MaimaiComboStatus.MAIMAI_COMBO_STATUS_NONE, - isClear: false, - }, - }), - { - dryScore: { scoreData: { lamp: "FAILED" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: MaimaiComboStatus.MAIMAI_COMBO_STATUS_FULL_COMBO, - isClear: false, - }, - }), - { - dryScore: { scoreData: { lamp: "FAILED" } }, - }, - ); - t.end(); - }); - - t.test("Should throw on missing chart", (t) => { - t.rejects( - () => - convert({ - info: { musicId: 999999, level: MaimaiLevel.MAIMAI_LEVEL_MASTER }, - }), - { - message: /Can't find chart with id 999999 and difficulty DX Master/u, - }, - ); - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.test.ts b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.test.ts new file mode 100644 index 000000000..3a54490f9 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.test.ts @@ -0,0 +1,236 @@ +import type { DeepPartial } from "#utils/types"; + +import { log } from "#lib/log/log"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import { + MaimaiComboStatus, + MaimaiLevel, + MaimaiScoreRank, + MaimaiSyncStatus, +} from "#proto/generated/maimai/common_pb"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingMaimaiDXChartConverter, TestingMaimaiDXSongConverter } from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { MytMaimaiDxScore } from "./types"; + +import ConvertAPIMytMaimaiDx from "./converter"; + +const parsedScore = { + playlogApiId: "6071c489-6ab9-4674-a443-f88b603fa596", + info: { + musicId: 11294, + level: MaimaiLevel.EXPERT, + achievement: 990562, + deluxscore: 1825, + scoreRank: MaimaiScoreRank.SS, + comboStatus: MaimaiComboStatus.NONE, + syncStatus: MaimaiSyncStatus.NONE, + isClear: true, + isAchieveNewRecord: true, + isDeluxscoreNewRecord: true, + track: 1, + userPlayDate: "2022-11-03T04:21:05.000+09:00", + }, + judge: { + judgeCriticalPerfect: 10, + judgePerfect: 656, + judgeGreat: 19, + judgeGood: 1, + judgeMiss: 8, + maxCombo: 279, + fastCount: 5, + lateCount: 8, + }, +} as MytMaimaiDxScore; + +async function seedMaimaiDxConverterFixture() { + await DB.insertInto("song") + .values({ + id: TestingMaimaiDXSongConverter.id, + legacy_id: 11294, + game_group: "maimaidx", + title: TestingMaimaiDXSongConverter.title, + artist: TestingMaimaiDXSongConverter.artist, + search_terms: TestingMaimaiDXSongConverter.searchTerms, + alt_titles: TestingMaimaiDXSongConverter.altTitles, + data: TestingMaimaiDXSongConverter.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingMaimaiDXChartConverter.chartID, + legacy_id: TestingMaimaiDXChartConverter.chartID, + game: "maimaidx", + song_id: TestingMaimaiDXSongConverter.id, + difficulty: "DX Expert", + level: TestingMaimaiDXChartConverter.level, + level_num: TestingMaimaiDXChartConverter.levelNum, + is_primary: TestingMaimaiDXChartConverter.isPrimary, + versions: TestingMaimaiDXChartConverter.versions, + data: TestingMaimaiDXChartConverter.data, + }) + .execute(); +} + +describe("ConvertAPIMytMaimaiDx", () => { + beforeEach(seedMaimaiDxConverterFixture); + + function convert(modifier: DeepPartial = {}) { + return ConvertAPIMytMaimaiDx(dmf(parsedScore, modifier), {}, "api/myt-maimaidx", log); + } + + it("returns song, chart, and dryScore for valid input", async () => { + const res = await convert(); + + expect(res).toStrictEqual({ + song: TestingMaimaiDXSongConverter, + chart: TestingMaimaiDXChartConverter, + dryScore: { + service: "MYT", + game: "maimaidx", + scoreMeta: {}, + timeAchieved: ParseDateFromString("2022-11-03T04:21:05.000+09:00"), + comment: null, + importType: "api/myt-maimaidx", + scoreData: { + percent: 99.0562, + lamp: "CLEAR", + judgements: { + pcrit: 10, + perfect: 656, + great: 19, + good: 1, + miss: 8, + }, + optional: { + fast: 5, + slow: 8, + maxCombo: 279, + }, + }, + }, + }); + }); + + it("rejects Utage charts", async () => { + await expect( + convert({ + info: { + musicId: 8032, + level: MaimaiLevel.UTAGE, + }, + }), + ).rejects.toMatchObject({ + failureType: "SkipScore", + message: /Utage charts are not supported/u, + }); + }); + + it("rejects unspecified difficulty", async () => { + await expect( + convert({ + info: { + level: MaimaiLevel.UNSPECIFIED, + }, + }), + ).rejects.toMatchObject({ + message: expect.stringMatching( + /Can't process a score with unspecified difficulty/u, + ) as string, + }); + }); + + it("maps lamp from combo status and clear flag", async () => { + await expect( + convert({ + info: { + comboStatus: MaimaiComboStatus.ALL_PERFECT_PLUS, + isClear: true, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "ALL PERFECT+" } }, + }); + + await expect( + convert({ + info: { + comboStatus: MaimaiComboStatus.ALL_PERFECT, + isClear: true, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "ALL PERFECT" } }, + }); + + await expect( + convert({ + info: { + comboStatus: MaimaiComboStatus.FULL_COMBO_PLUS, + isClear: true, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "FULL COMBO+" } }, + }); + + await expect( + convert({ + info: { + comboStatus: MaimaiComboStatus.FULL_COMBO, + isClear: true, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "FULL COMBO" } }, + }); + + await expect( + convert({ + info: { + comboStatus: MaimaiComboStatus.NONE, + isClear: true, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "CLEAR" } }, + }); + + await expect( + convert({ + info: { + comboStatus: MaimaiComboStatus.NONE, + isClear: false, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "FAILED" } }, + }); + + await expect( + convert({ + info: { + comboStatus: MaimaiComboStatus.FULL_COMBO, + isClear: false, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "FAILED" } }, + }); + }); + + it("throws when no chart matches", async () => { + await expect( + convert({ + info: { musicId: 999999, level: MaimaiLevel.MASTER }, + }), + ).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Can't find chart with id 999999 and difficulty DX Master/u, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.ts b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.ts index 67310d226..406926453 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.ts @@ -1,6 +1,7 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; -import type { MONGO_ScoreData } from "tachi-common"; +import type { ScoreData } from "tachi-common"; import { InternalFailure, @@ -13,7 +14,6 @@ import { MaimaiComboStatus, MaimaiLevel } from "#proto/generated/maimai/common_p import { FindChartOnInGameID } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { MytMaimaiDxScore } from "./types"; const DIFFICULTIES = { @@ -26,10 +26,7 @@ const DIFFICULTIES = { [MaimaiLevel.UTAGE]: "Utage", }; -function getLamp( - comboStatus: number, - isClear: boolean, -): MONGO_ScoreData<"maimaidx:Single">["lamp"] | undefined { +function getLamp(comboStatus: number, isClear: boolean): ScoreData<"maimaidx">["lamp"] | undefined { if (comboStatus === MaimaiComboStatus.UNSPECIFIED) { return undefined; } @@ -94,7 +91,7 @@ const ConvertAPIMytMaimaiDx: ConverterFunction = ); } - const chart = await FindChartOnInGameID("maimaidx", data.info.musicId, "Single", difficulty); + const chart = await FindChartOnInGameID("maimaidx", data.info.musicId, difficulty); if (chart === null) { throw new SongOrChartNotFoundFailure( @@ -105,14 +102,14 @@ const ConvertAPIMytMaimaiDx: ConverterFunction = ); } - const song = await FindSongOnID("maimaidx", chart.songID); + const song = await FindSongOnID("maimaidx", chart.song.id); if (song === null) { - log.error({ chart }, `Song/chart desync: ${chart.songID} for chart ${chart.chartID}`); - throw new InternalFailure(`Song/chart desync: ${chart.songID} for chart ${chart.chartID}`); + log.error({ chart }, `Song/chart desync: ${chart.song.id} for chart ${chart.chartID}`); + throw new InternalFailure(`Song/chart desync: ${chart.song.id} for chart ${chart.chartID}`); } - const dryScore: DryScore<"maimaidx:Single"> = { + const dryScore: DryScore<"maimaidx"> = { service: "MYT", game: "maimaidx", scoreMeta: {}, diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/parser.ts index 21bbd2b0b..ee1e0be93 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/parser.ts @@ -1,17 +1,19 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; -import type { integer } from "tachi-common"; +import type { GamesForGroup, integer } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { + CreateMytTransport, + FetchMytTitleAPIID, +} from "#lib/score-import/import-types/common/api-myt/traverse-api"; import { GetPlaylogRequestSchema, MaimaiUser } from "#proto/generated/maimai/user_pb"; import { create } from "@bufbuild/protobuf"; import { ConnectError, createClient } from "@connectrpc/connect"; -import type { ParserFunctionReturns } from "../../common/types"; import type { MytMaimaiDxScore } from "./types"; -import { CreateMytTransport, FetchMytTitleAPIID } from "../../common/api-myt/traverse-api"; - async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable { const profileApiId = await FetchMytTitleAPIID(userID, "maimaidx", log); const client = createClient(MaimaiUser, CreateMytTransport()); @@ -41,11 +43,11 @@ async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable> { +): Promise> { return { iterable: streamPlaylog(userID, log), context: {}, classProvider: null, - game: "maimaidx", + gameGroup: "maimaidx", }; } diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.oldtest.ts deleted file mode 100644 index b886a7309..000000000 --- a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.oldtest.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; -import { - OngekiBattleScoreRank, - OngekiClearStatus, - OngekiComboStatus, - OngekiLevel, - OngekiTechScoreRank, -} from "#proto/generated/ongeki/common_pb"; -import { dmf } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingOngekiChartConverter, TestingOngekiSongConverter } from "#test-utils/test-data"; -import t from "tap"; - -import type { MytOngekiScore } from "./types"; - -import ConvertAPIMytOngeki from "./converter"; - -const parsedScore: MytOngekiScore = { - playlogApiId: "806ca7ac-76f5-4d99-8760-770df60e1ff5", - info: { - musicId: 678, - level: OngekiLevel.ONGEKI_LEVEL_MASTER, - techScore: 1003385, - battleScore: 4987905, - overDamage: 13151, - techScoreRank: OngekiTechScoreRank.ONGEKI_TECH_SCORE_RANK_SS_PLUS, - battleScoreRank: OngekiBattleScoreRank.ONGEKI_BATTLE_SCORE_RANK_GREAT, - comboStatus: OngekiComboStatus.ONGEKI_COMBO_STATUS_NONE, - clearStatus: OngekiClearStatus.ONGEKI_CLEAR_STATUS_OVER_DAMAGE, - isFullBell: true, - isTechNewRecord: true, - isBattleNewRecord: true, - isOverDamageNewRecord: true, - platinumScore: 893, - userPlayDate: "2022-09-28T12:04:21.400Z", - }, - judge: { - judgeCriticalBreak: 967, - judgeBreak: 19, - judgeHit: 0, - judgeMiss: 5, - maxCombo: 525, - bellCount: 174, - totalBellCount: 174, - damageCount: 0, - }, -}; - -t.test("#ConvertAPIMytOngeki", (t) => { - t.beforeEach(ResetDBState); - - function convert(modifier: any = {}) { - return ConvertAPIMytOngeki(dmf(parsedScore, modifier), {}, "api/myt-ongeki", log); - } - - t.test("Should return a dryScore on valid input.", async (t) => { - const res = await convert(); - - t.strictSame(res, { - song: TestingOngekiSongConverter, - chart: TestingOngekiChartConverter, - dryScore: { - service: "MYT", - game: "ongeki", - scoreMeta: {}, - timeAchieved: ParseDateFromString("2022-09-28T12:04:21.400Z"), - comment: null, - importType: "api/myt-ongeki", - scoreData: { - score: 1003385, - noteLamp: "CLEAR", - bellLamp: "FULL BELL", - platinumScore: 893, - judgements: { - cbreak: 967, - break: 19, - hit: 0, - miss: 5, - }, - optional: { - damage: 0, - bellCount: 174, - totalBellCount: 174, - }, - }, - }, - }); - t.end(); - }); - - t.test("Should reject unspecified difficulty", (t) => { - t.rejects( - () => - convert({ - info: { - level: OngekiLevel.ONGEKI_LEVEL_UNSPECIFIED, - }, - }), - { - message: /Can't process a score with unspecified difficulty/u, - }, - ); - t.end(); - }); - - t.test("note lamp", async (t) => { - t.hasStrict( - await convert({ - info: { - comboStatus: OngekiComboStatus.ONGEKI_COMBO_STATUS_ALL_BREAK, - clearStatus: OngekiClearStatus.ONGEKI_CLEAR_STATUS_FAILED, - }, - }), - { - dryScore: { scoreData: { noteLamp: "ALL BREAK" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: OngekiComboStatus.ONGEKI_COMBO_STATUS_FULL_COMBO, - clearStatus: OngekiClearStatus.ONGEKI_CLEAR_STATUS_FAILED, - }, - }), - { - dryScore: { scoreData: { noteLamp: "FULL COMBO" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: OngekiComboStatus.ONGEKI_COMBO_STATUS_NONE, - clearStatus: OngekiClearStatus.ONGEKI_CLEAR_STATUS_OVER_DAMAGE, - }, - }), - { - dryScore: { scoreData: { noteLamp: "CLEAR" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: OngekiComboStatus.ONGEKI_COMBO_STATUS_NONE, - clearStatus: OngekiClearStatus.ONGEKI_CLEAR_STATUS_CLEARED, - }, - }), - { - dryScore: { scoreData: { noteLamp: "CLEAR" } }, - }, - ); - t.hasStrict( - await convert({ - info: { - comboStatus: OngekiComboStatus.ONGEKI_COMBO_STATUS_NONE, - clearStatus: OngekiClearStatus.ONGEKI_CLEAR_STATUS_FAILED, - }, - }), - { - dryScore: { scoreData: { noteLamp: "LOSS" } }, - }, - ); - t.end(); - }); - - t.test("Should reject unspecified clear status", (t) => { - t.rejects( - () => - convert({ - info: { - clearStatus: OngekiClearStatus.ONGEKI_CLEAR_STATUS_UNSPECIFIED, - }, - }), - { - message: /Can't process a score with an invalid combo status and\/or clear status/u, - }, - ); - t.end(); - }); - - t.test("Should throw on missing chart", (t) => { - t.rejects(() => convert({ info: { musicId: 999999 } }), { - message: /Can't find chart with id 999999 and difficulty MASTER/u, - }); - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.test.ts b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.test.ts new file mode 100644 index 000000000..77b87b1c4 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.test.ts @@ -0,0 +1,213 @@ +import type { DeepPartial } from "#utils/types"; + +import { log } from "#lib/log/log"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import { + OngekiBattleScoreRank, + OngekiClearStatus, + OngekiComboStatus, + OngekiLevel, + OngekiTechScoreRank, +} from "#proto/generated/ongeki/common_pb"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingOngekiChartConverter, TestingOngekiSong } from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { MytOngekiScore } from "./types"; + +import ConvertAPIMytOngeki from "./converter"; + +const parsedScore = { + playlogApiId: "806ca7ac-76f5-4d99-8760-770df60e1ff5", + info: { + musicId: 678, + level: OngekiLevel.MASTER, + techScore: 1003385, + battleScore: 4987905, + overDamage: 13151, + techScoreRank: OngekiTechScoreRank.SS_PLUS, + battleScoreRank: OngekiBattleScoreRank.GREAT, + comboStatus: OngekiComboStatus.NONE, + clearStatus: OngekiClearStatus.OVER_DAMAGE, + isFullBell: true, + isTechNewRecord: true, + isBattleNewRecord: true, + isOverDamageNewRecord: true, + platinumScore: 893, + userPlayDate: "2022-09-28T12:04:21.400Z", + }, + judge: { + judgeCriticalBreak: 967, + judgeBreak: 19, + judgeHit: 0, + judgeMiss: 5, + maxCombo: 525, + bellCount: 174, + totalBellCount: 174, + damageCount: 0, + }, +} as MytOngekiScore; + +async function seedOngekiConverterFixture() { + await DB.insertInto("song") + .values({ + id: TestingOngekiSong.id, + legacy_id: 678, + game_group: "ongeki", + title: TestingOngekiSong.title, + artist: TestingOngekiSong.artist, + search_terms: TestingOngekiSong.searchTerms, + alt_titles: TestingOngekiSong.altTitles, + data: TestingOngekiSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingOngekiChartConverter.chartID, + legacy_id: TestingOngekiChartConverter.chartID, + game: "ongeki", + song_id: TestingOngekiSong.id, + difficulty: "MASTER", + level: TestingOngekiChartConverter.level, + level_num: TestingOngekiChartConverter.levelNum, + is_primary: TestingOngekiChartConverter.isPrimary, + versions: TestingOngekiChartConverter.versions, + data: TestingOngekiChartConverter.data, + }) + .execute(); +} + +describe("ConvertAPIMytOngeki", () => { + beforeEach(seedOngekiConverterFixture); + + function convert(modifier: DeepPartial = {}) { + return ConvertAPIMytOngeki(dmf(parsedScore, modifier), {}, "api/myt-ongeki", log); + } + + it("returns song, chart, and dryScore for valid input", async () => { + const res = await convert(); + + expect(res).toStrictEqual({ + song: TestingOngekiSong, + chart: TestingOngekiChartConverter, + dryScore: { + service: "MYT", + game: "ongeki", + scoreMeta: {}, + timeAchieved: ParseDateFromString("2022-09-28T12:04:21.400Z"), + comment: null, + importType: "api/myt-ongeki", + scoreData: { + score: 1003385, + noteLamp: "CLEAR", + bellLamp: "FULL BELL", + platinumScore: 893, + judgements: { + cbreak: 967, + break: 19, + hit: 0, + miss: 5, + }, + optional: { + damage: 0, + bellCount: 174, + totalBellCount: 174, + }, + }, + }, + }); + }); + + it("rejects unspecified difficulty", async () => { + await expect( + convert({ + info: { + level: OngekiLevel.UNSPECIFIED, + }, + }), + ).rejects.toMatchObject({ + message: expect.stringMatching( + /Can't process a score with unspecified difficulty/u, + ) as string, + }); + }); + + it("maps note lamp from combo and clear status", async () => { + await expect( + convert({ + info: { + comboStatus: OngekiComboStatus.ALL_BREAK, + clearStatus: OngekiClearStatus.FAILED, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "ALL BREAK" } }, + }); + + await expect( + convert({ + info: { + comboStatus: OngekiComboStatus.FULL_COMBO, + clearStatus: OngekiClearStatus.FAILED, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "FULL COMBO" } }, + }); + + await expect( + convert({ + info: { + comboStatus: OngekiComboStatus.NONE, + clearStatus: OngekiClearStatus.OVER_DAMAGE, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "CLEAR" } }, + }); + + await expect( + convert({ + info: { + comboStatus: OngekiComboStatus.NONE, + clearStatus: OngekiClearStatus.CLEARED, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "CLEAR" } }, + }); + + await expect( + convert({ + info: { + comboStatus: OngekiComboStatus.NONE, + clearStatus: OngekiClearStatus.FAILED, + }, + }), + ).resolves.toMatchObject({ + dryScore: { scoreData: { noteLamp: "LOSS" } }, + }); + }); + + it("rejects invalid combo/clear combination", async () => { + await expect( + convert({ + info: { + clearStatus: OngekiClearStatus.UNSPECIFIED, + }, + }), + ).rejects.toMatchObject({ + message: /Can't process a score with an invalid combo status and\/or clear status/u, + }); + }); + + it("throws when no chart matches", async () => { + await expect(convert({ info: { musicId: 999999 } })).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Can't find chart with id 999999 and difficulty MASTER/u, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts index afe09d6e2..c0c4d8039 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts @@ -1,6 +1,7 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; -import type { MONGO_ScoreData } from "tachi-common"; +import type { ScoreData } from "tachi-common"; import { InternalFailure, @@ -16,7 +17,6 @@ import { import { FindChartOnInGameID } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { MytOngekiScore } from "./types"; const DIFFICULTIES = { @@ -32,7 +32,7 @@ function getNoteLamp( comboStatus: number, clearStatus: number, techScore: number, -): MONGO_ScoreData<"ongeki:Single">["noteLamp"] | undefined { +): ScoreData<"ongeki">["noteLamp"] | undefined { if ( comboStatus === OngekiComboStatus.UNSPECIFIED || clearStatus === OngekiClearStatus.UNSPECIFIED @@ -92,7 +92,7 @@ const ConvertAPIMytOngeki: ConverterFunction = asyn ); } - const chart = await FindChartOnInGameID("ongeki", data.info.musicId, "Single", difficulty); + const chart = await FindChartOnInGameID("ongeki", data.info.musicId, difficulty); if (chart === null) { throw new SongOrChartNotFoundFailure( @@ -103,14 +103,14 @@ const ConvertAPIMytOngeki: ConverterFunction = asyn ); } - const song = await FindSongOnID("ongeki", chart.songID); + const song = await FindSongOnID("ongeki", chart.song.id); if (song === null) { - log.error({ chart }, `Song/chart desync: ${chart.songID} for chart ${chart.chartID}`); - throw new InternalFailure(`Song/chart desync: ${chart.songID} for chart ${chart.chartID}`); + log.error({ chart }, `Song/chart desync: ${chart.song.id} for chart ${chart.chartID}`); + throw new InternalFailure(`Song/chart desync: ${chart.song.id} for chart ${chart.chartID}`); } - const dryScore: DryScore<"ongeki:Single"> = { + const dryScore: DryScore<"ongeki"> = { service: "MYT", game: "ongeki", scoreMeta: {}, diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/parser.ts index 834182538..d0815cc10 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/parser.ts @@ -1,17 +1,19 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; -import type { integer } from "tachi-common"; +import type { GamesForGroup, integer } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { + CreateMytTransport, + FetchMytTitleAPIID, +} from "#lib/score-import/import-types/common/api-myt/traverse-api"; import { GetPlaylogRequestSchema, OngekiUser } from "#proto/generated/ongeki/user_pb"; import { create } from "@bufbuild/protobuf"; import { ConnectError, createClient } from "@connectrpc/connect"; -import type { ParserFunctionReturns } from "../../common/types"; import type { MytOngekiScore } from "./types"; -import { CreateMytTransport, FetchMytTitleAPIID } from "../../common/api-myt/traverse-api"; - async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable { const profileApiId = await FetchMytTitleAPIID(userID, "ongeki", log); const client = createClient(OngekiUser, CreateMytTransport()); @@ -41,11 +43,11 @@ async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable> { +): Promise> { return { iterable: streamPlaylog(userID, log), context: {}, classProvider: null, - game: "ongeki", + gameGroup: "ongeki", }; } diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/class-handler.ts b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/class-handler.ts index 53c5d2303..f97912c2e 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/class-handler.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/class-handler.ts @@ -10,7 +10,7 @@ import { WaccaStageUps } from "tachi-common/config/game-support/wacca"; export default async function CreateMytWACCAClassHandler( titleApiId: string, transport: Transport, -): Promise { +): Promise> { const client = createClient(WaccaUser, transport); const req = create(DataRequestSchema, { apiId: titleApiId }); const data = await client.getData(req); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.oldtest.ts deleted file mode 100644 index 5163a297b..000000000 --- a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.oldtest.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; -import { - WaccaMusicDifficulty, - WaccaMusicScoreGrade, - WaccaPlayMode, -} from "#proto/generated/wacca/common_pb"; -import ResetDBState from "#test-utils/resets"; -import { TestingWaccaPupaExp, TestingWaccaPupaSong } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import type { MytWaccaScore } from "./types"; - -import ConvertAPIMytWACCA from "./converter"; - -const parsedScore: MytWaccaScore = { - musicId: 2085, - musicDifficulty: WaccaMusicDifficulty.WACCA_MUSIC_DIFFICULTY_EXPERT, - score: 996827, - grade: WaccaMusicScoreGrade.WACCA_MUSIC_SCORE_GRADE_SSS_PLUS, - judge: { - marvelous: 909, - great: 4, - good: 1, - miss: 1, - }, - clearStatus: { - isClear: true, - isMissless: true, - isFullCombo: false, - isAllMarvelous: false, - isGiveUp: false, - }, - isNewRecord: false, - combo: 408, - skillPoints: 0, - fast: 4, - late: 1, - userPlayMode: WaccaPlayMode.WACCA_PLAY_MODE_SINGLE, - track: 1, - userPlayDate: "2024-03-23T19:34:10.350+00:00", -}; - -t.test("#ConvertAPIMytWACCA", (t) => { - t.beforeEach(ResetDBState); - - function conv(g: Partial = {}) { - return ConvertAPIMytWACCA(deepmerge(parsedScore, g), {}, "api/myt-wacca", log); - } - - t.test("Should return a dryScore on valid input.", async (t) => { - const res = await conv(); - - t.strictSame(res, { - song: TestingWaccaPupaSong, - chart: TestingWaccaPupaExp, - dryScore: { - service: "MYT", - game: "wacca", - scoreMeta: {}, - timeAchieved: ParseDateFromString("2024-03-23 19:34:10.350 UTC"), - comment: null, - importType: "api/myt-wacca", - scoreData: { - score: 996827, - lamp: "MISSLESS", - judgements: { - marvelous: 909, - great: 4, - good: 1, - miss: 1, - }, - optional: { - fast: 4, - slow: 1, - maxCombo: 408, - }, - }, - }, - }); - - t.end(); - }); - - t.test("Should reject unspecified difficulty", (t) => { - t.rejects( - () => - conv({ musicDifficulty: WaccaMusicDifficulty.WACCA_MUSIC_DIFFICULTY_UNSPECIFIED }), - { - message: /Can't process a score with unspecified difficulty/u, - }, - ); - - t.end(); - }); - - t.test("Should reject unspecified clear status", (t) => { - t.rejects(() => conv({ clearStatus: undefined }), { - message: /Can't process a score without clearStatus/u, - }); - - t.end(); - }); - - t.test("Should throw on missing song", (t) => { - t.rejects(() => conv({ musicId: 999999 }), { - message: /Can't find chart with id 999999 and difficulty EXPERT/u, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.test.ts b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.test.ts new file mode 100644 index 000000000..4fac24539 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.test.ts @@ -0,0 +1,140 @@ +import { log } from "#lib/log/log"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import { + WaccaMusicDifficulty, + WaccaMusicScoreGrade, + WaccaPlayMode, +} from "#proto/generated/wacca/common_pb"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingWaccaPupaExp, TestingWaccaPupaSong } from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { MytWaccaScore } from "./types"; + +import ConvertAPIMytWACCA from "./converter"; + +const parsedScore = { + musicId: 2085, + musicDifficulty: WaccaMusicDifficulty.EXPERT, + score: 996827, + grade: WaccaMusicScoreGrade.SSS_PLUS, + judge: { + marvelous: 909, + great: 4, + good: 1, + miss: 1, + }, + clearStatus: { + isClear: true, + isMissless: true, + isFullCombo: false, + isAllMarvelous: false, + isGiveUp: false, + }, + isNewRecord: false, + combo: 408, + skillPoints: 0, + fast: 4, + late: 1, + userPlayMode: WaccaPlayMode.SINGLE, + track: 1, + userPlayDate: "2024-03-23T19:34:10.350+00:00", +} as MytWaccaScore; + +async function seedPupaExpert() { + await DB.insertInto("song") + .values({ + id: TestingWaccaPupaSong.id, + legacy_id: 2085, + game_group: "wacca", + title: TestingWaccaPupaSong.title, + artist: TestingWaccaPupaSong.artist, + search_terms: TestingWaccaPupaSong.searchTerms, + alt_titles: TestingWaccaPupaSong.altTitles, + data: TestingWaccaPupaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingWaccaPupaExp.chartID, + legacy_id: TestingWaccaPupaExp.chartID, + game: "wacca", + song_id: TestingWaccaPupaSong.id, + difficulty: "EXPERT", + level: TestingWaccaPupaExp.level, + level_num: TestingWaccaPupaExp.levelNum, + is_primary: TestingWaccaPupaExp.isPrimary, + versions: TestingWaccaPupaExp.versions, + data: TestingWaccaPupaExp.data, + }) + .execute(); +} + +describe("ConvertAPIMytWACCA", () => { + beforeEach(seedPupaExpert); + + function conv(g: Partial = {}) { + return ConvertAPIMytWACCA(dmf(parsedScore, g), {}, "api/myt-wacca", log); + } + + it("returns song, chart, and dryScore for valid input", async () => { + const res = await conv(); + + expect(res).toStrictEqual({ + song: TestingWaccaPupaSong, + chart: TestingWaccaPupaExp, + dryScore: { + service: "MYT", + game: "wacca", + scoreMeta: {}, + timeAchieved: ParseDateFromString("2024-03-23 19:34:10.350 UTC"), + comment: null, + importType: "api/myt-wacca", + scoreData: { + score: 996827, + lamp: "MISSLESS", + judgements: { + marvelous: 909, + great: 4, + good: 1, + miss: 1, + }, + optional: { + fast: 4, + slow: 1, + maxCombo: 408, + }, + }, + }, + }); + }); + + it("rejects unspecified difficulty", async () => { + await expect( + conv({ musicDifficulty: WaccaMusicDifficulty.UNSPECIFIED }), + ).rejects.toMatchObject({ + message: expect.stringMatching( + /Can't process a score with unspecified difficulty/u, + ) as string, + }); + }); + + it("rejects missing clearStatus", async () => { + const data = dmf(parsedScore, {}); + delete data.clearStatus; + + await expect(ConvertAPIMytWACCA(data, {}, "api/myt-wacca", log)).rejects.toMatchObject({ + message: /Can't process a score without clearStatus/u, + }); + }); + + it("throws when no chart matches musicId and difficulty", async () => { + await expect(conv({ musicId: 999999 })).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Can't find chart with id 999999 and difficulty EXPERT/u, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.ts b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.ts index 62803900c..687976059 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; import type { Difficulties } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; @@ -13,10 +14,9 @@ import { type WaccaClearStatus, WaccaMusicDifficulty } from "#proto/generated/wa import { FindChartOnInGameID } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { MytWaccaScore } from "./types"; -const DIFFICULTIES: Partial> = { +const DIFFICULTIES: Partial> = { [WaccaMusicDifficulty.UNSPECIFIED]: undefined, [WaccaMusicDifficulty.NORMAL]: "NORMAL", [WaccaMusicDifficulty.HARD]: "HARD", @@ -24,9 +24,7 @@ const DIFFICULTIES: Partial { +function convertClearStatus(status: WaccaClearStatus | undefined): GetEnumValue<"wacca", "lamp"> { if (status === undefined) { throw new InvalidScoreFailure(`Can't process a score without clearStatus`); } @@ -65,7 +63,7 @@ const ConvertAPIMytWACCA: ConverterFunction = async ); } - const chart = await FindChartOnInGameID("wacca", data.musicId, "Single", difficulty); + const chart = await FindChartOnInGameID("wacca", data.musicId, difficulty); if (chart === null) { throw new SongOrChartNotFoundFailure( @@ -76,17 +74,17 @@ const ConvertAPIMytWACCA: ConverterFunction = async ); } - const song = await FindSongOnID("wacca", chart.songID); + const song = await FindSongOnID("wacca", chart.song.id); if (song === null) { - log.error({ chart }, `Song/chart desync: ${chart.songID} for chart ${chart.chartID}`); - throw new InternalFailure(`Song/chart desync: ${chart.songID} for chart ${chart.chartID}`); + log.error({ chart }, `Song/chart desync: ${chart.song.id} for chart ${chart.chartID}`); + throw new InternalFailure(`Song/chart desync: ${chart.song.id} for chart ${chart.chartID}`); } const lamp = convertClearStatus(data.clearStatus); const timeAchieved = ParseDateFromString(data.userPlayDate); - const dryScore: DryScore<"wacca:Single"> = { + const dryScore: DryScore<"wacca"> = { service: "MYT", game: "wacca", scoreMeta: {}, diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts index ff0b56e1b..a3e4d7dd7 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts @@ -1,16 +1,19 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; -import type { integer } from "tachi-common"; +import type { GamesForGroup, integer } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { + CreateMytTransport, + FetchMytTitleAPIID, +} from "#lib/score-import/import-types/common/api-myt/traverse-api"; import { PlaylogRequestSchema, WaccaUser } from "#proto/generated/wacca/user_pb"; import { create } from "@bufbuild/protobuf"; import { ConnectError, createClient } from "@connectrpc/connect"; -import type { ParserFunctionReturns } from "../../common/types"; import type { MytWaccaScore } from "./types"; -import { CreateMytTransport, FetchMytTitleAPIID } from "../../common/api-myt/traverse-api"; import CreateMytWACCAClassHandler from "./class-handler"; async function* streamPlaylog(apiId: string, log: KtLogger): AsyncIterable { @@ -40,7 +43,7 @@ async function* streamPlaylog(apiId: string, log: KtLogger): AsyncIterable> { +): Promise> { const titleApiId = await FetchMytTitleAPIID(userID, "wacca", log); let classProvider; @@ -56,6 +59,6 @@ export default async function ParseMytWACCA( iterable: streamPlaylog(titleApiId, log), context: {}, classProvider, - game: "wacca", + gameGroup: "wacca", }; } diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.oldtest.ts deleted file mode 100644 index 29f7254e4..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.oldtest.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type { DryScore } from "#lib/score-import/framework/common/types"; - -import { log } from "#lib/log/log.js"; -import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; -import { dmf } from "#test-utils/misc"; -import t from "tap"; - -import type { CGContext, CGJubeatScore } from "../types"; - -import { ConverterAPICGJubeat } from "./converter"; - -const BIT_FAILED = 1 << 0; -const BIT_CLEAR = 1 << 1; -const BIT_FULL_COMBO = 1 << 2; -const BIT_EXCELLENT = 1 << 3; - -function mkInput(modifant: Partial = {}) { - const validInput: CGJubeatScore = { - internalId: 10000001, - difficulty: 1, // ADV - version: 9, // festo - dateTime: "2019-06-06 08:14:22", - score: 947_184, - hardMode: false, - perfectCount: 100, - greatCount: 50, - goodCount: 25, - poorCount: 0, - missCount: 0, - musicRate: 952, - clearFlag: BIT_FULL_COMBO | BIT_CLEAR | BIT_FAILED, - }; - - return dmf(validInput, modifant); -} - -// we use any here because it's not easy to allow deep partials -function mkOutput(modifant: any = {}): DryScore<"jubeat:Single"> { - const validOutput: DryScore<"jubeat:Single"> = { - comment: null, - game: "jubeat", - importType: "api/cg-dev-jubeat", - timeAchieved: ParseDateFromString("2019-06-06 08:14:22"), - service: "CG Dev", - scoreData: { - score: 947_184, - lamp: "FULL COMBO", - judgements: { - perfect: 100, - great: 50, - good: 25, - poor: 0, - miss: 0, - }, - musicRate: 95.2, - optional: {}, - }, - scoreMeta: {}, - }; - - return dmf(validOutput, modifant); -} - -t.test("#ConverterAPICGJubeat", (t) => { - const context: CGContext = { - service: "dev", - userID: 1, - }; - const convert = (modifant: Partial = {}) => - ConverterAPICGJubeat(mkInput(modifant), context, "api/cg-dev-jubeat", log); - - t.test("Valid Input", async (t) => { - const res = await convert(); - - t.hasStrict(res, { - song: { - id: 1, - }, - chart: { - difficulty: "ADV", - data: { - inGameID: 10000001, - }, - }, - dryScore: mkOutput(), - }); - - t.end(); - }); - - t.test("Lamp Interpretation from Bitfield", (t) => { - t.test("should interpret EXCELLENT bitfield correctly", async (t) => { - const clearFlag = BIT_EXCELLENT | BIT_FULL_COMBO | BIT_CLEAR | BIT_FAILED; - const score = 1_000_000; - const judgements = { - perfectCount: 200, - greatCount: 0, - goodCount: 0, - poorCount: 0, - missCount: 0, - }; - - t.hasStrict( - await convert({ - clearFlag, - score, - ...judgements, - }), - { - dryScore: mkOutput({ - scoreData: { - lamp: "EXCELLENT", - score, - judgements: { - perfect: 200, - great: 0, - good: 0, - poor: 0, - miss: 0, - }, - }, - }), - }, - ); - }); - - t.test("should interpret FULL COMBO bitfield correctly", async (t) => { - const clearFlag = BIT_FULL_COMBO | BIT_CLEAR | BIT_FAILED; - const judgements = { - perfectCount: 100, - greatCount: 50, - goodCount: 25, - poorCount: 0, - missCount: 0, - }; - const score = 947_184; - - t.hasStrict( - await convert({ - clearFlag, - score, - ...judgements, - }), - { - dryScore: mkOutput({ - scoreData: { - lamp: "FULL COMBO", - score, - judgements: { - perfect: 100, - great: 50, - good: 25, - poor: 0, - miss: 0, - }, - }, - }), - }, - ); - }); - - t.test("should interpret CLEAR bitfield correctly", async (t) => { - const clearFlag = BIT_CLEAR | BIT_FAILED; - const score = 750_000; - const judgements = { - perfectCount: 90, - greatCount: 40, - goodCount: 20, - poorCount: 5, - missCount: 1, - }; - - t.hasStrict( - await convert({ - clearFlag, - score, - ...judgements, - }), - { - dryScore: mkOutput({ - scoreData: { - lamp: "CLEAR", - score, - judgements: { - perfect: 90, - great: 40, - good: 20, - poor: 5, - miss: 1, - }, - }, - }), - }, - ); - }); - - t.test("should interpret FAILED bitfield correctly (standard)", async (t) => { - const clearFlag = BIT_FAILED; - const score = 650_000; - const judgements = { - perfectCount: 80, - greatCount: 30, - goodCount: 15, - poorCount: 10, - missCount: 5, - }; - - t.hasStrict( - await convert({ - clearFlag, - score, - ...judgements, - }), - { - dryScore: mkOutput({ - scoreData: { - lamp: "FAILED", - score, - judgements: { - perfect: 80, - great: 30, - good: 15, - poor: 10, - miss: 5, - }, - }, - }), - }, - ); - }); - - t.test("should interpret FAILED bitfield (case of challenge fail)", async (t) => { - const clearFlag = BIT_FAILED; - const score = 920_000; - const judgements = { - perfectCount: 110, - greatCount: 55, - goodCount: 30, - poorCount: 0, - missCount: 0, - }; - - t.hasStrict( - await convert({ - clearFlag, - score, - ...judgements, - }), - { - dryScore: mkOutput({ - scoreData: { - lamp: "FAILED", - score, - judgements: { - perfect: 110, - great: 55, - good: 30, - poor: 0, - miss: 0, - }, - }, - }), - }, - ); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.test.ts new file mode 100644 index 000000000..d703f7027 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.test.ts @@ -0,0 +1,301 @@ +import type { DryScore } from "#lib/score-import/framework/common/types"; + +import { log } from "#lib/log/log"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingJubeatChart, TestingJubeatSong } from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { CGContext, CGJubeatScore } from "../types"; + +import { ConverterAPICGJubeat } from "./converter"; + +const BIT_FAILED = 1 << 0; +const BIT_CLEAR = 1 << 1; +const BIT_FULL_COMBO = 1 << 2; +const BIT_EXCELLENT = 1 << 3; + +function mkInput(modifant: Partial = {}) { + const validInput: CGJubeatScore = { + internalId: 10000001, + difficulty: 1, + version: 9, + dateTime: "2019-06-06 08:14:22", + score: 947_184, + hardMode: false, + perfectCount: 100, + greatCount: 50, + goodCount: 25, + poorCount: 0, + missCount: 0, + musicRate: 952, + clearFlag: BIT_FULL_COMBO | BIT_CLEAR | BIT_FAILED, + }; + + return dmf(validInput, modifant); +} + +function mkOutput(modifant: Partial> = {}): DryScore<"jubeat"> { + const validOutput: DryScore<"jubeat"> = { + comment: null, + game: "jubeat", + importType: "api/cg-dev-jubeat", + timeAchieved: ParseDateFromString("2019-06-06 08:14:22"), + service: "CG Dev", + scoreData: { + score: 947_184, + lamp: "FULL COMBO", + judgements: { + perfect: 100, + great: 50, + good: 25, + poor: 0, + miss: 0, + }, + musicRate: 95.2, + optional: {}, + }, + scoreMeta: {}, + }; + + return dmf(validOutput, modifant); +} + +async function seedJubeatCgFixture() { + await DB.insertInto("song") + .values({ + id: TestingJubeatSong.id, + legacy_id: 1, + game_group: "jubeat", + title: TestingJubeatSong.title, + artist: TestingJubeatSong.artist, + search_terms: TestingJubeatSong.searchTerms, + alt_titles: TestingJubeatSong.altTitles, + data: TestingJubeatSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingJubeatChart.chartID, + legacy_id: TestingJubeatChart.chartID, + game: "jubeat", + song_id: TestingJubeatSong.id, + difficulty: TestingJubeatChart.difficulty, + level: TestingJubeatChart.level, + level_num: TestingJubeatChart.levelNum, + is_primary: TestingJubeatChart.isPrimary, + versions: TestingJubeatChart.versions, + data: TestingJubeatChart.data, + }) + .execute(); +} + +describe("ConverterAPICGJubeat", () => { + const context: CGContext = { + service: "dev", + userID: 1, + }; + + beforeEach(seedJubeatCgFixture); + + const convert = (modifant: Partial = {}) => + ConverterAPICGJubeat(mkInput(modifant), context, "api/cg-dev-jubeat", log); + + it("converts valid input", async () => { + const res = await convert(); + + expect(res.song).toMatchObject({ id: TestingJubeatSong.id }); + expect(res.chart).toMatchObject({ + difficulty: "ADV", + data: { + inGameID: 10000001, + }, + }); + expect(res.dryScore).toStrictEqual(mkOutput()); + }); + + describe("lamp from clearFlag bitfield", () => { + it("interprets EXCELLENT", async () => { + const clearFlag = BIT_EXCELLENT | BIT_FULL_COMBO | BIT_CLEAR | BIT_FAILED; + const score = 1_000_000; + const judgements = { + perfectCount: 200, + greatCount: 0, + goodCount: 0, + poorCount: 0, + missCount: 0, + }; + + const res = await convert({ + clearFlag, + score, + ...judgements, + }); + + expect(res.dryScore).toStrictEqual( + mkOutput({ + scoreData: { + lamp: "EXCELLENT", + score, + musicRate: 95.2, + judgements: { + perfect: 200, + great: 0, + good: 0, + poor: 0, + miss: 0, + }, + optional: {}, + }, + }), + ); + }); + + it("interprets FULL COMBO", async () => { + const clearFlag = BIT_FULL_COMBO | BIT_CLEAR | BIT_FAILED; + const judgements = { + perfectCount: 100, + greatCount: 50, + goodCount: 25, + poorCount: 0, + missCount: 0, + }; + const score = 947_184; + + const res = await convert({ + clearFlag, + score, + ...judgements, + }); + + expect(res.dryScore).toStrictEqual( + mkOutput({ + scoreData: { + lamp: "FULL COMBO", + score, + musicRate: 95.2, + judgements: { + perfect: 100, + great: 50, + good: 25, + poor: 0, + miss: 0, + }, + optional: {}, + }, + }), + ); + }); + + it("interprets CLEAR", async () => { + const clearFlag = BIT_CLEAR | BIT_FAILED; + const score = 750_000; + const judgements = { + perfectCount: 90, + greatCount: 40, + goodCount: 20, + poorCount: 5, + missCount: 1, + }; + + const res = await convert({ + clearFlag, + score, + ...judgements, + }); + + expect(res.dryScore).toStrictEqual( + mkOutput({ + scoreData: { + lamp: "CLEAR", + score, + musicRate: 95.2, + judgements: { + perfect: 90, + great: 40, + good: 20, + poor: 5, + miss: 1, + }, + optional: {}, + }, + }), + ); + }); + + it("interprets FAILED (standard)", async () => { + const clearFlag = BIT_FAILED; + const score = 650_000; + const judgements = { + perfectCount: 80, + greatCount: 30, + goodCount: 15, + poorCount: 10, + missCount: 5, + }; + + const res = await convert({ + clearFlag, + score, + ...judgements, + }); + + expect(res.dryScore).toStrictEqual( + mkOutput({ + scoreData: { + lamp: "FAILED", + score, + musicRate: 95.2, + judgements: { + perfect: 80, + great: 30, + good: 15, + poor: 10, + miss: 5, + }, + optional: {}, + }, + }), + ); + }); + + it("interprets FAILED (high score / challenge fail)", async () => { + const clearFlag = BIT_FAILED; + const score = 920_000; + const judgements = { + perfectCount: 110, + greatCount: 55, + goodCount: 30, + poorCount: 0, + missCount: 0, + }; + + const res = await convert({ + clearFlag, + score, + ...judgements, + }); + + expect(res.dryScore).toStrictEqual( + mkOutput({ + scoreData: { + lamp: "FAILED", + score, + musicRate: 95.2, + judgements: { + perfect: 110, + great: 55, + good: 30, + poor: 0, + miss: 0, + }, + optional: {}, + }, + }), + ); + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.ts index d845859ca..c0967bdc8 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/jb/converter.ts @@ -1,5 +1,6 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; -import type { Difficulties, integer, Judgements, Versions } from "tachi-common"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; +import type { Difficulties, integer, Versions } from "tachi-common"; import { InternalFailure, @@ -10,7 +11,6 @@ import { ParseDateFromString } from "#lib/score-import/framework/common/score-ut import { FindChartOnInGameID } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../types"; import type { CGContext, CGJubeatScore } from "../types"; import { FormatCGService } from "../util"; @@ -33,7 +33,7 @@ export const ConverterAPICGJubeat: ConverterFunction = }; const lamp = GetLamp(data.clearFlag); - const chart = await FindChartOnInGameID("jubeat", data.internalId, "Single", difficulty); + const chart = await FindChartOnInGameID("jubeat", data.internalId, difficulty); if (!chart) { throw new SongOrChartNotFoundFailure( @@ -44,16 +44,16 @@ export const ConverterAPICGJubeat: ConverterFunction = ); } - const song = await FindSongOnID("jubeat", chart.songID); + const song = await FindSongOnID("jubeat", chart.song.id); if (!song) { - log.error(`Song-Chart desync with song ID ${chart.songID} (jubeat).`); - throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (jubeat).`); + log.error(`Song-Chart desync with song ID ${chart.song.id} (jubeat).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (jubeat).`); } const timeAchieved = ParseDateFromString(data.dateTime); - const dryScore: DryScore<"jubeat:Single"> = { + const dryScore: DryScore<"jubeat"> = { comment: null, game: "jubeat", importType, @@ -72,7 +72,7 @@ export const ConverterAPICGJubeat: ConverterFunction = return { song, chart, dryScore }; }; -function ConvertDifficulty(diff: number, hardMode: boolean): Difficulties["jubeat:Single"] { +function ConvertDifficulty(diff: number, hardMode: boolean): Difficulties["jubeat"] { if (!hardMode) { switch (diff) { case 0: @@ -96,7 +96,7 @@ function ConvertDifficulty(diff: number, hardMode: boolean): Difficulties["jubea throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`); } -function ConvertVersion(ver: number): Versions["jubeat:Single"] { +function ConvertVersion(ver: number): Versions["jubeat"] { switch (ver) { case 1: return "jubeat"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.oldtest.ts deleted file mode 100644 index e5264f8ad..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.oldtest.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { DryScore } from "#lib/score-import/framework/common/types"; - -import { log } from "#lib/log/log.js"; -import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; -import { dmf } from "#test-utils/misc"; -import t from "tap"; - -import type { CGContext, CGMusecaScore } from "../types"; - -import { ConverterAPICGMuseca } from "./converter"; - -function mkInput(modifant: Partial = {}) { - const validInput: CGMusecaScore = { - internalId: 1, - difficulty: 0, - dateTime: "2019-06-06 08:14:22", - score: 912_000, - version: 2, - clearType: 2, - critical: 100, - near: 50, - error: 10, - maxChain: 300, - scoreGrade: "whatever, this is unused", - }; - - return dmf(validInput, modifant); -} - -// we use any here because it's not easy to allow deep partials -function mkOutput(modifant: any = {}): DryScore<"museca:Single"> { - const validOutput: DryScore<"museca:Single"> = { - comment: null, - game: "museca", - importType: "api/cg-dev-museca", - - // we handle it like this because -- with no timezone info - // this will fail in CI; it has a different timezone there! - timeAchieved: ParseDateFromString("2019-06-06 08:14:22"), - service: "CG Dev", - scoreData: { - score: 912_000, - lamp: "CLEAR", - judgements: { - critical: 100, - near: 50, - miss: 10, - }, - optional: {}, - }, - scoreMeta: {}, - }; - - return dmf(validOutput, modifant); -} - -t.test("#ConverterAPICGMuseca", (t) => { - const context: CGContext = { - service: "dev", - userID: 1, - }; - - const convert = (modifant: Partial = {}) => - ConverterAPICGMuseca(mkInput(modifant), context, "api/cg-dev-museca", log); - - t.test("Valid Input", async (t) => { - const res = await convert(); - - t.hasStrict(res, { - song: { - id: 1, - }, - chart: { - difficulty: "Green", - data: { - inGameID: 1, - }, - }, - dryScore: mkOutput(), - }); - - t.end(); - }); - - t.test("Lamps", async (t) => { - t.hasStrict(await convert({ score: 1_000_000 }), { - dryScore: { scoreData: { lamp: "PERFECT CONNECT ALL" } }, - }); - t.hasStrict(await convert({ score: 900_000, error: 0 }), { - dryScore: { scoreData: { lamp: "CONNECT ALL" } }, - }); - t.hasStrict(await convert({ score: 800_000, error: 10 }), { - dryScore: { scoreData: { lamp: "CLEAR" } }, - }); - t.hasStrict(await convert({ score: 700_000, error: 10 }), { - dryScore: { scoreData: { lamp: "FAILED" } }, - }); - t.hasStrict(await convert({ score: 799_999, error: 10 }), { - dryScore: { scoreData: { lamp: "FAILED" } }, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts new file mode 100644 index 000000000..789c0b529 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts @@ -0,0 +1,133 @@ +import type { DryScore } from "#lib/score-import/framework/common/types"; + +import { log } from "#lib/log/log"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingMusecaSong } from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { CGContext, CGMusecaScore } from "../types"; + +import { ConverterAPICGMuseca } from "./converter"; + +const MUSECA_CG_CHART_ID = "c_test_api_cg_museca_green_15b"; + +function mkInput(modifant: Partial = {}) { + const validInput: CGMusecaScore = { + internalId: 1, + difficulty: 0, + dateTime: "2019-06-06 08:14:22", + score: 912_000, + version: 2, + clearType: 2, + critical: 100, + near: 50, + error: 10, + maxChain: 300, + scoreGrade: "whatever, this is unused", + }; + + return dmf(validInput, modifant); +} + +function mkOutput(modifant: Partial> = {}): DryScore<"museca"> { + const validOutput: DryScore<"museca"> = { + comment: null, + game: "museca", + importType: "api/cg-dev-museca", + timeAchieved: ParseDateFromString("2019-06-06 08:14:22"), + service: "CG Dev", + scoreData: { + score: 912_000, + lamp: "CLEAR", + judgements: { + critical: 100, + near: 50, + miss: 10, + }, + optional: { + maxCombo: 300, + }, + }, + scoreMeta: {}, + }; + + return dmf(validOutput, modifant); +} + +async function seedMusecaCgFixture() { + await DB.insertInto("song") + .values({ + id: TestingMusecaSong.id, + legacy_id: 1, + game_group: "museca", + title: TestingMusecaSong.title, + artist: TestingMusecaSong.artist, + search_terms: TestingMusecaSong.searchTerms, + alt_titles: TestingMusecaSong.altTitles, + data: TestingMusecaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: MUSECA_CG_CHART_ID, + legacy_id: MUSECA_CG_CHART_ID, + game: "museca", + song_id: TestingMusecaSong.id, + difficulty: "Green", + level: "3", + level_num: 3, + is_primary: true, + versions: ["1.5-b"], + data: { + inGameID: 1, + }, + }) + .execute(); +} + +describe("ConverterAPICGMuseca", () => { + const context: CGContext = { + service: "dev", + userID: 1, + }; + + beforeEach(seedMusecaCgFixture); + + const convert = (modifant: Partial = {}) => + ConverterAPICGMuseca(mkInput(modifant), context, "api/cg-dev-museca", log); + + it("converts valid input", async () => { + const res = await convert(); + + expect(res.song).toMatchObject({ id: TestingMusecaSong.id }); + expect(res.chart).toMatchObject({ + difficulty: "Green", + data: { + inGameID: 1, + }, + }); + expect(res.dryScore).toStrictEqual(mkOutput()); + }); + + it("derives lamp from score and misses", async () => { + await expect(convert({ score: 1_000_000 })).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "PERFECT CONNECT ALL" } }, + }); + await expect(convert({ score: 900_000, error: 0 })).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "CONNECT ALL" } }, + }); + await expect(convert({ score: 800_000, error: 10 })).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "CLEAR" } }, + }); + await expect(convert({ score: 700_000, error: 10 })).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "FAILED" } }, + }); + await expect(convert({ score: 799_999, error: 10 })).resolves.toMatchObject({ + dryScore: { scoreData: { lamp: "FAILED" } }, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts index 4a89638ce..1d0f63584 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { Difficulties, Versions } from "tachi-common"; import { @@ -10,7 +11,6 @@ import { MusecaGetLamp, ParseDateFromString } from "#lib/score-import/framework/ import { FindChartOnInGameIDVersion } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../types"; import type { CGContext, CGMusecaScore } from "../types"; import { FormatCGService } from "../util"; @@ -24,13 +24,7 @@ export const ConverterAPICGMuseca: ConverterFunction = const difficulty = ConvertDifficulty(data.difficulty); const version = ConvertVersion(data.version); - const chart = await FindChartOnInGameIDVersion( - "museca", - data.internalId, - "Single", - difficulty, - version, - ); + const chart = await FindChartOnInGameIDVersion("museca", data.internalId, difficulty, version); if (!chart) { throw new SongOrChartNotFoundFailure( @@ -41,18 +35,18 @@ export const ConverterAPICGMuseca: ConverterFunction = ); } - const song = await FindSongOnID("museca", chart.songID); + const song = await FindSongOnID("museca", chart.song.id); if (!song) { - log.error(`Song-Chart desync with song ID ${chart.songID} (museca).`); - throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (museca).`); + log.error(`Song-Chart desync with song ID ${chart.song.id} (museca).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (museca).`); } const lamp = MusecaGetLamp(data.score, data.error); const timeAchieved = ParseDateFromString(data.dateTime); - const dryScore: DryScore<"museca:Single"> = { + const dryScore: DryScore<"museca"> = { comment: null, game: "museca", importType, @@ -76,7 +70,7 @@ export const ConverterAPICGMuseca: ConverterFunction = return { song, chart, dryScore }; }; -function ConvertDifficulty(diff: number): Difficulties["museca:Single"] { +function ConvertDifficulty(diff: number): Difficulties["museca"] { switch (diff) { case 0: return "Green"; @@ -89,7 +83,7 @@ function ConvertDifficulty(diff: number): Difficulties["museca:Single"] { throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`); } -function ConvertVersion(ver: number): Versions["museca:Single"] { +function ConvertVersion(ver: number): Versions["museca"] { switch (ver) { case 2: return "1.5-b"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts index 4c5197f07..e025cd4df 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts @@ -5,7 +5,7 @@ import ScoreImportFatalError from "#lib/score-import/framework/score-importing/s import DB from "#services/pg/db"; import fetch from "node-fetch"; import { p, type PrudenceSchema } from "prudence"; -import { FormatPrError, type integer } from "tachi-common"; +import { FormatPrError, type integer, type V3Game } from "tachi-common"; import type { ParserFunctionReturns } from "../types"; import type { @@ -107,7 +107,10 @@ const CG_SCHEMAS: Record = { * identical, this basically just placeholders cgGame and service. */ export function CreateCGParser(cgGame: CGSupportedGames, service: CGServices) { - return async (userID: integer, log: KtLogger): Promise> => { + return async ( + userID: integer, + log: KtLogger, + ): Promise> => { const row = await DB.selectFrom("priv_svc_cg_card_info") .select(SELECT_CG_CARD_INFO) .where("user_id", "=", userID) @@ -139,7 +142,7 @@ export function CreateCGParser(cgGame: CGSupportedGames, service: CGServices) service, userID: cardInfo.userID, }, - game: CGGameToTachiGame(cgGame), + gameGroup: CGGameToTachiGame(cgGame), iterable: scores as Array, classProvider: null, }; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.oldtest.ts deleted file mode 100644 index 449567094..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.oldtest.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { DryScore } from "#lib/score-import/framework/common/types"; - -import { log } from "#lib/log/log.js"; -import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; -import { dmf } from "#test-utils/misc"; -import t from "tap"; - -import type { CGContext, CGPopnScore } from "../types"; - -import { ConverterAPICGPopn } from "./converter"; - -function mkInput(modifant: Partial = {}) { - const validInput: CGPopnScore = { - internalId: 0, // this is "i really wanna hurt you" - difficulty: 0, - coolCount: 100, - badCount: 50, - greatCount: 15, - goodCount: 25, - clearFlag: 5, // clear, - dateTime: "2019-06-06 08:14:22", - score: 87_000, - version: 25, - }; - - return dmf(validInput, modifant); -} - -// we use any here because it's not easy to allow deep partials -function mkOutput(modifant: any = {}): DryScore<"popn:9B"> { - const validOutput: DryScore<"popn:9B"> = { - comment: null, - game: "popn", - importType: "api/cg-dev-popn", - - // we handle it like this because -- with no timezone info - // this will fail in CI; it has a different timezone there! - timeAchieved: ParseDateFromString("2019-06-06 08:14:22"), - service: "CG Dev", - scoreData: { - clearMedal: "clearCircle", - score: 87000, - judgements: { - cool: 100, - great: 15, - good: 25, - bad: 50, - }, - optional: {}, - }, - scoreMeta: {}, - }; - - return dmf(validOutput, modifant); -} - -t.test("#ConverterAPICGPopn", (t) => { - const context: CGContext = { - service: "dev", - userID: 1, - }; - - const convert = (modifant: Partial = {}) => - ConverterAPICGPopn(mkInput(modifant), context, "api/cg-dev-popn", log); - - t.test("Valid Input", async (t) => { - const res = await convert(); - - t.hasStrict(res, { - song: { - id: 1, - }, - chart: { - difficulty: "Easy", - data: { - inGameID: 0, - }, - }, - dryScore: mkOutput(), - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts new file mode 100644 index 000000000..7c3ad9b24 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts @@ -0,0 +1,114 @@ +import type { DryScore } from "#lib/score-import/framework/common/types"; + +import { log } from "#lib/log/log"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingPopnSong } from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { CGContext, CGPopnScore } from "../types"; + +import { ConverterAPICGPopn } from "./converter"; + +const POPN_CG_CHART_ID = "c_test_api_cg_popn_easy_peace"; + +function mkInput(modifant: Partial = {}) { + const validInput: CGPopnScore = { + internalId: 0, + difficulty: 0, + coolCount: 100, + badCount: 50, + greatCount: 15, + goodCount: 25, + clearFlag: 5, + dateTime: "2019-06-06 08:14:22", + score: 87_000, + version: 25, + }; + + return dmf(validInput, modifant); +} + +function mkOutput(modifant: Partial> = {}): DryScore<"popn"> { + const validOutput: DryScore<"popn"> = { + comment: null, + game: "popn", + importType: "api/cg-dev-popn", + timeAchieved: ParseDateFromString("2019-06-06 08:14:22"), + service: "CG Dev", + scoreData: { + clearMedal: "clearCircle", + score: 87_000, + judgements: { + cool: 100, + great: 15, + good: 25, + bad: 50, + }, + optional: {}, + }, + scoreMeta: {}, + }; + + return dmf(validOutput, modifant); +} + +async function seedPopnCgFixture() { + await DB.insertInto("song") + .values({ + id: TestingPopnSong.id, + legacy_id: 1, + game_group: "popn", + title: TestingPopnSong.title, + artist: TestingPopnSong.artist, + search_terms: TestingPopnSong.searchTerms, + alt_titles: TestingPopnSong.altTitles, + data: TestingPopnSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: POPN_CG_CHART_ID, + legacy_id: POPN_CG_CHART_ID, + game: "popn", + song_id: TestingPopnSong.id, + difficulty: "Easy", + level: "5", + level_num: 5, + is_primary: true, + versions: ["peace"], + data: { + inGameID: 0, + hashSHA256: "0000000000000000000000000000000000000000000000000000000000000000", + }, + }) + .execute(); +} + +describe("ConverterAPICGPopn", () => { + const context: CGContext = { + service: "dev", + userID: 1, + }; + + beforeEach(seedPopnCgFixture); + + const convert = (modifant: Partial = {}) => + ConverterAPICGPopn(mkInput(modifant), context, "api/cg-dev-popn", log); + + it("converts valid input", async () => { + const res = await convert(); + + expect(res.song).toMatchObject({ id: TestingPopnSong.id }); + expect(res.chart).toMatchObject({ + difficulty: "Easy", + data: { + inGameID: 0, + }, + }); + expect(res.dryScore).toStrictEqual(mkOutput()); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts index fd40fcca6..812542f60 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { Difficulties, integer, Versions } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; @@ -12,7 +13,6 @@ import { ParseDateFromString } from "#lib/score-import/framework/common/score-ut import { FindChartOnInGameIDVersion } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../types"; import type { CGContext, CGPopnScore } from "../types"; import { FormatCGService } from "../util"; @@ -30,13 +30,7 @@ export const ConverterAPICGPopn: ConverterFunction = asy throw new InvalidScoreFailure(`Score is > 100_000 (got ${data.score})`); } - const chart = await FindChartOnInGameIDVersion( - "popn", - data.internalId, - "9B", - difficulty, - version, - ); + const chart = await FindChartOnInGameIDVersion("popn", data.internalId, difficulty, version); if (!chart) { throw new SongOrChartNotFoundFailure( @@ -47,18 +41,18 @@ export const ConverterAPICGPopn: ConverterFunction = asy ); } - const song = await FindSongOnID("popn", chart.songID); + const song = await FindSongOnID("popn", chart.song.id); if (!song) { - log.error(`Song-Chart desync with song ID ${chart.songID} (popn).`); - throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (popn).`); + log.error(`Song-Chart desync with song ID ${chart.song.id} (popn).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (popn).`); } const clearMedal = GetClearMedal(data.clearFlag); const timeAchieved = ParseDateFromString(data.dateTime); - const dryScore: DryScore<"popn:9B"> = { + const dryScore: DryScore<"popn"> = { comment: null, game: "popn", importType, @@ -81,7 +75,7 @@ export const ConverterAPICGPopn: ConverterFunction = asy return { song, chart, dryScore }; }; -function ConvertDifficulty(diff: number): Difficulties["popn:9B"] { +function ConvertDifficulty(diff: number): Difficulties["popn"] { switch (diff) { case 0: return "Easy"; @@ -99,7 +93,7 @@ function ConvertDifficulty(diff: number): Difficulties["popn:9B"] { throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`); } -function ConvertVersion(ver: number): Versions["popn:9B"] { +function ConvertVersion(ver: number): Versions["popn"] { switch (ver) { case 27: return "unilab"; @@ -112,7 +106,7 @@ function ConvertVersion(ver: number): Versions["popn:9B"] { throw new InvalidScoreFailure(`Unknown/Unsupported Game Version ${ver}.`); } -function GetClearMedal(clearFlag: integer): GetEnumValue<"popn:9B", "clearMedal"> { +function GetClearMedal(clearFlag: integer): GetEnumValue<"popn", "clearMedal"> { switch (clearFlag) { case 1: return "failedCircle"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.oldtest.ts deleted file mode 100644 index ada615faf..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.oldtest.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { DryScore } from "#lib/score-import/framework/common/types"; - -import { log } from "#lib/log/log.js"; -import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; -import { dmf } from "#test-utils/misc"; -import t from "tap"; - -import type { CGContext, CGSDVXScore } from "../types"; - -import { ConverterAPICGSDVX } from "./converter"; - -function mkInput(modifant: Partial = {}) { - const validInput: CGSDVXScore = { - internalId: 1, // ALBIDA (Remix) ADV - difficulty: 1, - dateTime: "2019-06-06 08:14:22", - score: 9_123_000, - version: 6, - clearType: 2, - critical: 100, - near: 50, - error: 10, - exScore: 1234, - maxChain: 300, - scoreGrade: "whatever, this is unused", - }; - - return dmf(validInput, modifant); -} - -// we use any here because it's not easy to allow deep partials -function mkOutput(modifant: any = {}): DryScore<"sdvx:Single"> { - const validOutput: DryScore<"sdvx:Single"> = { - comment: null, - game: "sdvx", - importType: "api/cg-dev-sdvx", - - // we handle it like this because -- with no timezone info - // this will fail in CI; it has a different timezone there! - timeAchieved: ParseDateFromString("2019-06-06 08:14:22"), - service: "CG Dev", - scoreData: { - score: 9_123_000, - lamp: "CLEAR", - judgements: { - critical: 100, - near: 50, - miss: 10, - }, - optional: {}, - }, - scoreMeta: {}, - }; - - return dmf(validOutput, modifant); -} - -t.test("#ConverterAPICGSDVX", (t) => { - const context: CGContext = { - service: "dev", - userID: 1, - }; - - const convert = (modifant: Partial = {}) => - ConverterAPICGSDVX(mkInput(modifant), context, "api/cg-dev-sdvx", log); - - t.test("Valid Input", async (t) => { - const res = await convert(); - - t.hasStrict(res, { - song: { - id: 1, - }, - chart: { - difficulty: "ADV", - data: { - inGameID: 1, - }, - }, - dryScore: mkOutput(), - }); - - t.end(); - }); - - t.test("Lamps", async (t) => { - t.hasStrict(await convert({ clearType: 1 }), { - dryScore: mkOutput({ scoreData: { lamp: "FAILED" } }), - }); - t.hasStrict(await convert({ clearType: 2 }), { - dryScore: mkOutput({ scoreData: { lamp: "CLEAR" } }), - }); - t.hasStrict(await convert({ clearType: 3 }), { - dryScore: mkOutput({ scoreData: { lamp: "EXCESSIVE CLEAR" } }), - }); - t.hasStrict(await convert({ clearType: 4 }), { - dryScore: mkOutput({ scoreData: { lamp: "ULTIMATE CHAIN" } }), - }); - t.hasStrict(await convert({ clearType: 5 }), { - dryScore: mkOutput({ scoreData: { lamp: "PERFECT ULTIMATE CHAIN" } }), - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts new file mode 100644 index 000000000..f89aafaad --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts @@ -0,0 +1,141 @@ +import type { DryScore } from "#lib/score-import/framework/common/types"; + +import { log } from "#lib/log/log"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingAlbidaADV, TestingSDVXAlbidaSong } from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { CGContext, CGSDVXScore } from "../types"; + +import { ConverterAPICGSDVX } from "./converter"; + +function mkInput(modifant: Partial = {}) { + const validInput: CGSDVXScore = { + internalId: 1, + difficulty: 1, + dateTime: "2019-06-06 08:14:22", + score: 9_123_000, + version: 6, + clearType: 2, + critical: 100, + near: 50, + error: 10, + exScore: 1234, + maxChain: 300, + scoreGrade: "whatever, this is unused", + }; + + return dmf(validInput, modifant); +} + +function mkOutput(modifant: Partial> = {}): DryScore<"sdvx"> { + const validOutput: DryScore<"sdvx"> = { + comment: null, + game: "sdvx", + importType: "api/cg-dev-sdvx", + timeAchieved: ParseDateFromString("2019-06-06 08:14:22"), + service: "CG Dev", + scoreData: { + score: 9_123_000, + lamp: "CLEAR", + judgements: { + critical: 100, + near: 50, + miss: 10, + }, + optional: { + maxCombo: 300, + exScore: 1234, + }, + }, + scoreMeta: {}, + }; + + return dmf(validOutput, modifant); +} + +async function seedSdvxCgFixture() { + await DB.insertInto("song") + .values({ + id: TestingSDVXAlbidaSong.id, + legacy_id: 1, + game_group: "sdvx", + title: TestingSDVXAlbidaSong.title, + artist: TestingSDVXAlbidaSong.artist, + search_terms: TestingSDVXAlbidaSong.searchTerms, + alt_titles: TestingSDVXAlbidaSong.altTitles, + data: TestingSDVXAlbidaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingAlbidaADV.chartID, + legacy_id: TestingAlbidaADV.chartID, + game: "sdvx", + song_id: TestingSDVXAlbidaSong.id, + difficulty: "ADV", + level: TestingAlbidaADV.level, + level_num: TestingAlbidaADV.levelNum, + is_primary: TestingAlbidaADV.isPrimary, + versions: TestingAlbidaADV.versions, + data: TestingAlbidaADV.data, + }) + .execute(); +} + +describe("ConverterAPICGSDVX", () => { + const context: CGContext = { + service: "dev", + userID: 1, + }; + + beforeEach(seedSdvxCgFixture); + + const convert = (modifant: Partial = {}) => + ConverterAPICGSDVX(mkInput(modifant), context, "api/cg-dev-sdvx", log); + + it("converts valid input", async () => { + const res = await convert(); + + expect(res.song).toMatchObject({ id: TestingSDVXAlbidaSong.id }); + expect(res.chart).toMatchObject({ + difficulty: "ADV", + data: { + inGameID: 1, + }, + }); + expect(res.dryScore).toStrictEqual(mkOutput()); + }); + + it("maps clearType to lamp", async () => { + const lampOnly = (lamp: DryScore<"sdvx">["scoreData"]["lamp"]) => + mkOutput({ + scoreData: { + score: 9_123_000, + lamp, + judgements: { critical: 100, near: 50, miss: 10 }, + optional: { maxCombo: 300, exScore: 1234 }, + }, + }); + + await expect(convert({ clearType: 1 })).resolves.toMatchObject({ + dryScore: lampOnly("FAILED"), + }); + await expect(convert({ clearType: 2 })).resolves.toMatchObject({ + dryScore: lampOnly("CLEAR"), + }); + await expect(convert({ clearType: 3 })).resolves.toMatchObject({ + dryScore: lampOnly("EXCESSIVE CLEAR"), + }); + await expect(convert({ clearType: 4 })).resolves.toMatchObject({ + dryScore: lampOnly("ULTIMATE CHAIN"), + }); + await expect(convert({ clearType: 5 })).resolves.toMatchObject({ + dryScore: lampOnly("PERFECT ULTIMATE CHAIN"), + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts index 26ab0ceac..d978f2832 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { Versions } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; @@ -11,7 +12,6 @@ import { ParseDateFromString } from "#lib/score-import/framework/common/score-ut import { FindSDVXChartOnInGameIDVersion } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../types"; import type { CGContext, CGSDVXScore } from "../types"; import { FormatCGService } from "../util"; @@ -36,18 +36,18 @@ export const ConverterAPICGSDVX: ConverterFunction = asy ); } - const song = await FindSongOnID("sdvx", chart.songID); + const song = await FindSongOnID("sdvx", chart.song.id); if (!song) { - log.error(`Song-Chart desync with song ID ${chart.songID} (sdvx).`); - throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (sdvx).`); + log.error(`Song-Chart desync with song ID ${chart.song.id} (sdvx).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (sdvx).`); } const lamp = ConvertCGSDVXLamp(data.clearType); const timeAchieved = ParseDateFromString(data.dateTime); - const dryScore: DryScore<"sdvx:Single"> = { + const dryScore: DryScore<"sdvx"> = { comment: null, game: "sdvx", importType, @@ -91,7 +91,7 @@ function ConvertDifficulty(diff: number) { throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`); } -function ConvertVersion(ver: number): Versions["sdvx:Single"] { +function ConvertVersion(ver: number): Versions["sdvx"] { switch (ver) { case 1: return "booth"; @@ -114,7 +114,7 @@ function ConvertVersion(ver: number): Versions["sdvx:Single"] { * Convert CG's clearType enum into a Tachi lamp. Note that what numbers mean what are * dependent on what version of the game we're listening for. */ -function ConvertCGSDVXLamp(clearType: number): GetEnumValue<"sdvx:Single", "lamp"> { +function ConvertCGSDVXLamp(clearType: number): GetEnumValue<"sdvx", "lamp"> { switch (clearType) { case 1: return "FAILED"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts index 20325b440..cf8a5aff6 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts @@ -1,5 +1,5 @@ import type { KtLogger } from "#lib/log/log"; -import type { MONGO_CGCardInfo } from "tachi-common"; +import type { CGCardInfo } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { ServerConfig } from "#lib/setup/config"; @@ -33,7 +33,7 @@ const PR_CG_ERR_RESPONSE: PrudenceSchema = { */ export async function FetchCGScores( service: CGServices, - cardInfo: MONGO_CGCardInfo, + cardInfo: CGCardInfo, game: CGSupportedGames, log: KtLogger, fetch: NodeFetch = nodeFetch, @@ -102,7 +102,7 @@ function GetCGConf(service: CGServices) { return conf; } -function GetCGUrl(service: CGServices, cardInfo: MONGO_CGCardInfo, game: CGSupportedGames) { +function GetCGUrl(service: CGServices, cardInfo: CGCardInfo, game: CGSupportedGames) { const cgConf = GetCGConf(service); const params = new URLSearchParams(); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts similarity index 58% rename from typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.oldtest.ts rename to typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts index 6eb871372..273f3af94 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.oldtest.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts @@ -1,44 +1,13 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { MockBasicFetch, MockJSONFetch } from "#test-utils/mock-fetch"; -import ResetDBState from "#test-utils/resets"; import { IIDX_DANS } from "tachi-common"; -import t from "tap"; +import { describe, expect, it } from "vitest"; import { KaiTypeToBaseURL } from "../utils"; import { CreateKaiIIDXClassProvider } from "./class-handler"; -t.test("#CreateKaiIIDXClassProvider", async (t) => { - t.beforeEach(ResetDBState); - - const fn = await CreateKaiIIDXClassProvider( - "FLO", - "token", - - async () => { - throw new Error(`Unexpectedly called reauthFn?`); - }, - MockJSONFetch({ - [`${KaiTypeToBaseURL("FLO")}/api/iidx/v2/player_profile`]: { - _links: {}, - iidx_id: 12345678, - dj_name: "SOMEONE", - sp: 18, - dp: 4, - access_time: "2021-08-08T18:50:40Z", - register_time: "2019-01-19T12:53:50Z", - }, - }), - ); - - t.test("Should call the provided URL with the authentication token", (t) => { - const res = fn("iidx:SP", 1, {}, log); - - t.strictSame(res, { dan: "KAIDEN" }); - - t.end(); - }); - - t.test("Should return nothing if dan is not a number", async (t) => { +describe("CreateKaiIIDXClassProvider", () => { + it("maps SP dan from the player profile", async () => { const fn = await CreateKaiIIDXClassProvider( "FLO", "token", @@ -49,7 +18,33 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/iidx/v2/player_profile`]: { _links: {}, - iidx_id: 12345678, + iidx_id: 12_345_678, + dj_name: "SOMEONE", + sp: 18, + dp: 4, + access_time: "2021-08-08T18:50:40Z", + register_time: "2019-01-19T12:53:50Z", + }, + }), + ); + + const res = fn("iidx-sp", 1, {}, log); + + expect(res).toStrictEqual({ dan: "KAIDEN" }); + }); + + it("returns nothing if dan is not a number", async () => { + const fn = await CreateKaiIIDXClassProvider( + "FLO", + "token", + + async () => { + throw new Error(`Unexpectedly called reauthFn?`); + }, + MockJSONFetch({ + [`${KaiTypeToBaseURL("FLO")}/api/iidx/v2/player_profile`]: { + _links: {}, + iidx_id: 12_345_678, dj_name: "SOMEONE", sp: "NOT A NUMBER", dp: 4, @@ -59,14 +54,12 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { }), ); - const res = fn("iidx:SP", 1, {}, log); + const res = fn("iidx-sp", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should return nothing if dan is too great", async (t) => { + it("returns nothing if dan is too great", async () => { const fn = await CreateKaiIIDXClassProvider( "FLO", "token", @@ -77,7 +70,7 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/iidx/v2/player_profile`]: { _links: {}, - iidx_id: 12345678, + iidx_id: 12_345_678, dj_name: "SOMEONE", sp: IIDX_DANS.KAIDEN + 1, dp: 4, @@ -87,14 +80,12 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { }), ); - const res = fn("iidx:SP", 1, {}, log); + const res = fn("iidx-sp", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should return nothing if dan is negative", async (t) => { + it("returns nothing if dan is negative", async () => { const fn = await CreateKaiIIDXClassProvider( "FLO", "token", @@ -105,7 +96,7 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/iidx/v2/player_profile`]: { _links: {}, - iidx_id: 12345678, + iidx_id: 12_345_678, dj_name: "SOMEONE", sp: IIDX_DANS.KYU_7 - 1, dp: 4, @@ -115,14 +106,12 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { }), ); - const res = fn("iidx:SP", 1, {}, log); + const res = fn("iidx-sp", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should gracefully handle negative API responses", async (t) => { + it("returns nothing on HTTP errors", async () => { const fn = await CreateKaiIIDXClassProvider( "FLO", "token", @@ -133,14 +122,12 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { MockBasicFetch({ status: 500 }), ); - const res = fn("iidx:SP", 1, {}, log); + const res = fn("iidx-sp", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should call reauthFn if statusCode is 401", async (t) => { + it("calls reauthFn on 401", async () => { let pass = false; const fn = await CreateKaiIIDXClassProvider( "FLO", @@ -153,14 +140,12 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { MockBasicFetch({ status: 401 }), ); - fn("iidx:SP", 1, {}, log); + fn("iidx-sp", 1, {}, log); - t.equal(pass, true, "Should've called the reauth fn."); - - t.end(); + expect(pass).toBe(true); }); - t.test("Should ignore null dans", async (t) => { + it("returns nothing when the requested side has null dans", async () => { const fn = await CreateKaiIIDXClassProvider( "FLO", "token", @@ -171,7 +156,7 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/iidx/v2/player_profile`]: { _links: {}, - iidx_id: 12345678, + iidx_id: 12_345_678, dj_name: "SOMEONE", sp: 1, dp: null, @@ -181,14 +166,12 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { }), ); - const res = fn("iidx:DP", 1, {}, log); + const res = fn("iidx-dp", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should handle invalid playtypes", async (t) => { + it("throws on non-iidx games (exhaustive switch)", async () => { const fn = await CreateKaiIIDXClassProvider( "FLO", "token", @@ -199,7 +182,7 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/iidx/v2/player_profile`]: { _links: {}, - iidx_id: 12345678, + iidx_id: 12_345_678, dj_name: "SOMEONE", sp: 1, dp: 1, @@ -209,12 +192,6 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { }), ); - const res = fn("bms:14K", 1, {}, log); - - t.strictSame(res, {}); - - t.end(); + expect(() => fn("bms:14K" as never, 1, {}, log)).toThrow(/unreachable/u); }); - - t.end(); }); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.ts index 98e901c02..bba2aace9 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.ts @@ -1,8 +1,8 @@ import type { ClassProvider } from "#lib/score-import/framework/calculated-data/types"; import nodeFetch from "#utils/fetch"; -import { IsRecord } from "#utils/misc"; -import { IIDX_DANS } from "tachi-common"; +import { IsRecord, staticAssertUnreachable } from "#utils/misc"; +import { type GamesForGroup, IIDX_DANS } from "tachi-common"; import { IIDXDans } from "tachi-common/config/game-support/iidx"; import type { KaiAPIReauthFunction } from "../traverse-api"; @@ -14,7 +14,7 @@ export async function CreateKaiIIDXClassProvider( token: string, reauthFn: KaiAPIReauthFunction, fetch = nodeFetch, -): Promise { +): Promise> { let json: unknown; let err: unknown; const baseUrl = KaiTypeToBaseURL(kaiType); @@ -52,7 +52,7 @@ export async function CreateKaiIIDXClassProvider( err = e; } - return (gptString, userID, ratings, log) => { + return (game, _userID, _ratings, log) => { if (err !== undefined) { log.error({ err }, `An error occured while updating classes for ${baseUrl}.`); return {}; @@ -70,13 +70,15 @@ export async function CreateKaiIIDXClassProvider( let maybeIIDXDan: unknown; - if (gptString === "iidx:SP") { - maybeIIDXDan = json.sp; - } else if (gptString === "iidx:DP") { - maybeIIDXDan = json.dp; - } else { - log.warn(`KAIIIDXClassUpdater called with invalid gptString of ${gptString}.`); - return {}; + switch (game) { + case "iidx-sp": + maybeIIDXDan = json.sp; + break; + case "iidx-dp": + maybeIIDXDan = json.dp; + break; + default: + staticAssertUnreachable(game); } if ( @@ -84,7 +86,7 @@ export async function CreateKaiIIDXClassProvider( maybeIIDXDan === undefined || typeof maybeIIDXDan !== "number" ) { - log.info(`User has no ${gptString} dan. Not updating anything.`); + log.info(`User has no ${game} dan. Not updating anything.`); return {}; } diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.oldtest.ts deleted file mode 100644 index d10e44afa..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.oldtest.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { LoadTachiIIDXData, Testing511Song, Testing511SPA } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import { ConvertAPIKaiIIDX } from "./converter"; - -const iidxScore = { - chart_id: 3848, - music_id: 1000, - music_difficulty: 2, - play_style: "SINGLE", - difficulty: "ANOTHER", - iidx_id: 35247879, - version_played: 26, - lamp: 5, - ex_score: 1570, - grade: "AA", - miss_count: 24, - fast_count: null, - slow_count: null, - timestamp: "2020-10-31T19:10:50Z", - _id: 189232, -}; - -t.test("#ConvertAPIKaiIIDX", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a dryScore on valid input.", async (t) => { - const res = await ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", log); - - t.hasStrict(res, { - song: Testing511Song, - chart: Testing511SPA, - dryScore: { - comment: null, - game: "iidx", - importType: "api/flo-iidx", - timeAchieved: 1604171450000, - service: "FLO", - scoreData: { - score: 1570, - lamp: "HARD CLEAR", - judgements: {}, - optional: { - fast: null, - slow: null, - bp: 24, - }, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should convert a legacy leggendaria songID.", async (t) => { - await LoadTachiIIDXData(); - - const res = await ConvertAPIKaiIIDX( - deepmerge(iidxScore, { music_id: 24101 }), - { service: "FLO" }, - "api/flo-iidx", - log, - ); - - t.hasStrict(res, { - song: { - title: "冬椿 ft. Kanae Asaba", - }, - chart: { - difficulty: "LEGGENDARIA", - data: { - inGameID: 24011, - }, - }, - dryScore: { - comment: null, - game: "iidx", - importType: "api/flo-iidx", - timeAchieved: 1604171450000, - service: "FLO", - scoreData: { - score: 1570, - lamp: "HARD CLEAR", - judgements: {}, - optional: { - fast: null, - slow: null, - bp: 24, - }, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should throw SongOrChartNotFound on unknown chart", (t) => { - t.rejects( - () => - ConvertAPIKaiIIDX( - deepmerge(iidxScore, { music_id: 0 }), - { service: "FLO" }, - "api/flo-iidx", - log, - ), - { - message: /Could not find chart with songID 0 \(SP ANOTHER - Version 26\)/u, - }, - ); - - t.end(); - }); - - t.test("Should throw InvalidScoreFailure on invalid score", (t) => { - t.rejects( - () => - ConvertAPIKaiIIDX( - deepmerge(iidxScore, { music_id: "foo" }), - { service: "FLO" }, - "api/flo-iidx", - log, - ), - { - message: - /Error: music_id.*Expected a positive integer.* received foo \[type: string\]/iu, - }, - ); - - t.end(); - }); - - t.test("Should throw InternalFailure on song-chart desync", async (t) => { - await MONGODB_KILL.songs.iidx.remove({}); - - t.rejects(() => ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", log), { - message: /Song-Chart desync/u, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.test.ts new file mode 100644 index 000000000..1e2c1405d --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.test.ts @@ -0,0 +1,183 @@ +import { log } from "#lib/log/log"; +import DB from "#services/pg/db"; +import { Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { describe, expect, it } from "vitest"; + +import { ConvertAPIKaiIIDX } from "./converter"; + +const LEG_SONG_ID = "s_kai_iidx_leg_test"; +const LEG_CHART_ID = "c_kai_iidx_leg_test"; + +const iidxScore = { + chart_id: 3848, + music_id: 1000, + music_difficulty: 2, + play_style: "SINGLE", + difficulty: "ANOTHER", + iidx_id: 35247879, + version_played: 26, + lamp: 5, + ex_score: 1570, + grade: "AA", + miss_count: 24, + fast_count: null, + slow_count: null, + timestamp: "2020-10-31T19:10:50Z", + _id: 189232, +}; + +async function seed511Another26() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: "ANOTHER", + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +async function seedLeggendariaFixture() { + await DB.insertInto("song") + .values({ + id: LEG_SONG_ID, + legacy_id: 24_011, + game_group: "iidx", + title: "冬椿 ft. Kanae Asaba", + artist: "artist", + search_terms: [], + alt_titles: [], + data: { displayVersion: "1", genre: "TEST" }, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: LEG_CHART_ID, + legacy_id: LEG_CHART_ID, + game: "iidx-sp", + song_id: LEG_SONG_ID, + difficulty: "LEGGENDARIA", + level: "12", + level_num: 12, + is_primary: true, + versions: ["26"], + data: { + inGameID: 24_011, + notecount: 100, + "2dxtraSet": null, + }, + }) + .execute(); +} + +describe("ConvertAPIKaiIIDX", () => { + it("returns song, chart, and dryScore for valid input", async () => { + await seed511Another26(); + + const res = await ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", log); + + expect(res.song).toMatchObject(Testing511Song); + expect(res.chart).toMatchObject({ + chartID: Testing511SPA.chartID, + difficulty: "ANOTHER", + }); + expect(res.dryScore).toStrictEqual({ + comment: null, + game: "iidx-sp", + importType: "api/flo-iidx", + timeAchieved: 1604171450000, + service: "FLO", + scoreData: { + score: 1570, + lamp: "HARD CLEAR", + judgements: {}, + optional: { + fast: null, + slow: null, + bp: 24, + }, + }, + scoreMeta: {}, + }); + }); + + it("converts legacy Leggendaria music_id via lookup table", async () => { + await seed511Another26(); + await seedLeggendariaFixture(); + + const res = await ConvertAPIKaiIIDX( + deepmerge(iidxScore, { music_id: 24101 }), + { service: "FLO" }, + "api/flo-iidx", + log, + ); + + expect(res.song).toMatchObject({ title: "冬椿 ft. Kanae Asaba" }); + expect(res.chart).toMatchObject({ + difficulty: "LEGGENDARIA", + data: { + inGameID: 24_011, + }, + }); + expect(res.dryScore.game).toBe("iidx-sp"); + const sd = res.dryScore.scoreData as { lamp: string; score: number }; + expect(sd.score).toBe(1570); + expect(sd.lamp).toBe("HARD CLEAR"); + }); + + it("throws when chart is missing", async () => { + await seed511Another26(); + + await expect( + ConvertAPIKaiIIDX( + deepmerge(iidxScore, { music_id: 0 }), + { service: "FLO" }, + "api/flo-iidx", + log, + ), + ).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Could not find chart with songID 0 \(iidx-sp ANOTHER - Version 26\)/u, + }); + }); + + it("rejects invalid music_id type", async () => { + await seed511Another26(); + + await expect( + ConvertAPIKaiIIDX( + deepmerge(iidxScore, { music_id: "foo" }), + { service: "FLO" }, + "api/flo-iidx", + log, + ), + ).rejects.toMatchObject({ + message: expect.stringMatching( + /music_id.*Expected a positive integer.*foo/iu, + ) as string, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.ts index 45f1eff7e..158a51dc7 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.ts @@ -1,22 +1,21 @@ -import type { integer, Versions } from "tachi-common"; +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup, integer, Versions } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; -import { FormatPrError } from "#utils/prudence"; -import { FindIIDXChartOnInGameIDVersion } from "#utils/queries/charts"; -import { FindSongOnID } from "#utils/queries/songs"; -import { p } from "prudence"; - -import type { DryScore } from "../../../../framework/common/types"; -import type { ConverterFunction } from "../../types"; -import type { KaiContext, KaiIIDXScore } from "../types"; - import { InternalFailure, InvalidScoreFailure, SkipScoreFailure, SongOrChartNotFoundFailure, -} from "../../../../framework/common/converter-failures"; -import { ParseDateFromString } from "../../../../framework/common/score-utils"; +} from "#lib/score-import/framework/common/converter-failures"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import { FormatPrError } from "#utils/prudence"; +import { FindIIDXChartOnInGameIDVersion } from "#utils/queries/charts"; +import { FindSongOnID } from "#utils/queries/songs"; +import { p } from "prudence"; + +import type { KaiContext, KaiIIDXScore } from "../types"; const PR_KAI_IIDX_SCORE = { music_id: p.isPositiveInteger, @@ -33,7 +32,7 @@ const PR_KAI_IIDX_SCORE = { function ResolveKaiLamp( lamp: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7, -): GetEnumValue<"iidx:DP" | "iidx:SP", "lamp"> { +): GetEnumValue { switch (lamp) { case 0: return "NO PLAY"; @@ -212,7 +211,7 @@ export const ConvertAPIKaiIIDX: ConverterFunction = async ( // prudence checks this above. const score = data as KaiIIDXScore; - const playtype = score.play_style === "SINGLE" ? "SP" : "DP"; + const game = score.play_style === "SINGLE" ? "iidx-sp" : "iidx-dp"; const version = ConvertVersion(score.version_played); @@ -229,36 +228,31 @@ export const ConvertAPIKaiIIDX: ConverterFunction = async ( throw new SkipScoreFailure(`We don't support BEGINNER charts. Sorry!`); } - const chart = await FindIIDXChartOnInGameIDVersion( - musicID, - playtype, - score.difficulty, - version, - ); + const chart = await FindIIDXChartOnInGameIDVersion(game, musicID, score.difficulty, version); if (!chart) { throw new SongOrChartNotFoundFailure( - `Could not find chart with songID ${musicID} (${playtype} ${score.difficulty} - Version ${score.version_played})`, + `Could not find chart with songID ${musicID} (${game} ${score.difficulty} - Version ${score.version_played})`, importType, data, context, ); } - const song = await FindSongOnID("iidx", chart.songID); + const song = await FindSongOnID("iidx", chart.song.id); if (!song) { - log.error(`Song-Chart desync with song ID ${chart.songID} (iidx).`); - throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (iidx).`); + log.error(`Song-Chart desync with song ID ${chart.song.id} (iidx).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (iidx).`); } const lamp = ResolveKaiLamp(score.lamp); const timeAchieved = ParseDateFromString(score.timestamp); - const dryScore: DryScore<"iidx:DP" | "iidx:SP"> = { + const dryScore: DryScore = { comment: null, - game: "iidx", + game, importType, timeAchieved, service: context.service, @@ -278,7 +272,7 @@ export const ConvertAPIKaiIIDX: ConverterFunction = async ( return { song, chart, dryScore }; }; -function ConvertVersion(version: integer): Versions["iidx:DP" | "iidx:SP"] { +function ConvertVersion(version: integer): Versions[GamesForGroup["iidx"]] { switch (version) { case 20: return "20"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.test.ts similarity index 64% rename from typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.oldtest.ts rename to typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.test.ts index 36b7d670f..af1035239 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.oldtest.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.test.ts @@ -1,7 +1,6 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { MockJSONFetch } from "#test-utils/mock-fetch"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; +import { describe, expect, it } from "vitest"; import { ParseKaiIIDX } from "./parser"; @@ -14,10 +13,8 @@ const fakeAuth = { const NO_REAUTH = () => Promise.resolve(""); -t.test("#ParseKaiIIDX", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should iterate over the IIDX FLO API asynchronously.", async (t) => { +describe("ParseKaiIIDX", () => { + it("iterates FLO play_history pages", async () => { const mockFloAPI = MockJSONFetch({ "https://flo.example.com/api/iidx/v2/play_history": { _links: { @@ -35,21 +32,19 @@ t.test("#ParseKaiIIDX", (t) => { const res = await ParseKaiIIDX("FLO", fakeAuth, log, mockFloAPI, NO_REAUTH); - t.equal(res.game, "iidx"); - t.strictSame(res.context, { service: "FLO" }); + expect(res.gameGroup).toBe("iidx"); + expect(res.context).toStrictEqual({ service: "FLO" }); - const iter = []; + const iter: Array = []; for await (const el of res.iterable) { - iter.push(el); + iter.push(el as number); } - t.strictSame(iter, [1, 2, 3, 4, 5, 6]); - - t.end(); + expect(iter).toStrictEqual([1, 2, 3, 4, 5, 6]); }); - t.test("Should iterate over the IIDX EAG API asynchronously.", async (t) => { + it("iterates EAG play_history pages", async () => { const mockEagAPI = MockJSONFetch({ "https://eag.example.com/api/iidx/v2/play_history": { _links: { @@ -67,19 +62,15 @@ t.test("#ParseKaiIIDX", (t) => { const res = await ParseKaiIIDX("EAG", fakeAuth, log, mockEagAPI, NO_REAUTH); - t.equal(res.game, "iidx"); - t.strictSame(res.context, { service: "EAG" }); + expect(res.gameGroup).toBe("iidx"); + expect(res.context).toStrictEqual({ service: "EAG" }); - const iter = []; + const iter: Array = []; for await (const el of res.iterable) { - iter.push(el); + iter.push(el as number); } - t.strictSame(iter, [1, 2, 3, 4, 5, 6]); - - t.end(); + expect(iter).toStrictEqual([1, 2, 3, 4, 5, 6]); }); - - t.end(); }); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts index 72d0953a6..8b7c9a403 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts @@ -1,9 +1,9 @@ import type { KtLogger } from "#lib/log/log"; -import type { MONGO_KaiAuthDocument } from "tachi-common"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup, KaiAuthDocument } from "tachi-common"; import nodeFetch from "#utils/fetch"; -import type { ParserFunctionReturns } from "../../types"; import type { KaiContext } from "../types"; import { CreateKaiReauthFunction } from "../reauth"; @@ -13,11 +13,11 @@ import { CreateKaiIIDXClassProvider } from "./class-handler"; export async function ParseKaiIIDX( service: "EAG" | "FLO", - authDoc: MONGO_KaiAuthDocument, + authDoc: KaiAuthDocument, log: KtLogger, fetch = nodeFetch, reauthFn: (() => Promise) | null = null, -): Promise> { +): Promise> { const baseUrl = KaiTypeToBaseURL(service); const resolvedReauthFn = reauthFn ?? CreateKaiReauthFunction(service, authDoc, log, fetch); @@ -43,6 +43,6 @@ export async function ParseKaiIIDX( resolvedReauthFn, fetch, ), - game: "iidx", + gameGroup: "iidx", }; } diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts index 5713ada9b..019f8f528 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts @@ -1,4 +1,4 @@ -import type { MONGO_KaiAuthDocument } from "tachi-common"; +import type { KaiAuthDocument } from "tachi-common"; import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; @@ -36,7 +36,7 @@ describe("#CreateKaiReauthFunction", () => { ); } - const authDoc: MONGO_KaiAuthDocument = { + const authDoc: KaiAuthDocument = { refreshToken: "REFRESH_TOKEN", service: "FLO", token: "foobar", @@ -75,7 +75,7 @@ describe("#CreateKaiReauthFunction", () => { ); } - const authDoc: MONGO_KaiAuthDocument = { + const authDoc: KaiAuthDocument = { refreshToken: "REFRESH_TOKEN", service: "FLO", token: "foobar", @@ -107,7 +107,7 @@ describe("#CreateKaiReauthFunction", () => { ); } - const authDoc: MONGO_KaiAuthDocument = { + const authDoc: KaiAuthDocument = { refreshToken: "REFRESH_TOKEN", service: "FLO", token: "foobar", diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts index a087114bd..3c3d90095 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts @@ -1,5 +1,5 @@ import type { KtLogger } from "#lib/log/log"; -import type { MONGO_KaiAuthDocument } from "tachi-common"; +import type { KaiAuthDocument } from "tachi-common"; import { updateKaiAuthTokensInDb } from "#lib/kai-auth-token/persist"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; @@ -17,7 +17,7 @@ const REAUTH_SCHEMA = { export function CreateKaiReauthFunction( kaiType: "EAG" | "FLO" | "MIN", - authDoc: MONGO_KaiAuthDocument, + authDoc: KaiAuthDocument, log: KtLogger, fetch = nodeFetch, ) { diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts similarity index 56% rename from typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.oldtest.ts rename to typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts index 295d712c5..90b34dcb7 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.oldtest.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts @@ -1,43 +1,13 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { MockBasicFetch, MockJSONFetch } from "#test-utils/mock-fetch"; -import ResetDBState from "#test-utils/resets"; import { SDVX_DANS } from "tachi-common"; -import t from "tap"; +import { describe, expect, it } from "vitest"; import { KaiTypeToBaseURL } from "../utils"; import { CreateKaiSDVXClassProvider } from "./class-handler"; -t.test("#CreateKaiSDVXClassProvider", async (t) => { - t.beforeEach(ResetDBState); - - const fn = await CreateKaiSDVXClassProvider( - "FLO", - "token", - - async () => { - throw new Error(`Unexpectedly called reauthFn?`); - }, - MockJSONFetch({ - [`${KaiTypeToBaseURL("FLO")}/api/sdvx/v1/player_profile`]: { - _links: {}, - sdvx_id: 12345678, - name: "SOMEONE", - skill_level: 10, - access_time: "2019-08-26T18:22:36Z", - register_time: "2019-08-26T18:22:36Z", - }, - }), - ); - - t.test("Should call the provided URL with the authentication token", (t) => { - const res = fn("sdvx:Single", 1, {}, log); - - t.strictSame(res, { dan: "DAN_10" }); - - t.end(); - }); - - t.test("Should return nothing if dan is not a number", async (t) => { +describe("CreateKaiSDVXClassProvider", () => { + it("maps skill_level to dan id", async () => { const fn = await CreateKaiSDVXClassProvider( "FLO", "token", @@ -48,7 +18,32 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/sdvx/v1/player_profile`]: { _links: {}, - sdvx_id: 12345678, + sdvx_id: 12_345_678, + name: "SOMEONE", + skill_level: 10, + access_time: "2019-08-26T18:22:36Z", + register_time: "2019-08-26T18:22:36Z", + }, + }), + ); + + const res = fn("sdvx", 1, {}, log); + + expect(res).toStrictEqual({ dan: "DAN_10" }); + }); + + it("returns nothing if dan is not a number", async () => { + const fn = await CreateKaiSDVXClassProvider( + "FLO", + "token", + + async () => { + throw new Error(`Unexpectedly called reauthFn?`); + }, + MockJSONFetch({ + [`${KaiTypeToBaseURL("FLO")}/api/sdvx/v1/player_profile`]: { + _links: {}, + sdvx_id: 12_345_678, name: "SOMEONE", skill_level: "NOT A NUMBER", access_time: "2019-08-26T18:22:36Z", @@ -57,14 +52,12 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { }), ); - const res = fn("sdvx:Single", 1, {}, log); + const res = fn("sdvx", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should return nothing if dan is too great", async (t) => { + it("returns nothing if dan is too great", async () => { const fn = await CreateKaiSDVXClassProvider( "FLO", "token", @@ -75,7 +68,7 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/sdvx/v1/player_profile`]: { _links: {}, - sdvx_id: 12345678, + sdvx_id: 12_345_678, name: "SOMEONE", skill_level: SDVX_DANS.INF + 2, access_time: "2019-08-26T18:22:36Z", @@ -84,14 +77,12 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { }), ); - const res = fn("sdvx:Single", 1, {}, log); + const res = fn("sdvx", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should return nothing if dan is negative", async (t) => { + it("returns nothing if dan is negative", async () => { const fn = await CreateKaiSDVXClassProvider( "FLO", "token", @@ -102,7 +93,7 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/sdvx/v1/player_profile`]: { _links: {}, - sdvx_id: 12345678, + sdvx_id: 12_345_678, name: "SOMEONE", skill_level: -1, access_time: "2019-08-26T18:22:36Z", @@ -111,14 +102,12 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { }), ); - const res = fn("sdvx:Single", 1, {}, log); + const res = fn("sdvx", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should gracefully handle negative API responses", async (t) => { + it("returns nothing on HTTP errors", async () => { const fn = await CreateKaiSDVXClassProvider( "FLO", "token", @@ -129,14 +118,12 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { MockBasicFetch({ status: 500 }), ); - const res = fn("sdvx:Single", 1, {}, log); + const res = fn("sdvx", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - t.test("Should call reauthFn if statusCode is 401", async (t) => { + it("calls reauthFn on 401", async () => { let pass = false; const fn = await CreateKaiSDVXClassProvider( "FLO", @@ -149,14 +136,12 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { MockBasicFetch({ status: 401 }), ); - fn("sdvx:Single", 1, {}, log); + fn("sdvx", 1, {}, log); - t.equal(pass, true, "Should've called the reauth fn."); - - t.end(); + expect(pass).toBe(true); }); - t.test("Should ignore null dans", async (t) => { + it("returns nothing when skill_level is null", async () => { const fn = await CreateKaiSDVXClassProvider( "FLO", "token", @@ -167,7 +152,7 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { MockJSONFetch({ [`${KaiTypeToBaseURL("FLO")}/api/sdvx/v1/player_profile`]: { _links: {}, - sdvx_id: 12345678, + sdvx_id: 12_345_678, name: "SOMEONE", skill_level: null, access_time: "2019-08-26T18:22:36Z", @@ -176,12 +161,8 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { }), ); - const res = fn("sdvx:Single", 1, {}, log); + const res = fn("sdvx", 1, {}, log); - t.strictSame(res, {}); - - t.end(); + expect(res).toStrictEqual({}); }); - - t.end(); }); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.ts index 6c814d92a..62cd01aca 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.ts @@ -14,7 +14,7 @@ export async function CreateKaiSDVXClassProvider( token: string, reauthFn: KaiAPIReauthFunction, fetch = nodeFetch, -): Promise { +): Promise> { let json: unknown; let err: unknown; const baseUrl = KaiTypeToBaseURL(kaiType); @@ -44,7 +44,7 @@ export async function CreateKaiSDVXClassProvider( err = e; } - return (gptString, userID, ratings, log) => { + return (_game, _userID, _ratings, log) => { log.info( { json, diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.oldtest.ts deleted file mode 100644 index 3fb727dd6..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.oldtest.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { TestingAlbidaADV, TestingSDVXAlbidaSong } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import { ConvertAPIKaiSDVX, ConvertDifficulty, ConvertVersion, ResolveKaiLamp } from "./converter"; - -const sdvxScore = { - sdvx_id: 32157055, - music_id: 1, - music_difficulty: 1, - - // exg - played_version: 6, - - clear_type: 2, - max_chain: 179, - score: 9310699, - critical: 1754, - near: 112, - error: 78, - early: 70, - late: 42, - gauge_type: 0, - gauge_rate: 90.01, - timestamp: "2020-08-30T13:08:11Z", - _id: 127108, -}; - -t.test("#ConvertAPIKaiSDVX", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a dryScore on valid input.", async (t) => { - const res = await ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", log); - - t.hasStrict(res, { - song: TestingSDVXAlbidaSong, - chart: TestingAlbidaADV, - dryScore: { - comment: null, - game: "sdvx", - importType: "api/flo-sdvx", - timeAchieved: 1598792891000, - service: "FLO", - scoreData: { - score: 9310699, - lamp: "CLEAR", - judgements: { - critical: 1754, - near: 112, - miss: 78, - }, - optional: { - fast: 70, - slow: 42, - gauge: 90.01, - maxCombo: 179, - }, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should throw SongOrChartNotFound on unknown chart", (t) => { - t.rejects( - () => - ConvertAPIKaiSDVX( - deepmerge(sdvxScore, { music_id: 0 }), - { service: "FLO" }, - "api/flo-sdvx", - log, - ), - { - message: /Could not find chart with songID 0 \(ADV - Version exceed\)/u, - }, - ); - - t.end(); - }); - - t.test("Should throw InvalidScoreFailure on invalid score", (t) => { - t.rejects( - () => - ConvertAPIKaiSDVX( - deepmerge(sdvxScore, { music_id: "foo" }), - { service: "FLO" }, - "api/flo-sdvx", - log, - ), - { - message: - /Error: music_id.*Expected a positive integer.* received foo \[type: string\]/iu, - }, - ); - - t.end(); - }); - - t.test("Should throw InternalFailure on song-chart desync", async (t) => { - await MONGODB_KILL.songs.sdvx.remove({}); - - t.rejects(() => ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", log), { - message: /Song-Chart desync/u, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("#ConvertDifficulty", (t) => { - t.equal(ConvertDifficulty(0), "NOV"); - t.equal(ConvertDifficulty(1), "ADV"); - t.equal(ConvertDifficulty(2), "EXH"); - t.equal(ConvertDifficulty(3), "ANY_INF"); - t.equal(ConvertDifficulty(4), "MXM"); - t.throws(() => ConvertDifficulty(5)); - - t.end(); -}); - -t.test("#ConvertVersion", (t) => { - t.equal(ConvertVersion(1), "booth"); - t.equal(ConvertVersion(2), "inf"); - t.equal(ConvertVersion(3), "gw"); - t.equal(ConvertVersion(4), "heaven"); - t.equal(ConvertVersion(5), "vivid"); - t.equal(ConvertVersion(6), "exceed"); - t.throws(() => ConvertVersion(7)); - t.throws(() => ConvertVersion(0)); - - t.end(); -}); - -t.test("#ResolveKaiLamp", (t) => { - t.equal(ResolveKaiLamp(1), "FAILED"); - t.equal(ResolveKaiLamp(2), "CLEAR"); - t.equal(ResolveKaiLamp(3), "EXCESSIVE CLEAR"); - t.equal(ResolveKaiLamp(4), "ULTIMATE CHAIN"); - t.equal(ResolveKaiLamp(5), "PERFECT ULTIMATE CHAIN"); - t.equal(ResolveKaiLamp(6), "MAXXIVE CLEAR"); - t.throws(() => ResolveKaiLamp(7)); - t.throws(() => ResolveKaiLamp(0)); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.test.ts new file mode 100644 index 000000000..1192e5a39 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.test.ts @@ -0,0 +1,161 @@ +import { log } from "#lib/log/log"; +import { InvalidScoreFailure } from "#lib/score-import/framework/common/converter-failures"; +import DB from "#services/pg/db"; +import { TestingAlbidaADV, TestingSDVXAlbidaSong } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ConvertAPIKaiSDVX, ConvertDifficulty, ConvertVersion, ResolveKaiLamp } from "./converter"; + +const sdvxScore = { + sdvx_id: 32157055, + music_id: 1, + music_difficulty: 1, + played_version: 6, + clear_type: 2, + max_chain: 179, + score: 9310699, + critical: 1754, + near: 112, + error: 78, + early: 70, + late: 42, + gauge_type: 0, + gauge_rate: 90.01, + timestamp: "2020-08-30T13:08:11Z", + _id: 127108, +}; + +async function seedAlbidaAdvExceed() { + await DB.insertInto("song") + .values({ + id: TestingSDVXAlbidaSong.id, + legacy_id: 1, + game_group: "sdvx", + title: TestingSDVXAlbidaSong.title, + artist: TestingSDVXAlbidaSong.artist, + search_terms: TestingSDVXAlbidaSong.searchTerms, + alt_titles: TestingSDVXAlbidaSong.altTitles, + data: TestingSDVXAlbidaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingAlbidaADV.chartID, + legacy_id: TestingAlbidaADV.chartID, + game: "sdvx", + song_id: TestingSDVXAlbidaSong.id, + difficulty: "ADV", + level: TestingAlbidaADV.level, + level_num: TestingAlbidaADV.levelNum, + is_primary: TestingAlbidaADV.isPrimary, + versions: TestingAlbidaADV.versions, + data: TestingAlbidaADV.data, + }) + .execute(); +} + +describe("ConvertAPIKaiSDVX", () => { + beforeEach(seedAlbidaAdvExceed); + + it("returns song, chart, and dryScore for valid input", async () => { + const res = await ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", log); + + expect(res.song).toMatchObject({ id: TestingSDVXAlbidaSong.id }); + expect(res.chart).toMatchObject({ + difficulty: "ADV", + data: { inGameID: 1 }, + }); + expect(res.dryScore).toStrictEqual({ + comment: null, + game: "sdvx", + importType: "api/flo-sdvx", + timeAchieved: 1598792891000, + service: "FLO", + scoreData: { + score: 9310699, + lamp: "CLEAR", + judgements: { + critical: 1754, + near: 112, + miss: 78, + }, + optional: { + fast: 70, + slow: 42, + gauge: 90.01, + maxCombo: 179, + }, + }, + scoreMeta: {}, + }); + }); + + it("throws when chart is missing", async () => { + await expect( + ConvertAPIKaiSDVX( + deepmerge(sdvxScore, { music_id: 0 }), + { service: "FLO" }, + "api/flo-sdvx", + log, + ), + ).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Could not find chart with songID 0 \(ADV - Version exceed\)/u, + }); + }); + + it("rejects invalid music_id type", async () => { + await expect( + ConvertAPIKaiSDVX( + deepmerge(sdvxScore, { music_id: "foo" }), + { service: "FLO" }, + "api/flo-sdvx", + log, + ), + ).rejects.toMatchObject({ + message: expect.stringMatching( + /music_id.*Expected a positive integer.*foo/iu, + ) as string, + }); + }); +}); + +describe("ConvertDifficulty (Kai SDVX)", () => { + it("maps difficulty indices", () => { + expect(ConvertDifficulty(0)).toBe("NOV"); + expect(ConvertDifficulty(1)).toBe("ADV"); + expect(ConvertDifficulty(2)).toBe("EXH"); + expect(ConvertDifficulty(3)).toBe("ANY_INF"); + expect(ConvertDifficulty(4)).toBe("MXM"); + expect(() => ConvertDifficulty(5)).toThrow(InvalidScoreFailure); + }); +}); + +describe("ConvertVersion (Kai SDVX)", () => { + it("maps version indices", () => { + expect(ConvertVersion(1)).toBe("booth"); + expect(ConvertVersion(2)).toBe("inf"); + expect(ConvertVersion(3)).toBe("gw"); + expect(ConvertVersion(4)).toBe("heaven"); + expect(ConvertVersion(5)).toBe("vivid"); + expect(ConvertVersion(6)).toBe("exceed"); + expect(() => ConvertVersion(7)).toThrow(InvalidScoreFailure); + expect(() => ConvertVersion(0)).toThrow(InvalidScoreFailure); + }); +}); + +describe("ResolveKaiLamp (SDVX)", () => { + it("maps clear_type to lamp", () => { + expect(ResolveKaiLamp(1)).toBe("FAILED"); + expect(ResolveKaiLamp(2)).toBe("CLEAR"); + expect(ResolveKaiLamp(3)).toBe("EXCESSIVE CLEAR"); + expect(ResolveKaiLamp(4)).toBe("ULTIMATE CHAIN"); + expect(ResolveKaiLamp(5)).toBe("PERFECT ULTIMATE CHAIN"); + expect(ResolveKaiLamp(6)).toBe("MAXXIVE CLEAR"); + expect(() => ResolveKaiLamp(7)).toThrow(InvalidScoreFailure); + expect(() => ResolveKaiLamp(0)).toThrow(InvalidScoreFailure); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.ts index 12dd53e3d..707b16b3f 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.ts @@ -1,21 +1,20 @@ +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { Versions } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; -import { FormatPrError } from "#utils/prudence"; -import { FindSDVXChartOnInGameIDVersion } from "#utils/queries/charts"; -import { FindSongOnID } from "#utils/queries/songs"; -import { p } from "prudence"; - -import type { DryScore } from "../../../../framework/common/types"; -import type { ConverterFunction } from "../../types"; -import type { KaiContext, KaiSDVXScore } from "../types"; - import { InternalFailure, InvalidScoreFailure, SongOrChartNotFoundFailure, -} from "../../../../framework/common/converter-failures"; -import { ParseDateFromString } from "../../../../framework/common/score-utils"; +} from "#lib/score-import/framework/common/converter-failures"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import { FormatPrError } from "#utils/prudence"; +import { FindSDVXChartOnInGameIDVersion } from "#utils/queries/charts"; +import { FindSongOnID } from "#utils/queries/songs"; +import { p } from "prudence"; + +import type { KaiContext, KaiSDVXScore } from "../types"; const PR_KAI_SDVX_SCORE = { music_id: p.isPositiveInteger, @@ -62,18 +61,18 @@ export const ConvertAPIKaiSDVX: ConverterFunction = async ( ); } - const song = await FindSongOnID("sdvx", chart.songID); + const song = await FindSongOnID("sdvx", chart.song.id); if (!song) { - log.error(`Song-Chart desync with song ID ${chart.songID} (sdvx).`); - throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (sdvx).`); + log.error(`Song-Chart desync with song ID ${chart.song.id} (sdvx).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (sdvx).`); } const lamp = ResolveKaiLamp(score.clear_type); const timeAchieved = ParseDateFromString(score.timestamp); - const dryScore: DryScore<"sdvx:Single"> = { + const dryScore: DryScore<"sdvx"> = { comment: null, game: "sdvx", importType, @@ -117,7 +116,7 @@ export function ConvertDifficulty(diff: number) { throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`); } -export function ConvertVersion(ver: number): Versions["sdvx:Single"] { +export function ConvertVersion(ver: number): Versions["sdvx"] { switch (ver) { case 1: return "booth"; @@ -136,7 +135,7 @@ export function ConvertVersion(ver: number): Versions["sdvx:Single"] { throw new InvalidScoreFailure(`Unknown Game Version ${ver}.`); } -export function ResolveKaiLamp(clear: number): GetEnumValue<"sdvx:Single", "lamp"> { +export function ResolveKaiLamp(clear: number): GetEnumValue<"sdvx", "lamp"> { switch (clear) { case 1: return "FAILED"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.test.ts similarity index 64% rename from typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.oldtest.ts rename to typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.test.ts index f4f646bf3..592cf4c85 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.oldtest.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.test.ts @@ -1,7 +1,6 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { MockJSONFetch } from "#test-utils/mock-fetch"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; +import { describe, expect, it } from "vitest"; import { ParseKaiSDVX } from "./parser"; @@ -14,10 +13,8 @@ const fakeAuth = { const NO_REAUTH = () => Promise.resolve(""); -t.test("#ParseKaiSDVX", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should iterate over the SDVX FLO API asynchronously.", async (t) => { +describe("ParseKaiSDVX", () => { + it("iterates FLO play_history pages", async () => { const mockFloAPI = MockJSONFetch({ "https://flo.example.com/api/sdvx/v1/play_history": { _links: { @@ -35,21 +32,19 @@ t.test("#ParseKaiSDVX", (t) => { const res = await ParseKaiSDVX("FLO", fakeAuth, log, mockFloAPI, NO_REAUTH); - t.equal(res.game, "sdvx"); - t.strictSame(res.context, { service: "FLO" }); + expect(res.gameGroup).toBe("sdvx"); + expect(res.context).toStrictEqual({ service: "FLO" }); - const iter = []; + const iter: Array = []; for await (const el of res.iterable) { - iter.push(el); + iter.push(el as number); } - t.strictSame(iter, [1, 2, 3, 4, 5, 6]); - - t.end(); + expect(iter).toStrictEqual([1, 2, 3, 4, 5, 6]); }); - t.test("Should iterate over the SDVX EAG API asynchronously.", async (t) => { + it("iterates EAG play_history pages", async () => { const mockEagAPI = MockJSONFetch({ "https://eag.example.com/api/sdvx/v1/play_history": { _links: { @@ -67,19 +62,15 @@ t.test("#ParseKaiSDVX", (t) => { const res = await ParseKaiSDVX("EAG", fakeAuth, log, mockEagAPI, NO_REAUTH); - t.equal(res.game, "sdvx"); - t.strictSame(res.context, { service: "EAG" }); + expect(res.gameGroup).toBe("sdvx"); + expect(res.context).toStrictEqual({ service: "EAG" }); - const iter = []; + const iter: Array = []; for await (const el of res.iterable) { - iter.push(el); + iter.push(el as number); } - t.strictSame(iter, [1, 2, 3, 4, 5, 6]); - - t.end(); + expect(iter).toStrictEqual([1, 2, 3, 4, 5, 6]); }); - - t.end(); }); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts index 106008513..3e309e5d9 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts @@ -1,9 +1,9 @@ import type { KtLogger } from "#lib/log/log"; -import type { MONGO_KaiAuthDocument } from "tachi-common"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup, KaiAuthDocument } from "tachi-common"; import nodeFetch from "#utils/fetch"; -import type { ParserFunctionReturns } from "../../types"; import type { KaiContext } from "../types"; import { CreateKaiReauthFunction } from "../reauth"; @@ -13,11 +13,11 @@ import { CreateKaiSDVXClassProvider } from "./class-handler"; export async function ParseKaiSDVX( service: "EAG" | "FLO" | "MIN", - authDoc: MONGO_KaiAuthDocument, + authDoc: KaiAuthDocument, log: KtLogger, fetch = nodeFetch, reauthFn: KaiAPIReauthFunction | null = null, -): Promise> { +): Promise> { const baseUrl = KaiTypeToBaseURL(service); const resolvedReauthFn = reauthFn ?? CreateKaiReauthFunction(service, authDoc, log, fetch); @@ -43,6 +43,6 @@ export async function ParseKaiSDVX( resolvedReauthFn, fetch, ), - game: "sdvx", + gameGroup: "sdvx", }; } diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.test.ts similarity index 64% rename from typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.oldtest.ts rename to typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.test.ts index ff0fe8308..cd6d21cd8 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.oldtest.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.test.ts @@ -1,20 +1,16 @@ import type { NodeFetch } from "#utils/fetch"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { agta } from "#test-utils/misc"; import { MockBasicFetch, MockJSONFetch } from "#test-utils/mock-fetch"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; +import { describe, expect, it } from "vitest"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import { TraverseKaiAPI } from "./traverse-api"; const fakeAuth = "bar"; -t.test("#TraverseKaiAPI", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should traverse the API", async (t) => { +describe("TraverseKaiAPI", () => { + it("traverses paginated _items", async () => { const mockKaiAPI = MockJSONFetch({ "http://url.com/sub": { _links: { @@ -34,12 +30,10 @@ t.test("#TraverseKaiAPI", (t) => { const elements = await agta(res); - t.strictSame(elements, [1, 2, 3, 4, 5, 6]); - - t.end(); + expect(elements).toStrictEqual([1, 2, 3, 4, 5, 6]); }); - t.test("Should throw on attempted SSRF", (t) => { + it("throws when _next origin does not match base URL (SSRF guard)", async () => { const mockKaiAPI = MockJSONFetch({ "http://url.com/sub": { _links: { @@ -56,36 +50,30 @@ t.test("#TraverseKaiAPI", (t) => { }); const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI); - t.rejects( - () => agta(res), - new ScoreImportFatalError(500, `http://url.com returned invalid data.`), - ); - - t.end(); + await expect(agta(res)).rejects.toMatchObject({ + statusCode: 500, + message: "http://url.com returned invalid data.", + }); }); - t.test("Should throw on invalid response JSON", (t) => { + it("rejects on invalid JSON", async () => { const mockKaiAPI = (() => ({ json: null })) as unknown as NodeFetch; const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI); - t.rejects(() => agta(res)); - - t.end(); + await expect(agta(res)).rejects.toBeDefined(); }); - t.test("Should throw on request failure", (t) => { + it("rejects when fetch throws", async () => { const mockKaiAPI = () => { throw new Error("Fake Request timeout..."); }; const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI); - t.rejects(() => agta(res)); - - t.end(); + await expect(agta(res)).rejects.toBeDefined(); }); - t.test("Should throw on invalid _links", (t) => { + it("rejects on invalid _links shape", async () => { const mockKaiAPI = MockJSONFetch({ "http://url.com/sub": { _links: null, @@ -94,7 +82,7 @@ t.test("#TraverseKaiAPI", (t) => { const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI); - t.rejects(() => agta(res)); + await expect(agta(res)).rejects.toBeDefined(); const mockKaiAPI2 = MockJSONFetch({ "http://url.com/sub": { @@ -104,12 +92,10 @@ t.test("#TraverseKaiAPI", (t) => { const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI2); - t.rejects(() => agta(res2)); - - t.end(); + await expect(agta(res2)).rejects.toBeDefined(); }); - t.test("Should throw on invalid _links._next", (t) => { + it("rejects on invalid _links._next type", async () => { const mockKaiAPI = MockJSONFetch({ "http://url.com/sub": { _links: {}, @@ -118,7 +104,7 @@ t.test("#TraverseKaiAPI", (t) => { const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI); - t.rejects(() => agta(res)); + await expect(agta(res)).rejects.toBeDefined(); const mockKaiAPI2 = MockJSONFetch({ "http://url.com/sub": { @@ -130,12 +116,10 @@ t.test("#TraverseKaiAPI", (t) => { const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI2); - t.rejects(() => agta(res2)); - - t.end(); + await expect(agta(res2)).rejects.toBeDefined(); }); - t.test("Should throw on invalid _items", (t) => { + it("rejects when _items is not an array", async () => { const mockKaiAPI = MockJSONFetch({ "http://url.com/sub": { _links: { @@ -147,12 +131,10 @@ t.test("#TraverseKaiAPI", (t) => { const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI); - t.rejects(() => agta(res)); - - t.end(); + await expect(agta(res)).rejects.toBeDefined(); }); - t.test("Should throw on infinite loop", (t) => { + it("rejects on pagination infinite loop", async () => { const mockKaiAPI = MockJSONFetch({ "http://url.com/sub": { _links: { @@ -164,12 +146,10 @@ t.test("#TraverseKaiAPI", (t) => { const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, log, null, mockKaiAPI); - t.rejects(() => agta(res)); - - t.end(); + await expect(agta(res)).rejects.toBeDefined(); }); - t.test("Should attempt reauthentication if authentication fails.", async (t) => { + it("invokes reauth on 401 and still fails after retry cap", async () => { const mockKaiAPI = MockBasicFetch({ status: 401 }); let hasAttemptedReauth = false; @@ -187,17 +167,8 @@ t.test("#TraverseKaiAPI", (t) => { mockKaiAPI, ); - await t.rejects( - // not redundant here - // eslint-disable-next-line no-return-await - async () => await agta(res), - "Should correctly bail out of an endless reauth loop.", - ); + await expect(agta(res)).rejects.toBeDefined(); - t.equal(hasAttemptedReauth, true, "Should have called the reauth function."); - - t.end(); + expect(hasAttemptedReauth).toBe(true); }); - - t.end(); }); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts index 1992219c8..8d1394ff1 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts @@ -1,13 +1,12 @@ import type { KtLogger } from "#lib/log/log"; import { VERSION_STR } from "#lib/constants/version"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { TachiConfig } from "#lib/setup/config"; import nodeFetch from "#utils/fetch"; import { IsRecord } from "#utils/misc"; import { URL } from "url"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - /** * A Kai Reauth function is an async function that returns a string * which represents the new token to use, OR throws a score import diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/class-handler.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/class-handler.ts index a7f0de725..f03888283 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/class-handler.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/class-handler.ts @@ -2,41 +2,42 @@ import type { ClassProvider } from "#lib/score-import/framework/calculated-data/ import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { ClassToIndex } from "#utils/class"; -import { type Classes, GetGPTConfig, type GPTString } from "tachi-common"; +import { type Classes, GetGameConfig, type V3Game } from "tachi-common"; // Note: This is tested by batch-manuals parser.test.ts. export function CreateBatchManualClassProvider( - outerGptString: GPTString, - classes: Partial>, -): ClassProvider { - return (gptString, userID, ratings, log) => { - if (outerGptString !== gptString) { + outerGame: V3Game, + classes: Partial>, +): ClassProvider { + return (game, _userID, _ratings, log) => { + // TODO(zk): seems pointless? The game is already passed in. + if (outerGame !== game) { return {}; } - const gptConfig = GetGPTConfig(gptString); + const gameConfig = GetGameConfig(game); - const newObj: Partial> = {}; + const newObj: Partial> = {}; for (const [s, classID] of Object.entries(classes)) { if (classID === null) { continue; } - const set = s as Classes[GPTString]; + const set = s as Classes[V3Game]; - const index = ClassToIndex(gptString, set, classID); + const index = ClassToIndex(game, set, classID); if (index === null) { log.warn( - `User passed invalid class of ${classID} for set ${set}. Expected any of ${gptConfig.classes[ + `User passed invalid class of ${classID} for set ${set}. Expected any of ${gameConfig.classes[ set ]!.values.map((e) => e.id).join(", ")}`, ); throw new ScoreImportFatalError( 400, - `Invalid class of ${classID} for set ${set}. Expected any of ${gptConfig.classes[ + `Invalid class of ${classID} for set ${set}. Expected any of ${gameConfig.classes[ set ]!.values.map((e) => e.id).join(", ")}`, ); diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.oldtest.ts deleted file mode 100644 index dca370685..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.oldtest.ts +++ /dev/null @@ -1,619 +0,0 @@ -import type { - BatchManualScore, - ChartDocument, - MatchTypeResolver, - MatchTypeResolverWithDifficulty, -} from "tachi-common"; - -import { log } from "#lib/log/log.js"; -import { InvalidScoreFailure } from "#lib/score-import/framework/common/converter-failures"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { - BMSGazerChart, - BMSGazerSong, - Testing511Song, - Testing511SPA, - TestingSDVXAlbidaChart, -} from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import { - BatchManualScoreToResolver, - ConverterBatchManual, - ResolveChartFromSong, - ResolveSongAndChart, -} from "./converter"; - -const baseBatchManualScore = { - score: 500, - lamp: "HARD CLEAR" as const, - matchType: "tachiSongID" as const, - identifier: "1", - difficulty: "ANOTHER" as const, -}; - -const context = { - game: "iidx" as const, - playtype: "SP" as const, - service: "foo", - version: null, -}; - -const importType = "file/batch-manual" as const; - -const baseResolver: MatchTypeResolver = BatchManualScoreToResolver(baseBatchManualScore, context); -const baseResolverWithDiff: MatchTypeResolverWithDifficulty = BatchManualScoreToResolver( - baseBatchManualScore, - context, -) as MatchTypeResolverWithDifficulty; - -t.test("#ResolveSongAndChart", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should resolve for the songID if the matchType is songID", async (t) => { - const res = await ResolveSongAndChart(baseResolver, log); - - t.hasStrict( - res, - { song: { id: 1 }, chart: Testing511SPA }, - "Should return the right song and chart.", - ); - - t.equal( - await ResolveSongAndChart( - // @ts-expect-error bad - deepmerge(baseResolver, { identifier: "90000" }), - log, - ), - null, - ); - - t.end(); - }); - - t.test("Should resolve for the song title if the matchType is songTitle", async (t) => { - const res = await ResolveSongAndChart( - // @ts-expect-error bad - deepmerge(baseResolver, { matchType: "songTitle", identifier: "5.1.1." }), - log, - ); - - t.hasStrict( - res, - { song: { id: 1 }, chart: Testing511SPA }, - "Should return the right song and chart.", - ); - - t.equal( - await ResolveSongAndChart( - // @ts-expect-error bad - deepmerge(baseResolver, { - matchType: "songTitle", - identifier: "INVALID_TITLE", - }), - log, - ), - null, - ); - - t.end(); - }); - - t.test("Should resolve for the sdvx inGameID if matchType is sdvxInGameID", async (t) => { - const res = await ResolveSongAndChart( - { - matchType: "sdvxInGameID", - identifier: "1", - difficulty: "ADV", - game: "sdvx", - playtype: "Single", - version: null, - }, - log, - ); - - t.hasStrict( - res, - { song: { id: 1 }, chart: { data: { inGameID: 1 } } }, - "Should return the right song and chart.", - ); - - t.equal( - await ResolveSongAndChart( - { - matchType: "sdvxInGameID", - identifier: "9999999", - difficulty: "ADV", - game: "sdvx", - playtype: "Single", - version: null, - }, - log, - ), - null, - ); - - t.end(); - }); - - t.test("Should support ANY_INF if matchType is sdvxInGameID", async (t) => { - await MONGODB_KILL.charts.sdvx.insert( - deepmerge(TestingSDVXAlbidaChart, { - chartID: "fake_xcd", - data: {}, - difficulty: "XCD", - } as ChartDocument<"sdvx:Single">), - ); - - const res = await ResolveSongAndChart( - { - matchType: "sdvxInGameID", - identifier: "1", - difficulty: "ANY_INF", - game: "sdvx", - playtype: "Single", - version: null, - }, - log, - ); - - t.hasStrict( - res, - { song: { id: 1 }, chart: { data: { inGameID: 1 }, difficulty: "XCD" } }, - "Should return the right song and chart.", - ); - - t.end(); - }); - - t.test("Should resolve for the bms chartHash if the matchType is bmsChartHash", async (t) => { - const GAZER17MD5 = "38616b85332037cc12924f2ae2840262"; - const GAZER17SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d"; - - const resMD5 = await ResolveSongAndChart( - { - matchType: "bmsChartHash", - identifier: GAZER17MD5, - game: "bms", - playtype: "7K", - version: null, - }, - log, - ); - - t.hasStrict( - resMD5, - { song: BMSGazerSong, chart: BMSGazerChart }, - "Should return the right song and chart.", - ); - - const resSHA256 = await ResolveSongAndChart( - { - matchType: "bmsChartHash", - identifier: GAZER17SHA256, - game: "bms", - playtype: "7K", - version: null, - }, - log, - ); - - t.hasStrict( - resSHA256, - { song: BMSGazerSong, chart: BMSGazerChart }, - "Should return the right song and chart.", - ); - - t.equal( - await ResolveSongAndChart( - { - matchType: "bmsChartHash", - identifier: "bad_hash", - game: "bms", - playtype: "7K", - version: null, - }, - log, - ), - null, - ); - - t.end(); - }); - - t.test("Should resolve for the popn chartHash if the matchType is popnChartHash", async (t) => { - const chartHash = "2c26d666fa7c907e85115dbb279c267c14a263d47b2d46a93f99eae49d779119"; - - const res = await ResolveSongAndChart( - { - matchType: "popnChartHash", - identifier: chartHash, - game: "popn", - playtype: "9B", - version: null, - }, - log, - ); - - t.hasStrict( - res, - { - song: { id: 1 }, - chart: { - songID: 1, - data: { hashSHA256: chartHash }, - playtype: "9B", - }, - }, - "Should return the right song and chart.", - ); - - t.end(); - }); - - t.test("Should reject if popnChartHash is used while game is not popn", (t) => { - const chartHash = "2c26d666fa7c907e85115dbb279c267c14a263d47b2d46a93f99eae49d779119"; - - t.rejects(() => - ResolveSongAndChart( - { - matchType: "popnChartHash", - identifier: chartHash, - game: "iidx", - playtype: "SP", - version: null, - }, - log, - ), - ); - - t.end(); - }); - - t.test("Should resolve for the usc chartHash if the matchType is uscChartHash", async (t) => { - const chartHash = "USC_CHART_HASH"; - - const res = await ResolveSongAndChart( - { - matchType: "uscChartHash", - identifier: chartHash, - game: "usc", - playtype: "Controller", - version: null, - }, - log, - ); - - t.hasStrict( - res, - { - song: { id: 1 }, - chart: { songID: 1, chartID: "USC_CHART_ID", playtype: "Controller" }, - }, - "Should return the right song and chart.", - ); - - t.end(); - }); - - t.test("Should honor playtype in uscChartHash despite non-unique chartIDs.", async (t) => { - const chartHash = "USC_CHART_HASH"; - - t.equal( - await ResolveSongAndChart( - { - matchType: "uscChartHash", - identifier: chartHash, - game: "usc", - playtype: "Keyboard", - version: null, - }, - log, - ), - null, - ); - - t.end(); - }); - - t.test("Should trigger failsave if invalid matchType is provided.", (t) => { - t.rejects(() => - ResolveSongAndChart( - { - // @ts-expect-error bad - matchType: "BAD_MATCHTYPE", - game: "iidx", - playtype: "SP", - version: null, - }, - log, - ), - ); - - t.end(); - }); - - t.end(); -}); - -t.test("#ResolveChartFromSong", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the chart for the song + ptdf", async (t) => { - const res = await ResolveChartFromSong( - Testing511Song, - - // has playtype + diff - baseResolverWithDiff, - ); - - t.hasStrict(res, Testing511SPA); - - t.end(); - }); - - t.test("Should return null if no difficulty is provided.", (t) => { - t.rejects(() => - ResolveChartFromSong( - Testing511Song, - deepmerge(baseResolverWithDiff, { difficulty: null }), - ), - ); - - t.end(); - }); - - t.test("Should return null if an invalid difficulty is provided.", (t) => { - t.rejects(() => - ResolveChartFromSong( - Testing511Song, - // @ts-expect-error bad - deepmerge(baseResolverWithDiff, { - difficulty: "NOT_VALID_DIFFICULTY", - }), - ), - ); - - t.end(); - }); - - t.test("Should return null if no chart could be found.", async (t) => { - t.equal( - await ResolveChartFromSong( - Testing511Song, - - // 511 has no legg (yet, lol) - deepmerge(baseResolverWithDiff, { difficulty: "LEGGENDARIA" as const }), - ), - null, - ); - - t.end(); - }); - - t.test("Should successfully lookup if version is provided.", async (t) => { - const res = await ResolveChartFromSong(Testing511Song, baseResolverWithDiff); - - t.hasStrict(res, Testing511SPA); - - t.end(); - }); - - t.end(); -}); - -t.test("#ConverterFn", (t) => { - t.test("Should produce a DryScore", async (t) => { - const res = await ConverterBatchManual( - baseBatchManualScore, - { game: "iidx", service: "foo", playtype: "SP", version: null }, - importType, - log, - ); - - t.hasStrict(res, { - chart: Testing511SPA, - song: { id: 1 }, - dryScore: { - game: "iidx", - service: "foo (BATCH-MANUAL)", - comment: null, - importType: "file/batch-manual", - timeAchieved: null, - scoreData: { - lamp: "HARD CLEAR", - score: 500, - judgements: {}, - optional: {}, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should mount optionals", async (t) => { - const res = await ConverterBatchManual( - dmf(baseBatchManualScore as any, { - optional: { - bp: 123, - }, - }), - { game: "iidx", service: "foo", playtype: "SP", version: null }, - importType, - log, - ); - - t.hasStrict(res, { - chart: Testing511SPA, - song: { id: 1 }, - dryScore: { - game: "iidx", - service: "foo (BATCH-MANUAL)", - comment: null, - importType: "file/batch-manual", - timeAchieved: null, - scoreData: { - lamp: "HARD CLEAR", - score: 500, - judgements: {}, - optional: { - bp: 123, - }, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should mount hitMeta as optionals", async (t) => { - const res = await ConverterBatchManual( - dmf(baseBatchManualScore as any, { - hitMeta: { - bp: 123, - }, - }), - { game: "iidx", service: "foo", playtype: "SP", version: null }, - importType, - log, - ); - - t.hasStrict(res, { - chart: Testing511SPA, - song: { id: 1 }, - dryScore: { - game: "iidx", - service: "foo (BATCH-MANUAL)", - comment: null, - importType: "file/batch-manual", - timeAchieved: null, - scoreData: { - lamp: "HARD CLEAR", - score: 500, - judgements: {}, - optional: { - bp: 123, - }, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should mount judgements", async (t) => { - const res = await ConverterBatchManual( - dmf(baseBatchManualScore as any, { - judgements: { - pgreat: 13, - great: 3, - }, - }), - { game: "iidx", service: "foo", playtype: "SP", version: null }, - importType, - log, - ); - - t.hasStrict(res, { - chart: Testing511SPA, - song: { id: 1 }, - dryScore: { - game: "iidx", - service: "foo (BATCH-MANUAL)", - comment: null, - importType: "file/batch-manual", - timeAchieved: null, - scoreData: { - lamp: "HARD CLEAR", - score: 500, - judgements: { - pgreat: 13, - great: 3, - }, - optional: {}, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - const baseJubeatScore: BatchManualScore = { - musicRate: 10, - score: 920_000, - identifier: "1", - lamp: "CLEAR", - matchType: "tachiSongID", - difficulty: "ADV", - }; - - t.test("Should use the provided percent parameter for jubeat", async (t) => { - const res = await ConverterBatchManual( - baseJubeatScore, - { game: "jubeat", service: "foo", playtype: "Single", version: null }, - importType, - log, - ); - - t.hasStrict(res, { - chart: { songID: 1, difficulty: "ADV", playtype: "Single" }, - song: { id: 1 }, - dryScore: { - game: "jubeat", - service: "foo (BATCH-MANUAL)", - comment: null, - importType: "file/batch-manual", - timeAchieved: null, - scoreData: { - score: 920_000, - musicRate: 10, - judgements: {}, - optional: {}, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should produce a with timeAchieved null if timeAchieved is 0", async (t) => { - const res = await ConverterBatchManual( - deepmerge(baseBatchManualScore, { timeAchieved: 0 }), - { game: "iidx", service: "foo", playtype: "SP", version: null }, - importType, - log, - ); - - t.hasStrict(res, { - chart: Testing511SPA, - song: { id: 1 }, - dryScore: { - game: "iidx", - service: "foo (BATCH-MANUAL)", - comment: null, - importType: "file/batch-manual", - timeAchieved: null, - scoreData: { - lamp: "HARD CLEAR", - score: 500, - judgements: {}, - optional: {}, - }, - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts index d9646cd29..2075d91fa 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts @@ -214,19 +214,19 @@ describe("ResolveSongAndChart (Postgres)", () => { inGameID: 42, difficulty: "EXPERT", }); + const songId = `song-wacca-${suffix}`; const r: MatchTypeResolver = { game: "wacca", - playtype: "Single", version: null, - identifier: String(legacySongId), + identifier: songId, matchType: "tachiSongID", difficulty: "EXPERT", }; const got = await ResolveSongAndChart(r, mkLog()); expect(got).not.toBeNull(); - expect(got!.song.id).toBe(legacySongId); + expect(got!.song.id).toBe(songId); expect(got!.chart.difficulty).toBe("EXPERT"); }); @@ -244,7 +244,6 @@ describe("ResolveSongAndChart (Postgres)", () => { const r: MatchTypeResolver = { game: "wacca", - playtype: "Single", version: null, identifier: title.toLowerCase(), matchType: "songTitle", @@ -272,7 +271,6 @@ describe("ResolveSongAndChart (Postgres)", () => { const r: MatchTypeResolver = { game: "wacca", - playtype: "Single", version: null, identifier: String(inGameID), matchType: "inGameID", @@ -299,7 +297,6 @@ describe("ResolveSongAndChart (Postgres)", () => { const r: MatchTypeResolver = { game: "wacca", - playtype: "Single", version: "reverse", identifier: String(inGameID), matchType: "inGameID", @@ -314,7 +311,6 @@ describe("ResolveSongAndChart (Postgres)", () => { it("returns null when inGameID does not match", async () => { const r: MatchTypeResolver = { game: "wacca", - playtype: "Single", version: null, identifier: "999999991", matchType: "inGameID", @@ -338,7 +334,6 @@ describe("ResolveSongAndChart (Postgres)", () => { const r: MatchTypeResolver = { game: "maimai", - playtype: "Single", version: null, identifier: strId, matchType: "inGameStrID", @@ -366,7 +361,6 @@ describe("ResolveSongAndChart (Postgres)", () => { const r: MatchTypeResolver = { game: "maimai", - playtype: "Single", version: "finale", identifier: strId, matchType: "inGameStrID", @@ -383,10 +377,10 @@ describe("ResolveSongAndChart (Postgres)", () => { const legacySongId = nextLegacySongId(); const h = randomBytes(32).toString("hex"); await seedPopnChart({ suffix, legacySongId, hashSHA256: h }); + const songId = `song-popn-${suffix}`; const r: MatchTypeResolver = { game: "popn", - playtype: "9B", version: null, identifier: h, matchType: "popnChartHash", @@ -394,7 +388,7 @@ describe("ResolveSongAndChart (Postgres)", () => { const got = await ResolveSongAndChart(r, mkLog()); expect(got).not.toBeNull(); - expect(got!.song.id).toBe(legacySongId); + expect(got!.song.id).toBe(songId); expect((got!.chart.data as { hashSHA256?: string }).hashSHA256).toBe(h); }); @@ -410,17 +404,17 @@ describe("ResolveSongAndChart (Postgres)", () => { }); const r: MatchTypeResolver = { - game: "ddr", - playtype: "SP", + game: "ddr-sp", version: null, identifier: ddrSongHash, matchType: "ddrSongHash", difficulty: "EXPERT", }; + const songId = `song-ddr-${suffix}`; const got = await ResolveSongAndChart(r, mkLog()); expect(got).not.toBeNull(); - expect(got!.song.id).toBe(legacySongId); + expect(got!.song.id).toBe(songId); expect(got!.chart.difficulty).toBe("EXPERT"); }); @@ -438,8 +432,7 @@ describe("ResolveSongAndChart (Postgres)", () => { }); const r: MatchTypeResolver = { - game: "ddr", - playtype: "SP", + game: "ddr-sp", version: "world", identifier: ddrSongHash, matchType: "ddrSongHash", @@ -454,7 +447,6 @@ describe("ResolveSongAndChart (Postgres)", () => { it("suggests sdvxInGameID when sdvx is given inGameID", async () => { const r: MatchTypeResolver = { game: "sdvx", - playtype: "Single", version: null, identifier: "1", matchType: "inGameID", diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts index 1dc0c7d8e..490039ebc 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts @@ -1,5 +1,12 @@ import type { KtLogger } from "#lib/log/log"; +import type { DryScore } from "#lib/score-import/framework/common/types"; +import { + InternalFailure, + InvalidScoreFailure, + SongOrChartNotFoundFailure, +} from "#lib/score-import/framework/common/converter-failures"; +import { AssertStrAsDifficulty } from "#lib/score-import/framework/common/string-asserts"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { staticAssertUnreachable } from "#utils/misc"; import { @@ -8,13 +15,13 @@ import { FindChartOnInGameIDVersion, FindChartOnInGameStrIDPrimary, FindChartOnInGameStrIDVersion, - FindChartWithPTDF, - FindChartWithPTDFVersion, + FindChartWithSongDifficulty, + FindChartWithSongDifficultyVersion, FindITGChartOnHash, FindPopnChartOnHashSHA256, FindSDVXChartOnInGameID, FindSDVXChartOnInGameIDVersion, - FindUSCChartOnSHA1Playtype, + FindUSCChartOnSHA1, SongHasAnyChart, } from "#utils/queries/charts"; import { @@ -24,33 +31,22 @@ import { } from "#utils/queries/songs"; import { type BatchManualScore, + type ChartDocument, type Difficulties, - FormatGameGroup, - GetGamePTConfig, - type GPTString, + FormatGame, + GetGameConfig, + LEGACY_GameToGameGroupPT, + LEGACY_GetGamePTConfig, type MatchTypeResolver, type MatchTypeResolverWithDifficulty, - type MONGO_ChartDocument, - type MONGO_SongDocument, type MongoProvidedMetrics, - type Playtypes, + type SongDocument, type Versions, } from "tachi-common"; -import type { DryScore } from "../../../framework/common/types"; import type { ConverterFunction } from "../types"; import type { BatchManualContext } from "./types"; -import { - InternalFailure, - InvalidScoreFailure, - SongOrChartNotFoundFailure, -} from "../../../framework/common/converter-failures"; -import { - AssertStrAsDifficulty, - AssertStrAsPositiveInt, -} from "../../../framework/common/string-asserts"; - // only public because used in tests; ts has no way of doing that // lol export function BatchManualScoreToResolver( @@ -66,7 +62,6 @@ export function BatchManualScoreToResolver( artist: data.artist, version: context.version, game: context.game, - playtype: context.playtype, }; return resolver; @@ -84,7 +79,7 @@ export const ConverterBatchManual: ConverterFunction { - const { game, playtype } = context; + const { game } = context; const resolver = BatchManualScoreToResolver(data, context); @@ -111,11 +106,11 @@ export const ConverterBatchManual: ConverterFunction { - const { game, playtype } = resolver; +): Promise<{ chart: ChartDocument; song: SongDocument } | null> { + const { game } = resolver; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(game); - const config = GetGamePTConfig(game, playtype); + const gameConfig = GetGameConfig(game); - if (!config.supportedMatchTypes.includes(resolver.matchType)) { + if (!gameConfig.supportedMatchTypes.includes(resolver.matchType)) { // special, more helpful error message if (game === "sdvx" && resolver.matchType === "inGameID") { throw new InvalidScoreFailure( - `Cannot use matchType ${resolver.matchType} for ${FormatGameGroup( + `Cannot use matchType ${resolver.matchType} for ${FormatGame( game, - playtype, )}. Use 'sdvxInGameID' instead.`, ); } throw new InvalidScoreFailure( - `Cannot use matchType ${resolver.matchType} for ${FormatGameGroup( + `Cannot use matchType ${resolver.matchType} for ${FormatGame( game, - playtype, - )}. Expected any of ${config.supportedMatchTypes.join(", ")}.`, + )}. Expected any of ${gameConfig.supportedMatchTypes.join(", ")}.`, ); } @@ -182,18 +176,18 @@ export async function ResolveSongAndChart( return null; } - if (chart.playtype !== playtype) { + if (chart.game !== game) { throw new InvalidScoreFailure( - `Chart ${chart.chartID}'s playtype was ${chart.playtype}, but this was not equal to the import playtype of ${playtype}.`, + `Chart ${chart.chartID}'s game was ${chart.game}, but this was not equal to the resolver game of ${game}.`, ); } - const song = await FindSongOnID(game, chart.songID); + const song = await FindSongOnID(gameGroup, chart.song.id); if (!song) { - log.error(`BMS songID ${chart.songID} has charts but no parent song.`); + log.error(`BMS songID ${chart.song.id} has charts but no parent song.`); throw new InternalFailure( - `BMS songID ${chart.songID} has charts but no parent song.`, + `BMS songID ${chart.song.id} has charts but no parent song.`, ); } @@ -207,12 +201,12 @@ export async function ResolveSongAndChart( return null; } - const song = await FindSongOnID(game, chart.songID); + const song = await FindSongOnID(gameGroup, chart.song.id); if (!song) { - log.error(`ITG songID ${chart.songID} has charts but no parent song.`); + log.error(`ITG songID ${chart.song.id} has charts but no parent song.`); throw new InternalFailure( - `ITG songID ${chart.songID} has charts but no parent song.`, + `ITG songID ${chart.song.id} has charts but no parent song.`, ); } @@ -220,22 +214,22 @@ export async function ResolveSongAndChart( } case "popnChartHash": { - if (playtype !== "9B") { - throw new InvalidScoreFailure(`Invalid playtype '${playtype}', expected 9B.`); + if (game !== "popn") { + throw new InvalidScoreFailure(`Invalid game '${game}', expected popn.`); } - const chart = await FindPopnChartOnHashSHA256(resolver.identifier, playtype); + const chart = await FindPopnChartOnHashSHA256(resolver.identifier); if (!chart) { return null; } - const song = await FindSongOnID(game, chart.songID); + const song = await FindSongOnID(gameGroup, chart.song.id); if (!song) { - log.error(`Pop'n songID ${chart.songID} has charts but no parent song.`); + log.error(`Pop'n songID ${chart.song.id} has charts but no parent song.`); throw new InternalFailure( - `Pop'n songID ${chart.songID} has charts but no parent song.`, + `Pop'n songID ${chart.song.id} has charts but no parent song.`, ); } @@ -243,12 +237,7 @@ export async function ResolveSongAndChart( } case "tachiSongID": { - const songID = AssertStrAsPositiveInt( - resolver.identifier, - "Invalid songID - must be a stringified positive integer.", - ); - - const song = await FindSongOnID(game, songID); + const song = await FindSongOnID(gameGroup, resolver.identifier); if (!song) { return null; @@ -265,7 +254,7 @@ export async function ResolveSongAndChart( case "songTitle": { const song = await FindSongOnTitleInsensitive( - game, + gameGroup, resolver.identifier, resolver.artist, ); @@ -284,11 +273,11 @@ export async function ResolveSongAndChart( } case "sdvxInGameID": { - let chart: MONGO_ChartDocument | null; + let chart: ChartDocument | null; const identifier = Number(resolver.identifier); - const config = GetGamePTConfig("sdvx", "Single"); + const config = LEGACY_GetGamePTConfig("sdvx", "Single"); if (config.difficulties.type === "DYNAMIC") { log.error( @@ -314,7 +303,7 @@ export async function ResolveSongAndChart( ); } - const diff = resolver.difficulty as "ANY_INF" | Difficulties["sdvx:Single"]; + const diff = resolver.difficulty as "ANY_INF" | Difficulties["sdvx"]; if (resolver.version) { if (!Object.keys(config.versions).includes(resolver.version)) { @@ -328,7 +317,7 @@ export async function ResolveSongAndChart( chart = await FindSDVXChartOnInGameIDVersion( identifier, diff, - resolver.version as Versions["sdvx:Single"], + resolver.version as Versions["sdvx"], ); } else { chart = await FindSDVXChartOnInGameID(identifier, diff); @@ -338,10 +327,10 @@ export async function ResolveSongAndChart( return null; } - const song = await FindSongOnID("sdvx", chart.songID); + const song = await FindSongOnID("sdvx", chart.song.id); if (!song) { - log.error(`Song-Chart desync on ${chart.songID}.`); + log.error(`Song-Chart desync on ${chart.song.id}.`); throw new InternalFailure(`Failed to get song for a chart that exists.`); } @@ -351,35 +340,29 @@ export async function ResolveSongAndChart( case "inGameID": { const identifier = Number(resolver.identifier); - const difficulty = AssertStrAsDifficulty(resolver.difficulty, game, resolver.playtype); + const difficulty = AssertStrAsDifficulty(resolver.difficulty, game); - let chart: MONGO_ChartDocument | null; + let chart: ChartDocument | null; if (resolver.version) { chart = await FindChartOnInGameIDVersion( game, identifier, - resolver.playtype, difficulty, resolver.version, ); } else { - chart = await FindChartOnInGameIDPrimary( - game, - identifier, - resolver.playtype, - difficulty, - ); + chart = await FindChartOnInGameIDPrimary(game, identifier, difficulty); } if (!chart) { return null; } - const song = await FindSongOnID(game, chart.songID); + const song = await FindSongOnID(gameGroup, chart.song.id); if (!song) { - log.error(`Song-Chart desync on ${chart.songID}.`); + log.error(`Song-Chart desync on ${chart.song.id}.`); throw new InternalFailure(`Failed to get song for a chart that exists.`); } @@ -387,35 +370,29 @@ export async function ResolveSongAndChart( } case "inGameStrID": { - const difficulty = AssertStrAsDifficulty(resolver.difficulty, game, resolver.playtype); + const difficulty = AssertStrAsDifficulty(resolver.difficulty, game); - let chart: MONGO_ChartDocument | null; + let chart: ChartDocument | null; if (resolver.version) { chart = await FindChartOnInGameStrIDVersion( game, resolver.identifier, - resolver.playtype, difficulty, resolver.version, ); } else { - chart = await FindChartOnInGameStrIDPrimary( - game, - resolver.identifier, - resolver.playtype, - difficulty, - ); + chart = await FindChartOnInGameStrIDPrimary(game, resolver.identifier, difficulty); } if (!chart) { return null; } - const song = await FindSongOnID(game, chart.songID); + const song = await FindSongOnID(gameGroup, chart.song.id); if (!song) { - log.error(`Song-Chart desync on ${chart.songID}.`); + log.error(`Song-Chart desync on ${chart.song.id}.`); throw new InternalFailure(`Failed to get song for a chart that exists.`); } @@ -423,24 +400,24 @@ export async function ResolveSongAndChart( } case "uscChartHash": { - if (game !== "usc") { + if (gameGroup !== "usc") { throw new InvalidScoreFailure(`uscChartHash matchType can only be used on USC.`); } - if (playtype !== "Controller" && playtype !== "Keyboard") { + if (game !== "usc-controller" && game !== "usc-keyboard") { throw new InvalidScoreFailure(`Invalid playtype, expected Keyboard or Controller.`); } - const chart = await FindUSCChartOnSHA1Playtype(resolver.identifier, playtype); + const chart = await FindUSCChartOnSHA1(resolver.identifier, game); if (!chart) { return null; } - const song = await FindSongOnID(game, chart.songID); + const song = await FindSongOnID(gameGroup, chart.song.id); if (!song) { - log.error(`Song-Chart desync on ${chart.songID}.`); + log.error(`Song-Chart desync on ${chart.song.id}.`); throw new InternalFailure(`Failed to get song for a chart that exists.`); } @@ -448,11 +425,11 @@ export async function ResolveSongAndChart( } case "ddrSongHash": { - if (game !== "ddr") { + if (gameGroup !== "ddr") { throw new InvalidScoreFailure(`ddrSongHash matchType can only be used on DDR.`); } - const difficulty = AssertStrAsDifficulty(resolver.difficulty, game, resolver.playtype); + const difficulty = AssertStrAsDifficulty(resolver.difficulty, game); const song = await FindDDRSongOnDDRSongHash(resolver.identifier); @@ -465,25 +442,17 @@ export async function ResolveSongAndChart( throw new InternalFailure(`Failed to get chart for a song that exists.`); } - let chart: MONGO_ChartDocument | null; - - type DDRGPT = `ddr:${Playtypes["ddr"]}`; + let chart: ChartDocument | null; if (resolver.version) { - chart = await FindChartWithPTDFVersion( - "ddr", + chart = await FindChartWithSongDifficultyVersion( + game, song.id, - resolver.playtype as Playtypes["ddr"], difficulty, - resolver.version as Versions[DDRGPT], + resolver.version as Versions["ddr-dp" | "ddr-sp"], ); } else { - chart = await FindChartWithPTDF( - "ddr", - song.id, - resolver.playtype as Playtypes["ddr"], - difficulty, - ); + chart = await FindChartWithSongDifficulty(game, song.id, difficulty); } if (!chart) { @@ -500,7 +469,7 @@ export async function ResolveSongAndChart( } export async function ResolveChartFromSong( - song: MONGO_SongDocument, + song: SongDocument, resolver: MatchTypeResolverWithDifficulty, ) { const game = resolver.game; @@ -511,20 +480,19 @@ export async function ResolveChartFromSong( ); } - const difficulty = AssertStrAsDifficulty(resolver.difficulty, game, resolver.playtype); + const difficulty = AssertStrAsDifficulty(resolver.difficulty, game); let chart; if (resolver.version) { - chart = await FindChartWithPTDFVersion( + chart = await FindChartWithSongDifficultyVersion( game, song.id, - resolver.playtype, difficulty, resolver.version, ); } else { - chart = await FindChartWithPTDF(game, song.id, resolver.playtype, difficulty); + chart = await FindChartWithSongDifficulty(game, song.id, difficulty); } if (!chart) { diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.test.ts similarity index 69% rename from typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.oldtest.ts rename to typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.test.ts index 3ba57103b..e89084c42 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.oldtest.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.test.ts @@ -1,19 +1,37 @@ import type { BatchManual } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { TachiConfig } from "#lib/setup/config"; import { EscapeStringRegexp } from "#utils/misc"; import deepmerge from "deepmerge"; -import t from "tap"; +import { describe, expect, it } from "vitest"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import { ParseBatchManualFromObject as ParserFn } from "./parser"; const mockErr = (...msg: Array) => - ({ - statusCode: 400, - message: new RegExp(msg.map((e) => `${EscapeStringRegexp(e)}.*`).join(""), "u"), - name: "Error", - }) as unknown as ScoreImportFatalError; + new RegExp(msg.map((e) => `${EscapeStringRegexp(e)}.*`).join(""), "u"); + +function expectThrowsFatal(fn: () => void, expected: ScoreImportFatalError) { + try { + fn(); + expect.fail("expected ScoreImportFatalError"); + } catch (e) { + expect(e).toBeInstanceOf(ScoreImportFatalError); + expect((e as ScoreImportFatalError).statusCode).toBe(expected.statusCode); + expect((e as ScoreImportFatalError).message).toBe(expected.message); + } +} + +function expectThrowsFatalMatch(fn: () => void, pattern: RegExp) { + try { + fn(); + expect.fail("expected ScoreImportFatalError"); + } catch (e) { + expect(e).toBeInstanceOf(ScoreImportFatalError); + expect((e as ScoreImportFatalError).message).toMatch(pattern); + } +} const baseBatchManual = { scores: [], @@ -37,38 +55,36 @@ function dm(sc: any) { ); } -t.test("#ParserFn", (t) => { - t.test("Non-Object", (t) => { - t.throws( - () => ParserFn(false, "file/batch-manual", false, log), +describe("#ParserFn", () => { + it("Non-Object", () => { + expectThrowsFatal( + () => ParserFn(false as unknown as BatchManual, "file/batch-manual", false, log), new ScoreImportFatalError( 400, "Invalid BATCH-MANUAL (Not an object, received boolean.)", ), - "Should throw an error.", ); - - t.end(); }); - t.test("No Header", (t) => { - t.throws( - () => ParserFn({ scores: [] }, "file/batch-manual", false, log), + it("No Header", () => { + expectThrowsFatal( + () => + ParserFn({ scores: [] } as unknown as BatchManual, "file/batch-manual", false, log), new ScoreImportFatalError( 400, "Could not retrieve meta.game - is this valid BATCH-MANUAL?", ), - "Should throw an error.", ); - - t.end(); }); - t.test("No Game", (t) => { - t.throws( + it("No Game", () => { + expectThrowsFatal( () => ParserFn( - { scores: [], meta: { service: "foo", playtype: "SP" } }, + { + scores: [], + meta: { service: "foo", playtype: "SP" }, + } as unknown as BatchManual, "file/batch-manual", false, log, @@ -77,59 +93,53 @@ t.test("#ParserFn", (t) => { 400, "Could not retrieve meta.game - is this valid BATCH-MANUAL?", ), - "Should throw an error.", ); - - t.end(); }); - t.test("No Playtype", (t) => { - t.throws( + it("No Playtype — game group without playtype is not a V3 game", () => { + expectThrowsFatal( () => ParserFn( - { scores: [], meta: { service: "foo", game: "iidx" } }, + { + scores: [], + meta: { service: "foo", game: "iidx" }, + } as unknown as BatchManual, "file/batch-manual", false, log, ), new ScoreImportFatalError( 400, - "Could not retrieve meta.playtype - is this valid BATCH-MANUAL?", + `Invalid game 'iidx' - expected any of ${TachiConfig.GAME_GROUPS.join(", ")}.`, ), - "Should throw an error.", ); - - t.end(); }); - t.test("Invalid Game", (t) => { - t.throws( - () => - ParserFn( - { scores: [], meta: { service: "foo", game: "invalid_game", playtype: "SP" } }, - "file/batch-manual", - false, - log, - ), - "Should throw an error.", - ); + it("Invalid Game", () => { + expect(() => + ParserFn( + { scores: [], meta: { service: "foo", game: "invalid_game", playtype: "SP" } }, + "file/batch-manual", + false, + log, + ), + ).toThrow(ScoreImportFatalError); - t.throws( - () => - ParserFn( - { scores: [], meta: { service: "foo", game: 123, playtype: "SP" } }, - "file/batch-manual", - false, - log, - ), - "Should throw an error.", - ); - - t.end(); + expect(() => + ParserFn( + { + scores: [], + meta: { service: "foo", game: 123, playtype: "SP" }, + } as unknown as BatchManual, + "file/batch-manual", + false, + log, + ), + ).toThrow(ScoreImportFatalError); }); - t.test("Invalid Service", (t) => { - t.throws( + it("Invalid Service", () => { + expectThrowsFatal( () => ParserFn( { scores: [], meta: { service: "1", game: "iidx", playtype: "SP" } }, @@ -141,13 +151,15 @@ t.test("#ParserFn", (t) => { 400, "Invalid BATCH-MANUAL: meta.service | Expected a string with length between 3 and 60. | Received 1 [type: string].", ), - "Should throw an error.", ); - t.throws( + expectThrowsFatal( () => ParserFn( - { scores: [], meta: { service: 1, game: "iidx", playtype: "SP" } }, + { + scores: [], + meta: { service: 1, game: "iidx", playtype: "SP" }, + } as unknown as BatchManual, "file/batch-manual", false, log, @@ -156,13 +168,10 @@ t.test("#ParserFn", (t) => { 400, "Invalid BATCH-MANUAL: meta.service | Expected a string with length between 3 and 60. | Received 1 [type: number].", ), - "Should throw an error.", ); - - t.end(); }); - t.test("Valid Empty BATCH-MANUAL", (t) => { + it("Valid Empty BATCH-MANUAL", () => { const res = ParserFn( { scores: [], meta: { service: "foo", game: "iidx", playtype: "SP" } }, "file/batch-manual", @@ -170,21 +179,19 @@ t.test("#ParserFn", (t) => { log, ); - t.hasStrict(res, { - game: "iidx", + expect(res).toMatchObject({ + gameGroup: "iidx", context: { service: "foo", - game: "iidx", + game: "iidx-sp", version: null, }, iterable: [], }); - - t.end(); }); - t.test("Valid BATCH-MANUAL", (t) => { - t.test("Basic BATCH-MANUAL", (t) => { + describe("Valid BATCH-MANUAL", () => { + it("Basic BATCH-MANUAL", () => { const res = ParserFn( { scores: [ @@ -222,12 +229,11 @@ t.test("#ParserFn", (t) => { log, ); - t.hasStrict(res, { - game: "iidx", + expect(res).toMatchObject({ + gameGroup: "iidx", context: { service: "foo", - game: "iidx", - playtype: "SP", + game: "iidx-sp", version: null, }, iterable: [ @@ -259,11 +265,9 @@ t.test("#ParserFn", (t) => { }, ], }); - - t.end(); }); - t.test("Valid Optional", (t) => { + it("Valid Optional", () => { const res = ParserFn( dm({ optional: { bp: 10, gauge: 100, gaugeHistory: null, comboBreak: 7 } }), "file/batch-manual", @@ -271,12 +275,11 @@ t.test("#ParserFn", (t) => { log, ); - t.hasStrict(res, { - game: "iidx", + expect(res).toMatchObject({ + gameGroup: "iidx", context: { service: "foo", - game: "iidx", - playtype: "SP", + game: "iidx-sp", version: null, }, iterable: [ @@ -295,11 +298,9 @@ t.test("#ParserFn", (t) => { }, ], }); - - t.end(); }); - t.test("Valid judgements", (t) => { + it("Valid judgements", () => { const res = ParserFn( dm({ judgements: { pgreat: 1, great: null, bad: 0 } }), "file/batch-manual", @@ -307,12 +308,11 @@ t.test("#ParserFn", (t) => { log, ); - t.hasStrict(res, { - game: "iidx", + expect(res).toMatchObject({ + gameGroup: "iidx", context: { service: "foo", - game: "iidx", - playtype: "SP", + game: "iidx-sp", version: null, }, iterable: [ @@ -330,11 +330,9 @@ t.test("#ParserFn", (t) => { }, ], }); - - t.end(); }); - t.test("With class", (t) => { + it("With class", () => { const res = ParserFn( { meta: baseBatchManual.meta, @@ -346,15 +344,13 @@ t.test("#ParserFn", (t) => { log, ); - t.not(res.classProvider, null); + expect(res.classProvider).not.toBeNull(); - t.strictSame(res.classProvider!("iidx:SP", 1, {}, log), { dan: "KAIDEN" }); - t.strictSame(res.classProvider!("iidx:DP", 1, {}, log), {}); - - t.end(); + expect(res.classProvider!("iidx-sp", 1, {}, log)).toStrictEqual({ dan: "KAIDEN" }); + expect(res.classProvider!("iidx-dp", 1, {}, log)).toStrictEqual({}); }); - t.test("With class set to null.", (t) => { + it("With class set to null.", () => { const res = ParserFn( { meta: baseBatchManual.meta, @@ -366,16 +362,12 @@ t.test("#ParserFn", (t) => { log, ); - t.equal(res.classProvider, null); - - t.end(); + expect(res.classProvider).toBeNull(); }); - - t.end(); }); - t.test("Invalid BATCH-MANUAL", (t) => { - t.test("Invalid Lamp For Game", (t) => { + describe("Invalid BATCH-MANUAL", () => { + it("Invalid Lamp For Game", () => { const fn = () => ParserFn( { @@ -397,36 +389,32 @@ t.test("#ParserFn", (t) => { log, ); - t.throws( + expectThrowsFatal( fn, new ScoreImportFatalError( 400, "Invalid BATCH-MANUAL: scores[0].lamp | Expected any of NO PLAY, FAILED, ASSIST CLEAR, EASY CLEAR, CLEAR, HARD CLEAR, EX HARD CLEAR, FULL COMBO. | Received ALL JUSTICE [type: string].", ), ); - - t.end(); }); - t.test("Non-numeric score", (t) => { + it("Non-numeric score", () => { const fn = () => ParserFn(dm({ score: "123" }), "file/batch-manual", false, log); - t.throws( + expectThrowsFatal( fn, new ScoreImportFatalError( 400, "Invalid BATCH-MANUAL: scores[0].score | Expected an integer. | Received 123 [type: string].", ), ); - - t.end(); }); - t.test("Invalid timeAchieved", (t) => { + it("Invalid timeAchieved", () => { const fn = () => ParserFn(dm({ timeAchieved: "string" }), "file/batch-manual", false, log); - t.throws( + expectThrowsFatal( fn, new ScoreImportFatalError( 400, @@ -442,27 +430,23 @@ t.test("#ParserFn", (t) => { log, ); - t.throws( + expectThrowsFatal( fn2, new ScoreImportFatalError( 400, "Invalid BATCH-MANUAL: scores[0].timeAchieved | Expected a number greater than 1 Trillion - did you pass unix seconds instead of milliseconds? | Received 1620768609.637 [type: number].", ), - "Should throw if timeAchieved is less than 10_000_000_000.", ); - - t.end(); }); - t.test("TimeAchieved of 0 should be legal.", (t) => { + it("TimeAchieved of 0 should be legal.", () => { const res = ParserFn(dm({ timeAchieved: 0 }), "file/batch-manual", false, log); - t.hasStrict(res, { - game: "iidx", + expect(res).toMatchObject({ + gameGroup: "iidx", context: { service: "foo", - game: "iidx", - playtype: "SP", + game: "iidx-sp", version: null, }, iterable: [ @@ -476,85 +460,72 @@ t.test("#ParserFn", (t) => { }, ], }); - - t.end(); }); - t.test("Invalid Identifier", (t) => { - // this is not a valid playtype for IIDX + it("Invalid Identifier", () => { const fn = () => ParserFn(dm({ identifier: null }), "file/batch-manual", false, log); - t.throws( + expectThrowsFatalMatch( fn, mockErr("scores[0].identifier | Expected string", "Received null [type: null]"), ); - - t.end(); }); - t.test("Invalid MatchType", (t) => { + it("Invalid MatchType", () => { const fn = () => ParserFn(dm({ matchType: "Invalid_MatchType" }), "file/batch-manual", false, log); - t.throws( + expectThrowsFatalMatch( fn, mockErr( "scores[0].matchType | Expected any of", "Received Invalid_MatchType [type: string]", ), ); - - t.end(); }); - t.test("Invalid judgements", (t) => { + it("Invalid judgements", () => { const fn = () => ParserFn(dm({ judgements: { not_key: 123 } }), "file/batch-manual", false, log); - t.throws(fn, mockErr("scores[0].judgements | Invalid Key not_key")); + expectThrowsFatalMatch(fn, mockErr("scores[0].judgements | Invalid Key not_key")); const fn2 = () => ParserFn(dm({ judgements: { pgreat: "123" } }), "file/batch-manual", false, log); - t.throws( + expectThrowsFatalMatch( fn2, mockErr( "scores[0].judgements | Key pgreat had an invalid value of 123 [type: string]", ), ); - - t.end(); }); - t.test("Invalid optional", (t) => { + it("Invalid optional", () => { const fn = () => ParserFn(dm({ hitMeta: { not_key: 123 } }), "file/batch-manual", false, log); - t.throws(fn, mockErr("scores[0].hitMeta | Unexpected")); + expectThrowsFatalMatch(fn, mockErr("scores[0].hitMeta | Unexpected")); const fn2 = () => ParserFn(dm({ hitMeta: { bp: -1 } }), "file/batch-manual", false, log); - t.throws(fn2, mockErr("scores[0].hitMeta.bp")); + expectThrowsFatalMatch(fn2, mockErr("scores[0].hitMeta.bp")); const fn3 = () => ParserFn(dm({ optional: { not_key: 123 } }), "file/batch-manual", false, log); - t.throws(fn3, mockErr("scores[0].optional | Unexpected")); + expectThrowsFatalMatch(fn3, mockErr("scores[0].optional | Unexpected")); const fn4 = () => ParserFn(dm({ optional: { bp: -1 } }), "file/batch-manual", false, log); - t.throws(fn4, mockErr("scores[0].optional.bp")); - - t.end(); + expectThrowsFatalMatch(fn4, mockErr("scores[0].optional.bp")); }); - t.test("Invalid class", (t) => { - // Out of bounds. (18 is kaiden) - - t.test("Should throw if class is out of bounds.", (t) => { - t.throws( + describe("Invalid class", () => { + it("Should throw if class is out of bounds.", () => { + expectThrowsFatalMatch( () => ParserFn( { @@ -570,12 +541,10 @@ t.test("#ParserFn", (t) => { "Invalid BATCH-MANUAL: classes.dan | Expected any of KYU_7, KYU_6, KYU_5, KYU_4, KYU_3, KYU_2, KYU_1, DAN_1, DAN_2, DAN_3, DAN_4, DAN_5, DAN_6, DAN_7, DAN_8, DAN_9, DAN_10, CHUUDEN, KAIDEN. | Received UNKNOWN [type: string]", ), ); - - t.end(); }); - t.test("Should throw if dans for different games are passed.", (t) => { - t.throws( + it("Should throw if dans for different games are passed.", () => { + expectThrowsFatalMatch( () => ParserFn( { @@ -589,12 +558,10 @@ t.test("#ParserFn", (t) => { ), mockErr("classes | Unexpected properties inside object: stageUp"), ); - - t.end(); }); - t.test("Should throw if dan is a non-string.", (t) => { - t.throws( + it("Should throw if dan is a non-string.", () => { + expectThrowsFatalMatch( () => ParserFn( { @@ -610,19 +577,17 @@ t.test("#ParserFn", (t) => { "Invalid BATCH-MANUAL: classes.dan | Expected any of KYU_7, KYU_6, KYU_5, KYU_4, KYU_3, KYU_2, KYU_1, DAN_1, DAN_2, DAN_3, DAN_4, DAN_5, DAN_6, DAN_7, DAN_8, DAN_9, DAN_10, CHUUDEN, KAIDEN. | Received 9 [type: number].", ), ); - - t.end(); }); - t.test("Should throw if unknown classes are present.", (t) => { - t.throws( + it("Should throw if unknown classes are present.", () => { + expectThrowsFatalMatch( () => ParserFn( { meta: baseBatchManual.meta, scores: [baseBatchManualScore], classes: { dan: "DAN_9", unknownDan: "FIRST" }, - } as unknown, + } as unknown as BatchManual, "file/batch-manual", false, log, @@ -630,16 +595,14 @@ t.test("#ParserFn", (t) => { mockErr("classes | Unexpected properties inside object: unknownDan."), ); - // should also throw if classes from a valid game and invalid game - // are passed. - t.throws( + expectThrowsFatalMatch( () => ParserFn( { meta: baseBatchManual.meta, scores: [baseBatchManualScore], classes: { dan: "KAIDEN", stageUp: "XII" }, - } as unknown, + } as unknown as BatchManual, "file/batch-manual", false, log, @@ -648,15 +611,7 @@ t.test("#ParserFn", (t) => { "Invalid BATCH-MANUAL: classes | Unexpected properties inside object: stageUp.", ), ); - - t.end(); }); - - t.end(); }); - - t.end(); }); - - t.end(); }); diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.ts index f12475b3c..31f5ab70c 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.ts @@ -1,24 +1,24 @@ import type { KtLogger } from "#lib/log/log"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { TachiConfig } from "#lib/setup/config"; -import { IsRecord, IsValidGame, IsValidPlaytype } from "#utils/misc"; +import { IsEnabledGame, IsEnabledGameGroup, IsRecord, IsValidPlaytype } from "#utils/misc"; import { FormatPrError } from "#utils/prudence"; import { p } from "prudence"; import { type BatchManual, type BatchManualScore, - type GameGroup, + GameToGameGroup, GetGameGroupConfig, - GetGPTString, type ImportTypes, - type Playtype, + LEGACY_GameGroupPTToGame, + type V3Game, } from "tachi-common"; import { PR_BATCH_MANUAL } from "tachi-common/lib/schemas"; import type { ParserFunctionReturns } from "../types"; import type { BatchManualContext } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import { CreateBatchManualClassProvider } from "./class-handler"; /** @@ -28,10 +28,10 @@ import { CreateBatchManualClassProvider } from "./class-handler"; */ export function ParseBatchManualFromObject( object: unknown, - importType: ImportTypes, + _importType: ImportTypes, inferTimestamp: boolean, _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { // now to perform some basic validation so we can return // the iterable @@ -47,10 +47,6 @@ export function ParseBatchManualFromObject( // attempt to retrieve game // @ts-expect-error man. const maybeGame = object.meta?.game as unknown; - - // @ts-expect-error man. - const maybePlaytype = object.meta?.playtype as unknown; - if (maybeGame === undefined) { throw new ScoreImportFatalError( 400, @@ -58,43 +54,51 @@ export function ParseBatchManualFromObject( ); } + // @ts-expect-error man. + const maybePlaytype = object.meta?.playtype as unknown; + + let game: V3Game; + if (maybePlaytype === undefined) { - throw new ScoreImportFatalError( - 400, - `Could not retrieve meta.playtype - is this valid BATCH-MANUAL?`, - ); + // so, game should be a v3 game. + + if (typeof maybeGame !== "string" || !IsEnabledGame(maybeGame)) { + throw new ScoreImportFatalError( + 400, + `Invalid game '${maybeGame}' - expected any of ${TachiConfig.GAME_GROUPS.join(", ")}.`, + ); + } + + game = maybeGame as V3Game; + } else { + // playtype provided + + const maybeGameGroup = maybeGame; + + if (typeof maybeGameGroup !== "string" || !IsEnabledGameGroup(maybeGameGroup)) { + throw new ScoreImportFatalError( + 400, + `Invalid game group '${maybeGameGroup}' - expected any of ${TachiConfig.GAME_GROUPS.join(", ")}.`, + ); + } + + if (typeof maybePlaytype !== "string" || !IsValidPlaytype(maybeGameGroup, maybePlaytype)) { + const gameGroupConfig = GetGameGroupConfig(maybeGameGroup); + throw new ScoreImportFatalError( + 400, + `Invalid playtype '${maybePlaytype}' - expected any of ${gameGroupConfig.playtypes.join(", ")}.`, + ); + } + + game = LEGACY_GameGroupPTToGame(maybeGameGroup, maybePlaytype); } - // maybeGame could be a number or really anything. - // So we have to check that it's both a string and a valid game. - if (!(typeof maybeGame === "string" && IsValidGame(maybeGame))) { - throw new ScoreImportFatalError( - 400, - `Invalid game '${maybeGame}' - expected any of ${TachiConfig.GAMES.join(", ")}.`, - ); - } - - const game: GameGroup = maybeGame; - - const gameConfig = GetGameGroupConfig(game); - - if (!(typeof maybePlaytype === "string" && IsValidPlaytype(game, maybePlaytype))) { - throw new ScoreImportFatalError( - 400, - `Invalid playtype '${maybePlaytype}' - expected any of ${gameConfig.playtypes.join( - ", ", - )}.`, - ); - } - - const playtype: Playtype = maybePlaytype; - // now that we have the game, we can validate this against // the prudence schema for batch-manual. // This mostly works as a sanity check, and doesn't // check things like whether a score is > 100% // or something. - const err = p(object, PR_BATCH_MANUAL(game, playtype)); + const err = p(object, PR_BATCH_MANUAL(game)); if (err) { throw new ScoreImportFatalError(400, FormatPrError(err, "Invalid BATCH-MANUAL")); @@ -124,11 +128,10 @@ export function ParseBatchManualFromObject( } return { - game, + gameGroup: GameToGameGroup(game), context: { service: batchManual.meta.service, game, - playtype, version: batchManual.meta.version ?? null, }, iterable: batchManual.scores, @@ -136,7 +139,7 @@ export function ParseBatchManualFromObject( // if classes are provided, use those as a class handler. Otherwise, we // don't care. classProvider: batchManual.classes - ? CreateBatchManualClassProvider(GetGPTString(game, playtype), batchManual.classes) + ? CreateBatchManualClassProvider(game, batchManual.classes) : null, }; } diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/types.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/types.ts index e3d7e1e10..2eb48cf2a 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/types.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/types.ts @@ -1,8 +1,7 @@ -import type { GameGroup, GPTString, Playtype, Versions } from "tachi-common"; +import type { V3Game, Versions } from "tachi-common"; export interface BatchManualContext { - game: GameGroup; - playtype: Playtype; - version: Versions[GPTString] | null; + game: V3Game; + version: Versions[V3Game] | null; service: string; } diff --git a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/converter.oldtest.ts deleted file mode 100644 index 7823f8a0e..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/converter.oldtest.ts +++ /dev/null @@ -1,31 +0,0 @@ -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -// const valid511Score = { -// bp: "12", -// difficulty: "ANOTHER" as const, // lol -// exscore: "192", -// great: "42", -// pgreat: "75", -// lamp: "HARD CLEAR", -// level: "10", -// }; - -// const converterContext = { -// playtype: "SP" as const, -// hasBeginnerAndLegg: false, -// importVersion: "27", -// service: "e-amusement", -// }; - -// const data = { -// scores: [valid511Score], -// timestamp: "Tue, 27 Apr 2021 21:35:35 GMT", -// title: "5.1.1", -// }; - -t.todo("#ConverterFn", (t) => { - t.beforeEach(ResetDBState); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/converter.ts b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/converter.ts index f7cf3edb1..09a1d966b 100644 --- a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/converter.ts @@ -1,22 +1,21 @@ -import type { MONGO_ChartDocument } from "tachi-common"; +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ChartDocument, GamesForGroup } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; -import { FindChartWithPTDFVersion } from "#utils/queries/charts"; -import { FindSongOnTitle } from "#utils/queries/songs"; - -import type { DryScore } from "../../../framework/common/types"; -import type { ConverterFunction } from "../types"; -import type { IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./types"; - import { InvalidScoreFailure, SkipScoreFailure, SongOrChartNotFoundFailure, -} from "../../../framework/common/converter-failures"; -import { ParseDateFromString } from "../../../framework/common/score-utils"; -import { AssertStrAsPositiveInt } from "../../../framework/common/string-asserts"; +} from "#lib/score-import/framework/common/converter-failures"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import { AssertStrAsPositiveInt } from "#lib/score-import/framework/common/string-asserts"; +import { FindChartWithSongDifficultyVersion } from "#utils/queries/charts"; +import { FindSongOnTitle } from "#utils/queries/songs"; -const EAMUSEMENT_LAMP_RESOLVER: Map> = new Map([ +import type { ConverterFunction } from "../types"; +import type { IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./types"; + +const EAMUSEMENT_LAMP_RESOLVER: Map> = new Map([ ["ASSIST CLEAR", "ASSIST CLEAR"], ["CLEAR", "CLEAR"], ["EASY CLEAR", "EASY CLEAR"], @@ -77,13 +76,18 @@ const ConvertEamIIDXCSV: ConverterFunction< eamScore.difficulty = "LEGGENDARIA"; } - const tachiChart = (await FindChartWithPTDFVersion( - "iidx", + const game = context.playtype === "SP" ? "iidx-sp" : "iidx-dp"; + + if (eamScore.difficulty === "BEGINNER") { + throw new SkipScoreFailure("BEGINNER charts are not supported."); + } + + const tachiChart = (await FindChartWithSongDifficultyVersion( + game, tachiSong.id, - context.playtype, eamScore.difficulty, context.importVersion, - )) as MONGO_ChartDocument<"iidx:DP" | "iidx:SP"> | null; + )) as ChartDocument | null; if (!tachiChart) { throw new SongOrChartNotFoundFailure( @@ -134,10 +138,10 @@ const ConvertEamIIDXCSV: ConverterFunction< const timestamp = ParseDateFromString(data.timestamp); - const dryScore: DryScore<"iidx:DP" | "iidx:SP"> = { + const dryScore: DryScore = { service: context.service, comment: null, - game: "iidx", + game, importType, scoreData: { score: exscore, diff --git a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.test.ts similarity index 59% rename from typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.oldtest.ts rename to typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.test.ts index 97d0542b0..7ae5380fe 100644 --- a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.oldtest.ts +++ b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.test.ts @@ -1,66 +1,55 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { MockMulterFile } from "#test-utils/mock-multer"; import { TestingIIDXEamusementCSV26, TestingIIDXEamusementCSV27 } from "#test-utils/test-data"; -import t from "tap"; +import { describe, expect, it } from "vitest"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import GenericParseEamIIDXCSV, { IIDXCSVParse, ResolveHeaders } from "./parser"; -t.test("#ParseEamusementCSV", (t) => { - t.test("Valid Rootage-Type CSV", (t) => { +describe("IIDXCSVParse", () => { + it("parses valid Rootage-style CSV", () => { const { iterableData, hasBeginnerAndLegg, version } = IIDXCSVParse( TestingIIDXEamusementCSV26, "SP", log, ); - t.equal(iterableData.length, 456, "Should return exactly 456 datapoints."); - t.equal(hasBeginnerAndLegg, false, "Should not mark as a HV csv."); - t.equal(version, "26", "Should correctly assert that the version of this CSV is ROOTAGE."); - - t.end(); + expect(iterableData.length).toBe(456); + expect(hasBeginnerAndLegg).toBe(false); + expect(version).toBe("26"); }); - t.test("Valid HV CSV", (t) => { + it("parses valid HV CSV", () => { const { iterableData, hasBeginnerAndLegg, version } = IIDXCSVParse( TestingIIDXEamusementCSV27, "SP", log, ); - t.equal(iterableData.length, 6285, "Should return exactly 6285 datapoints."); - t.equal(hasBeginnerAndLegg, true, "Should mark as a HV csv."); - t.equal( - version, - "27", - "Should correctly assert that the version of this CSV is HEROIC VERSE.", - ); - - t.end(); + expect(iterableData.length).toBe(6285); + expect(hasBeginnerAndLegg).toBe(true); + expect(version).toBe("27"); }); - t.test("Broken CSV", (t) => { - // This is mostly tested in the unit tests for NaiveCSVParse (the general one), - // but we make sure that the CSVParseErrors are converted to ScoreImportFatalErrors + it("throws on malformed CSV rows", () => { const buffer = Buffer.from(`${"a,".repeat(26)}a\n${"a,".repeat(3)}a`); - t.throws( - () => IIDXCSVParse(buffer, "SP", log), - new ScoreImportFatalError(400, "Row 1 has an invalid amount of cells (4, expected 27)"), + expect(() => IIDXCSVParse(buffer, "SP", log)).toThrow( + new ScoreImportFatalError( + 400, + "Row 1 has an invalid amount of cells (4, expected 27).", + ), ); - - t.end(); }); - t.test("Version Inference", (t) => { + it("infers the highest e-amusement version from rows", () => { const headerStr = `${"a,".repeat(26)}a`; const row = `GARBAGE VERSION,foo bar,${"a,".repeat(24)}a`; - const InvalidVersions = Buffer.from(`${headerStr}\n${row}`); + const invalidVersions = Buffer.from(`${headerStr}\n${row}`); - t.throws( - () => IIDXCSVParse(InvalidVersions, "SP", log), + expect(() => IIDXCSVParse(invalidVersions, "SP", log)).toThrow( new ScoreImportFatalError( 400, "Invalid/Unsupported Eamusement Version Name GARBAGE VERSION.", @@ -76,7 +65,7 @@ t.test("#ParseEamusementCSV", (t) => { log, ); - t.equal(version, "27", "Should pick the largest version from the list of scores."); + expect(version).toBe("27"); ({ version } = IIDXCSVParse( Buffer.from([headerStr, row17th, row27th].join("\n")), @@ -84,21 +73,12 @@ t.test("#ParseEamusementCSV", (t) => { log, )); - // this is technically allowing invalid eam-csv, but, who cares? - t.equal( - version, - "27", - "Should pick largest version regardless of order scores are received in.", - ); - - t.end(); + expect(version).toBe("27"); }); - - t.end(); }); -t.test("#ResolveHeader", (t) => { - t.test("Rootage-type Headers", (t) => { +describe("ResolveHeaders", () => { + it("detects Rootage-style headers", () => { const { hasBeginnerAndLegg } = ResolveHeaders( [ "バージョン", @@ -132,12 +112,10 @@ t.test("#ResolveHeader", (t) => { log, ); - t.equal(hasBeginnerAndLegg, false, "Should return false for hasBeginnerAndLegg"); - - t.end(); + expect(hasBeginnerAndLegg).toBe(false); }); - t.test("HV-type Headers", (t) => { + it("detects HV-style headers", () => { const { hasBeginnerAndLegg } = ResolveHeaders( [ "バージョン", @@ -185,46 +163,31 @@ t.test("#ResolveHeader", (t) => { log, ); - t.equal(hasBeginnerAndLegg, true, "Should return true for hasBeginnerAndLegg"); - - t.end(); + expect(hasBeginnerAndLegg).toBe(true); }); - t.test("No headers", (t) => { - t.throws( - () => ResolveHeaders([], log), + it("throws when header count is wrong", () => { + expect(() => ResolveHeaders([], log)).toThrow( new ScoreImportFatalError( 400, "Invalid CSV provided. CSV does not have the correct amount of headers.", ), - "Should throw an error on no headers.", ); - t.end(); - }); - - t.test("Too Many Headers", (t) => { - t.throws( - () => ResolveHeaders(Array(1000), log), + expect(() => ResolveHeaders(Array(1000), log)).toThrow( new ScoreImportFatalError( 400, "Invalid CSV provided. CSV does not have the correct amount of headers.", ), - "Should throw an error on no headers.", ); - - t.end(); }); - - t.end(); }); -t.test("#ParseEamusementCSV", (t) => { - t.test("Playtype Tests", (t) => { +describe("GenericParseEamIIDXCSV", () => { + it("requires playtype and checks filename safety", () => { const validSPFile = MockMulterFile(TestingIIDXEamusementCSV27, "iidx_27_sp.csv"); - t.throws( - () => GenericParseEamIIDXCSV(validSPFile, {}, "e-amusement", log), + expect(() => GenericParseEamIIDXCSV(validSPFile, {}, "e-amusement", log)).toThrow( new ScoreImportFatalError(400, "Invalid playtype of undefined given."), ); @@ -235,21 +198,21 @@ t.test("#ParseEamusementCSV", (t) => { log, ); - t.equal(context.playtype, "SP", "Should correctly assert SP for a body of SP."); + expect(context.playtype).toBe("SP"); const mockDPFile = MockMulterFile(TestingIIDXEamusementCSV27, "iidx_27_dp.csv"); ({ context } = GenericParseEamIIDXCSV(mockDPFile, { playtype: "DP" }, "e-amusement", log)); - t.equal(context.playtype, "DP", "Should correctly assert DP for a body of DP."); + expect(context.playtype).toBe("DP"); - t.throws( - () => GenericParseEamIIDXCSV(validSPFile, { playtype: "DP" }, "e-amusement", log), + expect(() => + GenericParseEamIIDXCSV(validSPFile, { playtype: "DP" }, "e-amusement", log), + ).toThrow( new ScoreImportFatalError( 400, "Safety Triggered: Filename contained 'SP', but was marked as a DP import. Are you *absolutely* sure this is right?", ), - "Should trigger safety if playtype asserted goes against filename.", ); ({ context } = GenericParseEamIIDXCSV( @@ -259,14 +222,6 @@ t.test("#ParseEamusementCSV", (t) => { log, )); - t.equal( - context.playtype, - "DP", - "Should ignore weird filenames if assertPlaytypeCorrect is passed.", - ); - - t.end(); + expect(context.playtype).toBe("DP"); }); - - t.end(); }); diff --git a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts index 28a6d9de6..aa10edb6e 100644 --- a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts @@ -1,20 +1,13 @@ import type { KtLogger } from "#lib/log/log"; -import { StringIsGameVersion } from "#utils/misc"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { staticAssertUnreachable, StringIsGameVersion } from "#utils/misc"; import { CSVParseError, NaiveCSVParse } from "#utils/naive-csv-parser"; -import { - GetGamePTConfig, - type integer, - Playtype, - type Playtypes, - type Versions, -} from "tachi-common"; +import { type GamesForGroup, type V3Game, type Versions } from "tachi-common"; import type { ParserFunctionReturns } from "../types"; import type { EamusementScoreData, IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - export enum EAM_VERSION_NAMES { "1st&substream" = 1, "2nd style" = 2, @@ -107,7 +100,7 @@ export function ResolveHeaders(headers: Array, log: KtLogger) { ); } -export function IIDXCSVParse(csvBuffer: Buffer, playtype: Playtypes["iidx"], log: KtLogger) { +export function IIDXCSVParse(csvBuffer: Buffer, playtype: "DP" | "SP", log: KtLogger) { let rawHeaders: Array; let rawRows: Array>; @@ -181,7 +174,19 @@ export function IIDXCSVParse(csvBuffer: Buffer, playtype: Playtypes["iidx"], log ); } - if (!StringIsGameVersion("iidx", playtype, gameVersion.toString())) { + let game: V3Game; + switch (playtype) { + case "SP": + game = "iidx-sp"; + break; + case "DP": + game = "iidx-dp"; + break; + default: + staticAssertUnreachable(playtype); + } + + if (!StringIsGameVersion(game, gameVersion.toString())) { throw new ScoreImportFatalError( 400, `Unsupported version '${gameVersion}'. Is your CSV properly filled out?`, @@ -190,7 +195,7 @@ export function IIDXCSVParse(csvBuffer: Buffer, playtype: Playtypes["iidx"], log return { iterableData, - version: gameVersion.toString() as Versions["iidx:DP" | "iidx:SP"], + version: gameVersion.toString() as Versions[GamesForGroup["iidx"]], hasBeginnerAndLegg, }; } @@ -205,7 +210,7 @@ function GenericParseEamIIDXCSV( body: Record, service: string, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { let playtype: "DP" | "SP"; if (body.playtype === "SP") { @@ -256,7 +261,7 @@ function GenericParseEamIIDXCSV( return { iterable: iterableData, context, - game: "iidx", + gameGroup: "iidx", classProvider: null, }; } diff --git a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/types.ts b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/types.ts index 9b5438821..caecefafd 100644 --- a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/types.ts +++ b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/types.ts @@ -30,7 +30,7 @@ export type RawIIDXEamusementCSVData = { export interface IIDXEamusementCSVContext { playtype: "DP" | "SP"; - importVersion: Versions["iidx:DP" | "iidx:SP"]; + importVersion: Versions["iidx-dp" | "iidx-sp"]; hasBeginnerAndLegg: boolean; service: string; } diff --git a/typescript/server/src/lib/score-import/import-types/common/types.ts b/typescript/server/src/lib/score-import/import-types/common/types.ts index 758d60aae..275e8ff2d 100644 --- a/typescript/server/src/lib/score-import/import-types/common/types.ts +++ b/typescript/server/src/lib/score-import/import-types/common/types.ts @@ -1,18 +1,20 @@ import type { KtLogger } from "#lib/log/log"; import type { ClassProvider } from "#lib/score-import/framework/calculated-data/types"; +import type { ConverterFailure } from "#lib/score-import/framework/common/converter-failures"; +import type { DryScore } from "#lib/score-import/framework/common/types"; import type { USCClientScore } from "#server/router/ir/usc/_playtype/types"; import type { EmptyObject } from "#utils/types"; import type { BatchManualScore, + ChartDocument, GameGroup, + GameGroupFromGame, ImportTypes, integer, - MONGO_ChartDocument, - MONGO_SongDocument, + SongDocument, + V3Game, } from "tachi-common"; -import type { ConverterFailure } from "../../framework/common/converter-failures"; -import type { DryScore } from "../../framework/common/types"; import type { MytChunithmScore } from "../api/myt-chunithm/types"; import type { MytMaimaiDxScore } from "../api/myt-maimaidx/types"; import type { MytOngekiScore } from "../api/myt-ongeki/types"; @@ -143,8 +145,8 @@ export interface OrphanScoreDocument { export interface ConverterFnSuccessReturn { dryScore: DryScore; - chart: MONGO_ChartDocument; - song: MONGO_SongDocument; + chart: ChartDocument; + song: SongDocument; } export type ConverterFnReturnOrFailure = ConverterFailure | ConverterFnSuccessReturn; @@ -156,17 +158,17 @@ export type ConverterFunction = ( log: KtLogger, ) => Promise; -export type ImportInputParser = ( +export type ImportInputParser = ( log: KtLogger, -) => ParserFunctionReturns | Promise>; +) => ParserFunctionReturns | Promise>; -export interface ParserFunctionReturns { +export interface ParserFunctionReturns { iterable: AsyncIterable | Iterable; context: C; - game: GameGroup; - classProvider: ClassProvider | null; + gameGroup: GameGroupFromGame[TGame]; + classProvider: ClassProvider | null; } -export type ParserFunction> = ( +export type ParserFunction> = ( ...args: A -) => ParserFunctionReturns | Promise>; +) => ParserFunctionReturns | Promise>; diff --git a/typescript/server/src/lib/score-import/import-types/file/batch-manual/parser.ts b/typescript/server/src/lib/score-import/import-types/file/batch-manual/parser.ts index 86526f181..3875ba11a 100644 --- a/typescript/server/src/lib/score-import/import-types/file/batch-manual/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/batch-manual/parser.ts @@ -1,11 +1,10 @@ import type { KtLogger } from "#lib/log/log"; -import type { BatchManualScore } from "tachi-common"; +import type { BatchManualContext } from "#lib/score-import/import-types/common/batch-manual/types"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { BatchManualScore, V3Game } from "tachi-common"; -import type { BatchManualContext } from "../../common/batch-manual/types"; -import type { ParserFunctionReturns } from "../../common/types"; - -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; -import { ParseBatchManualFromObject } from "../../common/batch-manual/parser"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { ParseBatchManualFromObject } from "#lib/score-import/import-types/common/batch-manual/parser"; /** * Parses a buffer of BATCH-MANUAL data. @@ -16,7 +15,7 @@ function ParseBatchManual( fileData: Express.Multer.File, body: Record, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { let jsonData: unknown; try { diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-iidx-csv/parser.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-iidx-csv/parser.ts index cac9cf106..264be4b8d 100644 --- a/typescript/server/src/lib/score-import/import-types/file/eamusement-iidx-csv/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/eamusement-iidx-csv/parser.ts @@ -1,18 +1,18 @@ import type { KtLogger } from "#lib/log/log"; - import type { IIDXEamusementCSVContext, IIDXEamusementCSVData, -} from "../../common/eamusement-iidx-csv/types"; -import type { ParserFunctionReturns } from "../../common/types"; +} from "#lib/score-import/import-types/common/eamusement-iidx-csv/types"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup } from "tachi-common"; -import GenericParseEamIIDXCSV from "../../common/eamusement-iidx-csv/parser"; +import GenericParseEamIIDXCSV from "#lib/score-import/import-types/common/eamusement-iidx-csv/parser"; function ParseEamusementIIDXCSV( fileData: Express.Multer.File, body: Record, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { return GenericParseEamIIDXCSV(fileData, body, "e-amusement", log); } diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.oldtest.ts deleted file mode 100644 index 4db3cdbef..000000000 --- a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.oldtest.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { GetKTDataJSON, TestingAlbidaADV, TestingSDVXAlbidaSong } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import type { SDVXEamusementCSVData } from "./types"; - -import ConvertEamSDVXCSV from "./converter"; - -const parsedScore = { - title: "ALBIDA Powerless Mix", - difficulty: "ADVANCED", - level: "10", - lamp: "EXCESSIVE COMPLETE", - score: "9310699", - exscore: "0", -}; - -t.test("#ConvertEamSDVXCSV", (t) => { - t.beforeEach(ResetDBState); - - function conv(g: Partial = {}) { - return ConvertEamSDVXCSV(deepmerge(parsedScore, g), {}, "file/eamusement-sdvx-csv", log); - } - - t.test("Should return a dryScore on valid input.", async (t) => { - const res = await conv(); - - t.hasStrict(res, { - song: TestingSDVXAlbidaSong, - chart: TestingAlbidaADV, - dryScore: { - service: "e-amusement", - game: "sdvx", - scoreMeta: {}, - timeAchieved: null, - comment: null, - importType: "file/eamusement-sdvx-csv", - scoreData: { - score: 9310699, - lamp: "EXCESSIVE CLEAR", - judgements: {}, - optional: {}, - }, - }, - }); - - t.end(); - }); - - t.test("Should include exScore if provided", async (t) => { - const res = await conv({ exscore: "5730" }); - - t.hasStrict(res, { - song: TestingSDVXAlbidaSong, - chart: TestingAlbidaADV, - dryScore: { - service: "e-amusement", - game: "sdvx", - scoreMeta: {}, - timeAchieved: null, - comment: null, - importType: "file/eamusement-sdvx-csv", - scoreData: { - score: 9310699, - lamp: "EXCESSIVE CLEAR", - judgements: {}, - optional: { - exScore: 5730, - }, - }, - }, - }); - - t.end(); - }); - - t.test("Should reject invalid difficulty name", (t) => { - t.rejects(() => conv({ difficulty: "INVALID" }), { - message: /Invalid difficulty of INVALID\./u, - }); - - t.end(); - }); - - t.test("Should throw SongOrChartNotFound on unknown song", (t) => { - t.rejects(() => conv({ title: "INVALID SONG" }), { - message: /Could not find song for INVALID SONG\./u, - }); - - t.end(); - }); - - t.test("Should throw SongOrChartNotFound on unknown chart", (t) => { - t.rejects(() => conv({ difficulty: "VIVID" }), { - message: /Could not find chart for ALBIDA Powerless Mix \[VVD\]\./u, - }); - - t.end(); - }); - - t.test("Should reject incorrect level", (t) => { - t.rejects(() => conv({ level: "17" }), { - message: /Should be level 10, but found level 17\./u, - }); - - t.end(); - }); - - t.test("Should reject invalid score", (t) => { - t.rejects(() => conv({ score: "not a number" }), { - message: /Invalid score of not a number\./u, - }); - - t.rejects(() => conv({ score: "10000001" }), { - message: /Invalid score of 10000001 \(was greater than 10,000,000\)\./u, - }); - - t.end(); - }); - - t.test("Should reject invalid lamp", (t) => { - t.rejects(() => conv({ lamp: "INVALID" }), { - message: /Invalid lamp of INVALID\./u, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts new file mode 100644 index 000000000..27ee2f881 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts @@ -0,0 +1,146 @@ +import { log } from "#lib/log/log"; +import DB from "#services/pg/db"; +import { TestingAlbidaADV, TestingSDVXAlbidaSong } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { describe, expect, it } from "vitest"; + +import type { SDVXEamusementCSVData } from "./types"; + +import ConvertEamSDVXCSV from "./converter"; + +const parsedScore = { + title: "ALBIDA Powerless Mix", + difficulty: "ADVANCED", + level: "10", + lamp: "EXCESSIVE COMPLETE", + score: "9310699", + exscore: "0", +}; + +async function seedAlbidaAdv() { + await DB.insertInto("song") + .values({ + id: TestingSDVXAlbidaSong.id, + legacy_id: 1, + game_group: "sdvx", + title: TestingSDVXAlbidaSong.title, + artist: TestingSDVXAlbidaSong.artist, + search_terms: TestingSDVXAlbidaSong.searchTerms, + alt_titles: TestingSDVXAlbidaSong.altTitles, + data: TestingSDVXAlbidaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingAlbidaADV.chartID, + legacy_id: TestingAlbidaADV.chartID, + game: "sdvx", + song_id: TestingSDVXAlbidaSong.id, + difficulty: "ADV", + level: TestingAlbidaADV.level, + level_num: TestingAlbidaADV.levelNum, + is_primary: TestingAlbidaADV.isPrimary, + versions: TestingAlbidaADV.versions, + data: TestingAlbidaADV.data, + }) + .execute(); +} + +function conv(g: Partial = {}) { + return ConvertEamSDVXCSV(deepmerge(parsedScore, g), {}, "file/eamusement-sdvx-csv", log); +} + +describe("ConvertEamSDVXCSV", () => { + it("returns song, chart, and dryScore for valid input", async () => { + await seedAlbidaAdv(); + + const res = await conv(); + + expect(res.song).toMatchObject(TestingSDVXAlbidaSong); + expect(res.chart).toMatchObject({ + chartID: TestingAlbidaADV.chartID, + game: "sdvx", + difficulty: "ADV", + }); + expect(res.dryScore).toMatchObject({ + service: "e-amusement", + game: "sdvx", + scoreMeta: {}, + timeAchieved: null, + comment: null, + importType: "file/eamusement-sdvx-csv", + scoreData: { + score: 9310699, + lamp: "EXCESSIVE CLEAR", + judgements: {}, + }, + }); + expect((res.dryScore.scoreData.optional as { exScore?: number | null }).exScore).toBeNull(); + }); + + it("includes exScore when non-zero", async () => { + await seedAlbidaAdv(); + + const res = await conv({ exscore: "5730" }); + + expect(res.dryScore.scoreData.optional).toStrictEqual({ exScore: 5730 }); + }); + + it("rejects invalid difficulty", async () => { + await seedAlbidaAdv(); + + await expect(conv({ difficulty: "INVALID" })).rejects.toMatchObject({ + message: expect.stringMatching(/Invalid difficulty of INVALID/u) as string, + }); + }); + + it("throws when the song is unknown", async () => { + await seedAlbidaAdv(); + + await expect(conv({ title: "INVALID SONG" })).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Could not find song for INVALID SONG\./u, + }); + }); + + it("throws when the chart is unknown", async () => { + await seedAlbidaAdv(); + + await expect(conv({ difficulty: "VIVID" })).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Could not find chart for ALBIDA Powerless Mix \[VVD\]\./u, + }); + }); + + it("rejects incorrect level", async () => { + await seedAlbidaAdv(); + + await expect(conv({ level: "17" })).rejects.toMatchObject({ + message: expect.stringMatching(/Should be level 10, but found level 17/u) as string, + }); + }); + + it("rejects invalid score", async () => { + await seedAlbidaAdv(); + + await expect(conv({ score: "not a number" })).rejects.toMatchObject({ + message: expect.stringMatching(/Invalid score of not a number/u) as string, + }); + + await expect(conv({ score: "10000001" })).rejects.toMatchObject({ + message: expect.stringMatching( + /Invalid score of 10000001 \(was greater than 10,000,000\)/u, + ) as string, + }); + }); + + it("rejects invalid lamp", async () => { + await seedAlbidaAdv(); + + await expect(conv({ lamp: "INVALID" })).rejects.toMatchObject({ + message: expect.stringMatching(/Invalid lamp of INVALID/u) as string, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.ts index 384ab1a66..4cabe2514 100644 --- a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; import type { Difficulties } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; @@ -8,13 +9,12 @@ import { SongOrChartNotFoundFailure, } from "#lib/score-import/framework/common/converter-failures"; import { AssertStrAsPositiveInt } from "#lib/score-import/framework/common/string-asserts"; -import { FindChartWithPTDF } from "#utils/queries/charts"; +import { FindChartWithSongDifficulty } from "#utils/queries/charts"; import { FindSongOnTitle } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { SDVXEamusementCSVData } from "./types"; -const DIFFICULTY_MAP: Map = new Map([ +const DIFFICULTY_MAP: Map = new Map([ ["ADVANCED", "ADV"], ["EXCEED", "XCD"], ["EXHAUST", "EXH"], @@ -26,7 +26,7 @@ const DIFFICULTY_MAP: Map = new Map([ ["VIVID", "VVD"], ]); -const LAMP_MAP: Map> = new Map([ +const LAMP_MAP: Map> = new Map([ ["COMPLETE", "CLEAR"], ["EXCESSIVE COMPLETE", "EXCESSIVE CLEAR"], ["MAXXIVE COMPLETE", "MAXXIVE CLEAR"], @@ -61,7 +61,7 @@ const ConvertEamSDVXCSV: ConverterFunction = const humanisedChartTitle = `${song.title} [${difficulty}]`; - const chart = await FindChartWithPTDF("sdvx", song.id, "Single", difficulty); + const chart = await FindChartWithSongDifficulty("sdvx", song.id, difficulty); if (!chart) { throw new SongOrChartNotFoundFailure( @@ -108,7 +108,7 @@ const ConvertEamSDVXCSV: ConverterFunction = throw new InvalidScoreFailure(`${humanisedChartTitle} - Invalid lamp of ${data.lamp}.`); } - const dryScore: DryScore<"sdvx:Single"> = { + const dryScore: DryScore<"sdvx"> = { service: "e-amusement", game: "sdvx", scoreMeta: {}, diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.oldtest.ts deleted file mode 100644 index 26725ee9d..000000000 --- a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.oldtest.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; -import { MockMulterFile } from "#test-utils/mock-multer"; -import { TestingSDVXEamusementCSV } from "#test-utils/test-data"; -import t from "tap"; - -import type { SDVXEamusementCSVData } from "./types"; - -import ParseEamusementSDVXCSV from "./parser"; - -t.test("#ParseEamusementSDVXCSV", (t) => { - t.test("Valid CSV", (t) => { - const file = MockMulterFile(TestingSDVXEamusementCSV, "score.csv"); - - const { iterable, game } = ParseEamusementSDVXCSV(file, {}, log); - - t.equal(game, "sdvx"); - - const iterableData = iterable as Array; - - t.equal(iterableData.length, 204); - - t.end(); - }); - - t.test("Broken CSV", (t) => { - const buffer = Buffer.from(`${"a,".repeat(10)}a\n${"a,".repeat(3)}a`); - - const file = MockMulterFile(buffer, "score.csv"); - - t.throws( - () => ParseEamusementSDVXCSV(file, {}, log), - new ScoreImportFatalError(400, "Row 1 has an invalid amount of cells (4, expected 11)"), - ); - - t.end(); - }); - - t.test("Wrong number of headers", (t) => { - const buffer = Buffer.from(`${"a,".repeat(15)}a\n`.repeat(3)); - - const file = MockMulterFile(buffer, "score.csv"); - - t.throws( - () => ParseEamusementSDVXCSV(file, {}, log), - new ScoreImportFatalError( - 400, - "Invalid CSV provided. CSV does not have the correct number of headers.", - ), - "Should throw an error on no headers.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.test.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.test.ts new file mode 100644 index 000000000..002471319 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.test.ts @@ -0,0 +1,45 @@ +import { log } from "#lib/log/log"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { MockMulterFile } from "#test-utils/mock-multer"; +import { TestingSDVXEamusementCSV } from "#test-utils/test-data"; +import { describe, expect, it } from "vitest"; + +import type { SDVXEamusementCSVData } from "./types"; + +import ParseEamusementSDVXCSV from "./parser"; + +describe("ParseEamusementSDVXCSV", () => { + it("parses the fixture CSV", () => { + const file = MockMulterFile(TestingSDVXEamusementCSV, "score.csv"); + + const { iterable, gameGroup } = ParseEamusementSDVXCSV(file, {}, log); + + expect(gameGroup).toBe("sdvx"); + + const iterableData = iterable as Array; + + expect(iterableData.length).toBe(204); + }); + + it("rejects rows with wrong cell counts", () => { + const buffer = Buffer.from(`${"a,".repeat(10)}a\n${"a,".repeat(3)}a`); + + const file = MockMulterFile(buffer, "score.csv"); + + expect(() => ParseEamusementSDVXCSV(file, {}, log)).toThrow(ScoreImportFatalError); + expect(() => ParseEamusementSDVXCSV(file, {}, log)).toThrow( + "Row 1 has an invalid amount of cells (4, expected 11)", + ); + }); + + it("rejects CSV with wrong header count", () => { + const buffer = Buffer.from(`${"a,".repeat(15)}a\n`.repeat(3)); + + const file = MockMulterFile(buffer, "score.csv"); + + expect(() => ParseEamusementSDVXCSV(file, {}, log)).toThrow(ScoreImportFatalError); + expect(() => ParseEamusementSDVXCSV(file, {}, log)).toThrow( + "Invalid CSV provided. CSV does not have the correct number of headers.", + ); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.ts index 675964d16..5da10b8cd 100644 --- a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.ts @@ -1,13 +1,13 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; +import type { GamesForGroup } from "tachi-common"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { CSVParseError, NaiveCSVParse } from "#utils/naive-csv-parser"; -import type { ParserFunctionReturns } from "../../common/types"; import type { SDVXEamusementCSVData } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - const HEADER_COUNT = 11; // A SDVX CSV Row has exactly 11 elements. This is used for type safety. @@ -29,7 +29,7 @@ export default function ParseEamusementSDVXCSV( fileData: Express.Multer.File, _body: Record, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { let rawHeaders: Array; let rawRows: Array>; @@ -69,7 +69,7 @@ export default function ParseEamusementSDVXCSV( return { iterable, context: {}, - game: "sdvx", + gameGroup: "sdvx", classProvider: null, }; } diff --git a/typescript/server/src/lib/score-import/import-types/file/pli-iidx-csv/parser.ts b/typescript/server/src/lib/score-import/import-types/file/pli-iidx-csv/parser.ts index 34a87c475..60dae1029 100644 --- a/typescript/server/src/lib/score-import/import-types/file/pli-iidx-csv/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/pli-iidx-csv/parser.ts @@ -1,18 +1,18 @@ import type { KtLogger } from "#lib/log/log"; - import type { IIDXEamusementCSVContext, IIDXEamusementCSVData, -} from "../../common/eamusement-iidx-csv/types"; -import type { ParserFunctionReturns } from "../../common/types"; +} from "#lib/score-import/import-types/common/eamusement-iidx-csv/types"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup } from "tachi-common"; -import GenericParseEamIIDXCSV from "../../common/eamusement-iidx-csv/parser"; +import GenericParseEamIIDXCSV from "#lib/score-import/import-types/common/eamusement-iidx-csv/parser"; function ParsePLIIIDXCSV( fileData: Express.Multer.File, body: Record, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { return GenericParseEamIIDXCSV(fileData, body, "PLI", log); } diff --git a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.oldtest.ts deleted file mode 100644 index 31a165bce..000000000 --- a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.oldtest.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { - LoadTachiIIDXData, - MockParsedS3Score, - Testing511Song, - Testing511SPA, -} from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import type { S3Score } from "./types"; - -import { ConvertFileS3, ParseDifficulty, ResolveS3Lamp } from "./converter"; - -function cfile(data: S3Score) { - return ConvertFileS3(data, {}, "file/solid-state-squad", log); -} - -t.test("#ConvertFileS3", (t) => { - t.beforeEach(ResetDBState); - - function mfile(merge: Partial) { - return cfile(deepmerge(MockParsedS3Score, merge)); - } - - const dryScore = { - game: "iidx", - comment: null, - importType: "file/solid-state-squad", - service: "Solid State Squad", - scoreData: { - score: 100, - lamp: "FULL COMBO", - judgements: { - pgreat: 25, - great: 50, - good: 0, - bad: 0, - poor: 4, - }, - optional: {}, - }, - scoreMeta: {}, - - // can't be tested because the timestamp format doesnt specify a timezone. - // timeAchieved: 1287460462000, - }; - - t.test("Should import a valid S3 score", async (t) => { - const res = await cfile(MockParsedS3Score); - - t.hasStrict( - res, - { - chart: Testing511SPA, - song: Testing511Song, - dryScore, - }, - "Should correctly return the song, chart and DryScore.", - ); - - t.end(); - }); - - t.test("Should support comments in S3 scores", async (t) => { - const res = await mfile({ comment: "FOO BAR" }); - - t.hasStrict( - res, - { - chart: Testing511SPA, - song: Testing511Song, - dryScore: deepmerge(dryScore, { comment: "FOO BAR" }), - }, - "Should correctly return the song, chart and DryScore.", - ); - - t.end(); - }); - - t.test("Should find song case-insensitively", async (t) => { - await LoadTachiIIDXData(); - - const res = await mfile({ songname: "aBSolUte", diff: 7 }); - - t.hasStrict( - res, - { - chart: { songID: 97, difficulty: "HYPER", playtype: "SP" }, - song: { title: "ABSOLUTE" }, - - // dryScore, dont care - }, - "Should correctly return the song, chart and DryScore.", - ); - - t.end(); - }); - - t.test("Should reject invalid styles in S3 scores", (t) => { - t.rejects(mfile({ styles: "3rd,4th,INVALID" }), { - message: /Song has invalid style INVALID/u, - }); - - t.end(); - }); - - t.test("Should throw ktdatanf if no song", (t) => { - t.rejects(mfile({ songname: "INVALID SONG TITLE" }), { - message: /Could not find song with title INVALID SONG TITLE/u, - }); - - t.end(); - }); - - t.test("Should throw ktdatanf if no song", (t) => { - t.rejects(mfile({ diff: "B" }), { - message: /Could not find chart 5\.1\.1\. \(SP LEGGENDARIA/u, - }); - - t.end(); - }); - - t.test("Should throw a skipscore if the song is 5key", (t) => { - t.rejects(mfile({ diff: 5 }), { - message: /5KEY scores are not supported/u, - }); - - t.end(); - }); - - t.test("Should throw an invalidscore if the difficulty is invalid", (t) => { - t.rejects(mfile({ diff: "INVALID" } as unknown as S3Score), { - message: /Invalid difficulty INVALID/u, - }); - - t.end(); - }); - - t.test("Should throw an invalidscore if the hardeasy is invalid", (t) => { - t.rejects( - mfile({ mods: { hardeasy: "INVALID" }, cleartype: "cleared" } as unknown as S3Score), - { - message: /Invalid hardeasy of INVALID while evaluating a 'cleared' score/u, - }, - ); - - t.end(); - }); - - t.test("Should throw an invalidscore if the cleartype is invalid", (t) => { - t.rejects(mfile({ cleartype: "INVALID" } as unknown as S3Score), { - message: /Invalid cleartype of INVALID/u, - }); - - t.end(); - }); - - t.test("Should throw an invalidscore if the date is invalid.", (t) => { - t.rejects(mfile({ date: "INVALID" }), { - message: /Invalid\/Unparsable score timestamp of INVALID/u, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("#ParseDifficulty", (t) => { - t.beforeEach(ResetDBState); - - t.strictSame(ParseDifficulty("L7"), { playtype: "SP", difficulty: "NORMAL" }); - t.strictSame(ParseDifficulty(7), { playtype: "SP", difficulty: "HYPER" }); - t.strictSame(ParseDifficulty("A"), { playtype: "SP", difficulty: "ANOTHER" }); - t.strictSame(ParseDifficulty("B"), { playtype: "SP", difficulty: "LEGGENDARIA" }); - t.strictSame(ParseDifficulty("L14"), { playtype: "DP", difficulty: "NORMAL" }); - t.strictSame(ParseDifficulty(14), { playtype: "DP", difficulty: "HYPER" }); - t.strictSame(ParseDifficulty("A14"), { playtype: "DP", difficulty: "ANOTHER" }); - t.strictSame(ParseDifficulty("B14"), { playtype: "DP", difficulty: "LEGGENDARIA" }); - t.throws(() => ParseDifficulty(5)); - - t.end(); -}); - -t.test("#ResolveS3Lamp", (t) => { - t.beforeEach(ResetDBState); - - t.equal(ResolveS3Lamp({ cleartype: "played" } as S3Score), "FAILED"); - t.equal(ResolveS3Lamp({ cleartype: "cleared", mods: {} } as S3Score), "CLEAR"); - t.equal( - ResolveS3Lamp({ cleartype: "cleared", mods: { hardeasy: "E" } } as S3Score), - "EASY CLEAR", - ); - t.equal( - ResolveS3Lamp({ cleartype: "cleared", mods: { hardeasy: "H" } } as S3Score), - "HARD CLEAR", - ); - t.equal(ResolveS3Lamp({ cleartype: "combo" } as S3Score), "FULL COMBO"); - t.equal(ResolveS3Lamp({ cleartype: "comboed" } as S3Score), "FULL COMBO"); - t.equal(ResolveS3Lamp({ cleartype: "perfect" } as S3Score), "FULL COMBO"); - t.equal(ResolveS3Lamp({ cleartype: "perfected" } as S3Score), "FULL COMBO"); - - t.throws(() => ResolveS3Lamp({ cleartype: "invalid" } as unknown as S3Score)); - t.throws(() => - ResolveS3Lamp({ - cleartype: "cleared", - mods: { hardeasy: "invalid" }, - } as unknown as S3Score), - ); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts new file mode 100644 index 000000000..a234621a5 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts @@ -0,0 +1,283 @@ +import { log } from "#lib/log/log"; +import { + InvalidScoreFailure, + SkipScoreFailure, +} from "#lib/score-import/framework/common/converter-failures"; +import DB from "#services/pg/db"; +import { MockParsedS3Score, Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { describe, expect, it } from "vitest"; + +import type { S3Score } from "./types"; + +import { ConvertFileS3, ParseDifficulty, ResolveS3Lamp } from "./converter"; + +const SONG_511_ID = Testing511Song.id; +const CHART_511_ID = Testing511SPA.chartID; + +async function seed511SongOnly() { + await DB.insertInto("song") + .values({ + id: SONG_511_ID, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); +} + +async function seed511ChartAnother() { + await seed511SongOnly(); + + await DB.insertInto("chart") + .values({ + id: CHART_511_ID, + legacy_id: CHART_511_ID, + game: "iidx-sp", + song_id: SONG_511_ID, + difficulty: "ANOTHER", + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: ["7-cs"], + data: Testing511SPA.data, + }) + .execute(); +} + +async function seedAbsoluteHyper() { + const songId = "s_absolute_ci"; + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: 97, + game_group: "iidx", + title: "ABSOLUTE", + artist: "artist", + search_terms: [], + alt_titles: [], + data: { displayVersion: "1", genre: "TEST" }, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: "c_absolute_ci", + legacy_id: "c_absolute_ci", + game: "iidx-sp", + song_id: songId, + difficulty: "HYPER", + level: "10", + level_num: 10, + is_primary: true, + versions: ["7-cs"], + data: { inGameID: 1, notecount: 100 }, + }) + .execute(); +} + +function cfile(data: S3Score) { + return ConvertFileS3(data, {}, "file/solid-state-squad", log); +} + +function mfile(merge: Partial) { + return cfile(deepmerge(MockParsedS3Score, merge)); +} + +describe("ConvertFileS3", () => { + const dryScore = { + game: "iidx-sp" as const, + comment: null, + importType: "file/solid-state-squad", + service: "Solid State Squad", + scoreData: { + score: 100, + lamp: "FULL COMBO", + judgements: { + pgreat: 25, + great: 50, + good: 0, + bad: 0, + poor: 4, + }, + optional: {}, + }, + scoreMeta: {}, + }; + + it("imports a valid S3 score", async () => { + await seed511ChartAnother(); + + const res = await cfile(MockParsedS3Score); + + expect(res.chart).toMatchObject({ + chartID: Testing511SPA.chartID, + game: "iidx-sp", + difficulty: "ANOTHER", + level: Testing511SPA.level, + song: Testing511Song, + }); + expect(res.song).toMatchObject(Testing511Song); + expect(res).toMatchObject({ dryScore }); + expect(res.dryScore.timeAchieved).toEqual(expect.any(Number)); + }); + + it("supports comments on S3 scores", async () => { + await seed511ChartAnother(); + + const res = await mfile({ comment: "FOO BAR" }); + + expect(res.chart).toMatchObject({ + chartID: Testing511SPA.chartID, + game: "iidx-sp", + difficulty: "ANOTHER", + }); + expect(res.song).toMatchObject(Testing511Song); + expect(res).toMatchObject({ dryScore: { ...dryScore, comment: "FOO BAR" } }); + }); + + it("finds songs case-insensitively", async () => { + await seed511ChartAnother(); + await seedAbsoluteHyper(); + + const res = await mfile({ songname: "aBSolUte", diff: 7 }); + + expect(res).toMatchObject({ + chart: { difficulty: "HYPER", game: "iidx-sp", song: { title: "ABSOLUTE" } }, + song: { title: "ABSOLUTE" }, + }); + }); + + it("rejects invalid styles", async () => { + await seed511SongOnly(); + + await expect(mfile({ styles: "3rd,4th,INVALID" })).rejects.toMatchObject({ + failureType: "InvalidScore", + message: expect.stringMatching(/Song has invalid style INVALID/u) as string, + }); + }); + + it("throws when no song matches", async () => { + await expect(mfile({ songname: "INVALID SONG TITLE" })).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: expect.stringMatching( + /Could not find song with title INVALID SONG TITLE/u, + ) as string, + }); + }); + + it("throws when no chart matches difficulty", async () => { + await seed511ChartAnother(); + + await expect(mfile({ diff: "B" })).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: expect.stringMatching( + /Could not find chart 5\.1\.1\. \(iidx-sp LEGGENDARIA version \(7-cs\)\)/u, + ) as string, + }); + }); + + it("skips 5KEY scores", async () => { + await seed511SongOnly(); + + await expect(mfile({ diff: 5 })).rejects.toMatchObject({ + failureType: "SkipScore", + message: expect.stringMatching(/5KEY scores are not supported/u) as string, + }); + }); + + it("rejects invalid difficulty", async () => { + await seed511SongOnly(); + + await expect(mfile({ diff: "INVALID" } as unknown as S3Score)).rejects.toMatchObject({ + failureType: "InvalidScore", + message: expect.stringMatching(/Invalid difficulty INVALID/u) as string, + }); + }); + + it("rejects invalid hardeasy for a cleared lamp", async () => { + await seed511ChartAnother(); + + await expect( + mfile({ mods: { hardeasy: "INVALID" }, cleartype: "cleared" } as unknown as S3Score), + ).rejects.toMatchObject({ + failureType: "InvalidScore", + message: expect.stringMatching( + /Invalid hardeasy of INVALID while evaluating a 'cleared' score\?/u, + ) as string, + }); + }); + + it("rejects invalid cleartype", async () => { + await seed511ChartAnother(); + + await expect(mfile({ cleartype: "INVALID" } as unknown as S3Score)).rejects.toMatchObject({ + failureType: "InvalidScore", + message: expect.stringMatching(/Invalid cleartype of INVALID/u) as string, + }); + }); + + it("rejects invalid timestamps", async () => { + await seed511ChartAnother(); + + await expect(mfile({ date: "INVALID" })).rejects.toMatchObject({ + failureType: "InvalidScore", + message: expect.stringMatching( + /Invalid\/Unparsable score timestamp of INVALID/u, + ) as string, + }); + }); +}); + +describe("ParseDifficulty", () => { + it("maps S3 difficulty tokens to IIDX SP/DP charts", () => { + expect(ParseDifficulty("L7")).toStrictEqual({ game: "iidx-sp", difficulty: "NORMAL" }); + expect(ParseDifficulty(7)).toStrictEqual({ game: "iidx-sp", difficulty: "HYPER" }); + expect(ParseDifficulty("A")).toStrictEqual({ game: "iidx-sp", difficulty: "ANOTHER" }); + expect(ParseDifficulty("B")).toStrictEqual({ game: "iidx-sp", difficulty: "LEGGENDARIA" }); + expect(ParseDifficulty("L14")).toStrictEqual({ game: "iidx-dp", difficulty: "NORMAL" }); + expect(ParseDifficulty(14)).toStrictEqual({ game: "iidx-dp", difficulty: "HYPER" }); + expect(ParseDifficulty("A14")).toStrictEqual({ game: "iidx-dp", difficulty: "ANOTHER" }); + expect(ParseDifficulty("B14")).toStrictEqual({ + game: "iidx-dp", + difficulty: "LEGGENDARIA", + }); + expect(() => ParseDifficulty(5)).toThrow(SkipScoreFailure); + }); +}); + +describe("ResolveS3Lamp", () => { + it("maps cleartype and hardeasy to lamps", () => { + expect(ResolveS3Lamp({ cleartype: "played" } as S3Score)).toBe("FAILED"); + expect(ResolveS3Lamp({ cleartype: "cleared", mods: {} } as S3Score)).toBe("CLEAR"); + expect(ResolveS3Lamp({ cleartype: "cleared", mods: { hardeasy: "E" } } as S3Score)).toBe( + "EASY CLEAR", + ); + expect(ResolveS3Lamp({ cleartype: "cleared", mods: { hardeasy: "H" } } as S3Score)).toBe( + "HARD CLEAR", + ); + expect(ResolveS3Lamp({ cleartype: "combo" } as S3Score)).toBe("FULL COMBO"); + expect(ResolveS3Lamp({ cleartype: "comboed" } as S3Score)).toBe("FULL COMBO"); + expect(ResolveS3Lamp({ cleartype: "perfect" } as S3Score)).toBe("FULL COMBO"); + expect(ResolveS3Lamp({ cleartype: "perfected" } as S3Score)).toBe("FULL COMBO"); + }); + + it("rejects unknown cleartype or hardeasy", () => { + expect(() => ResolveS3Lamp({ cleartype: "invalid" } as unknown as S3Score)).toThrow( + InvalidScoreFailure, + ); + expect(() => + ResolveS3Lamp({ + cleartype: "cleared", + mods: { hardeasy: "invalid" }, + } as unknown as S3Score), + ).toThrow(InvalidScoreFailure); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.ts b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.ts index b78a830ca..d5277ae80 100644 --- a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/converter.ts @@ -1,50 +1,49 @@ +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; -import type { Difficulties, Playtypes, Versions } from "tachi-common"; +import type { Difficulties, GamesForGroup, Versions } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; -import { FindChartWithPTDFVersion } from "#utils/queries/charts"; -import { FindSongOnTitleInsensitive } from "#utils/queries/songs"; - -import type { DryScore } from "../../../framework/common/types"; -import type { ConverterFunction } from "../../common/types"; -import type { S3Score } from "./types"; - import { InvalidScoreFailure, SkipScoreFailure, SongOrChartNotFoundFailure, -} from "../../../framework/common/converter-failures"; -import { ParseDateFromString } from "../../../framework/common/score-utils"; +} from "#lib/score-import/framework/common/converter-failures"; +import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; +import { FindChartWithSongDifficultyVersion } from "#utils/queries/charts"; +import { FindSongOnTitleInsensitive } from "#utils/queries/songs"; + +import type { S3Score } from "./types"; export function ParseDifficulty(diff: S3Score["diff"]): { - difficulty: Difficulties["iidx:DP" | "iidx:SP"]; - playtype: Playtypes["iidx"]; + difficulty: Difficulties[GamesForGroup["iidx"]]; + game: GamesForGroup["iidx"]; } { switch (diff) { case "L7": - return { playtype: "SP", difficulty: "NORMAL" }; + return { game: "iidx-sp", difficulty: "NORMAL" }; case 7: - return { playtype: "SP", difficulty: "HYPER" }; + return { game: "iidx-sp", difficulty: "HYPER" }; case "A": - return { playtype: "SP", difficulty: "ANOTHER" }; + return { game: "iidx-sp", difficulty: "ANOTHER" }; case "B": - return { playtype: "SP", difficulty: "LEGGENDARIA" }; + return { game: "iidx-sp", difficulty: "LEGGENDARIA" }; case 5: throw new SkipScoreFailure(`5KEY scores are not supported.`); case "L14": - return { playtype: "DP", difficulty: "NORMAL" }; + return { game: "iidx-dp", difficulty: "NORMAL" }; case 14: - return { playtype: "DP", difficulty: "HYPER" }; + return { game: "iidx-dp", difficulty: "HYPER" }; case "A14": - return { playtype: "DP", difficulty: "ANOTHER" }; + return { game: "iidx-dp", difficulty: "ANOTHER" }; case "B14": - return { playtype: "DP", difficulty: "LEGGENDARIA" }; + return { game: "iidx-dp", difficulty: "LEGGENDARIA" }; default: throw new InvalidScoreFailure(`Invalid difficulty ${diff}.`); } } -export function ResolveS3Lamp(data: S3Score): GetEnumValue<"iidx:DP" | "iidx:SP", "lamp"> { +export function ResolveS3Lamp(data: S3Score): GetEnumValue { switch (data.cleartype) { case "played": return "FAILED"; @@ -72,7 +71,7 @@ export function ResolveS3Lamp(data: S3Score): GetEnumValue<"iidx:DP" | "iidx:SP" } } -const S3_VERSION_CONV: Record = { +const S3_VERSION_CONV: Record = { "3rd": "3-cs", "4th": "4-cs", "5th": "5-cs", @@ -126,14 +125,14 @@ export const ConvertFileS3: ConverterFunction = async ( ); } - const { playtype, difficulty } = ParseDifficulty(data.diff); + const { game, difficulty } = ParseDifficulty(data.diff); const version = ConvertVersion(data.styles); - const chart = await FindChartWithPTDFVersion("iidx", song.id, playtype, difficulty, version); + const chart = await FindChartWithSongDifficultyVersion(game, song.id, difficulty, version); if (!chart) { throw new SongOrChartNotFoundFailure( - `Could not find chart ${data.songname} (${playtype} ${difficulty} version (${version}))`, + `Could not find chart ${data.songname} (${game} ${difficulty} version (${version}))`, importType, data, context, @@ -156,8 +155,8 @@ export const ConvertFileS3: ConverterFunction = async ( }; } - const dryScore: DryScore<"iidx:DP" | "iidx:SP"> = { - game: "iidx", + const dryScore: DryScore = { + game, comment: data.comment ?? null, importType: "file/solid-state-squad", service: "Solid State Squad", diff --git a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.oldtest.ts deleted file mode 100644 index c7925ec5f..000000000 --- a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.oldtest.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { MockMulterFile } from "#test-utils/mock-multer"; -import ResetDBState from "#test-utils/resets"; -import { GetKTDataBuffer } from "#test-utils/test-data"; -import t from "tap"; - -import { ParseSolidStateXML } from "./parser"; - -t.test("#ParseSolidStateXML", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should parse simple, valid S3 XML", (t) => { - const res = ParseSolidStateXML( - MockMulterFile(GetKTDataBuffer("./s3/valid.xml"), "valid.xml"), - {}, - log, - ); - - t.hasStrict( - res.iterable, - [ - { - id: 187, - diff: 7, - songname: "GAMBOL", - styles: "3rd", - exscore: 100, - scorebreakdown: { - justgreats: 25, - greats: 50, - good: 0, - bad: 0, - poor: 4, - }, - mods: { - hardeasy: "H", - }, - cleartype: "cleared", - date: "2010-10-19 04:54:22", - }, - { - id: 187, - diff: "L7", - songname: "GAMBOL", - styles: "3rd", - exscore: 100, - scorebreakdown: { - justgreats: 25, - greats: 50, - good: 0, - bad: 0, - poor: 4, - }, - mods: {}, - cleartype: "perfect", - date: "2010-10-19 04:54:22", - }, - ], - "Should return the right scores in the iterable.", - ); - t.equal(res.game, "iidx", "Should return IIDX as the game."); - t.equal(res.classProvider, null, "Should return no class handler."); - t.strictSame(res.context, {}, "Should return no context."); - - t.end(); - }); - - t.test("Should parse S3 XML with a single score", (t) => { - const res = ParseSolidStateXML( - MockMulterFile(GetKTDataBuffer("./s3/one-score.xml"), "one-score.xml"), - {}, - log, - ); - - t.hasStrict( - res.iterable, - [ - { - id: 187, - diff: 7, - songname: "GAMBOL", - styles: "3rd", - exscore: 100, - scorebreakdown: { - justgreats: 50, - greats: 50, - good: 0, - bad: 0, - poor: 4, - }, - mods: {}, - cleartype: "perfect", - date: "2010-10-19 04:54:22", - }, - ], - "Should return the right score in the iterable.", - ); - t.equal(res.game, "iidx", "Should return IIDX as the game."); - t.equal(res.classProvider, null, "Should return no class handler."); - t.strictSame(res.context, {}, "Should return no context."); - - t.end(); - }); - - t.test("Should parse S3 XML on the chart .59", (t) => { - const res = ParseSolidStateXML( - MockMulterFile(GetKTDataBuffer("./s3/point-five-nine.xml"), "point-five-nine.xml"), - {}, - log, - ); - - t.hasStrict( - res.iterable, - [ - { - id: 187, - diff: 7, - songname: ".59", - styles: "3rd", - exscore: 100, - scorebreakdown: { - justgreats: 50, - greats: 50, - good: 0, - bad: 0, - poor: 4, - }, - mods: {}, - cleartype: "perfect", - date: "2010-10-19 04:54:22", - }, - ], - "Should return the right score in the iterable.", - ); - t.equal(res.game, "iidx", "Should return IIDX as the game."); - t.equal(res.classProvider, null, "Should return no class handler."); - t.strictSame(res.context, {}, "Should return no context."); - - t.end(); - }); - - t.test("Should reject S3 XML with no scores", (t) => { - t.throws( - () => - ParseSolidStateXML( - MockMulterFile(GetKTDataBuffer("./s3/no-score-data.xml"), "no-score-data.xml"), - - {}, - log, - ), - { message: /Invalid S3 XML/u }, - ); - - t.end(); - }); - - t.test("Should reject invalid lamps", (t) => { - t.throws( - () => - ParseSolidStateXML( - MockMulterFile(GetKTDataBuffer("./s3/invalid-lamp.xml"), "invalid-lamp.xml"), - {}, - log, - ), - { message: /Invalid S3 XML.*cleartype.*BAD LAMP/u }, - ); - - t.end(); - }); - - t.test("Should reject malicious mods", (t) => { - t.throws( - () => - ParseSolidStateXML( - MockMulterFile( - GetKTDataBuffer("./s3/malicious-mods.xml"), - "malicious-mods.xml", - ), - {}, - log, - ), - { message: /Invalid S3 XML.*object.*1/u }, - ); - - t.end(); - }); - - t.test("Should reject malicious scorebreakdown", (t) => { - t.throws( - () => - ParseSolidStateXML( - MockMulterFile( - GetKTDataBuffer("./s3/malicious-scorebreakdown.xml"), - "malicious-scorebreakdown.xml", - ), - {}, - log, - ), - { message: /Invalid S3 XML.*object.*1/u }, - ); - - t.end(); - }); - - t.test("Should reject invalid exscore", (t) => { - t.throws( - () => - ParseSolidStateXML( - MockMulterFile( - GetKTDataBuffer("./s3/invalid-exscore.xml"), - "invalid-exscore.xml", - ), - {}, - log, - ), - { message: /Invalid S3 XML.*exscore.*positive integer.*-1/u }, - ); - - t.end(); - }); - - t.test("Should reject billion laughs", (t) => { - t.setTimeout(5000); - - t.throws( - () => - ParseSolidStateXML( - MockMulterFile( - GetKTDataBuffer("./s3/danger/billion-laughs.xml"), - "billion-laughs.xml", - ), - {}, - log, - ), - { message: /Invalid S3 XML/u }, - ); - - t.end(); - }); - - t.test("Should not expand specifically crafted billion laughs", (t) => { - t.setTimeout(5000); - - const res = ParseSolidStateXML( - MockMulterFile( - GetKTDataBuffer("./s3/danger/specific-blaugh.xml"), - "specific-blaugh.xml", - ), - {}, - log, - ); - - // @ts-expect-error shush - t.equal(res.iterable[0].songname, "&lol9;", "Should not expand billion laughs."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.test.ts b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.test.ts new file mode 100644 index 000000000..d7879b8b2 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.test.ts @@ -0,0 +1,195 @@ +import { log } from "#lib/log/log"; +import { MockMulterFile } from "#test-utils/mock-multer"; +import { GetKTDataBuffer } from "#test-utils/test-data"; +import { describe, expect, it } from "vitest"; + +import type { S3Score } from "./types"; + +import { ParseSolidStateXML } from "./parser"; + +describe("ParseSolidStateXML", () => { + it("parses simple valid S3 XML", () => { + const res = ParseSolidStateXML( + MockMulterFile(GetKTDataBuffer("./s3/valid.xml"), "valid.xml"), + {}, + log, + ); + + expect(res.iterable as S3Score[]).toHaveLength(2); + expect((res.iterable as S3Score[])[0]).toMatchObject({ + id: 187, + diff: 7, + songname: "GAMBOL", + styles: "3rd", + exscore: 100, + scorebreakdown: { + justgreats: 25, + greats: 50, + good: 0, + bad: 0, + poor: 4, + }, + mods: { + hardeasy: "H", + }, + cleartype: "cleared", + date: "2010-10-19 04:54:22", + }); + expect((res.iterable as S3Score[])[1]).toMatchObject({ + id: 187, + diff: "L7", + songname: "GAMBOL", + styles: "3rd", + exscore: 100, + scorebreakdown: { + justgreats: 25, + greats: 50, + good: 0, + bad: 0, + poor: 4, + }, + cleartype: "perfect", + date: "2010-10-19 04:54:22", + }); + expect(res.gameGroup).toBe("iidx"); + expect(res.classProvider).toBeNull(); + expect(res.context).toEqual({}); + }); + + it("parses S3 XML with a single score", () => { + const res = ParseSolidStateXML( + MockMulterFile(GetKTDataBuffer("./s3/one-score.xml"), "one-score.xml"), + {}, + log, + ); + + expect(res.iterable as S3Score[]).toHaveLength(1); + expect((res.iterable as S3Score[])[0]).toMatchObject({ + id: 187, + diff: 7, + songname: "GAMBOL", + styles: "3rd", + exscore: 100, + scorebreakdown: { + justgreats: 50, + greats: 50, + good: 0, + bad: 0, + poor: 4, + }, + cleartype: "perfect", + date: "2010-10-19 04:54:22", + }); + expect(res.gameGroup).toBe("iidx"); + expect(res.classProvider).toBeNull(); + expect(res.context).toEqual({}); + }); + + it("parses .59 chart XML", () => { + const res = ParseSolidStateXML( + MockMulterFile(GetKTDataBuffer("./s3/point-five-nine.xml"), "point-five-nine.xml"), + {}, + log, + ); + + expect(res.iterable as S3Score[]).toHaveLength(1); + expect((res.iterable as S3Score[])[0]).toMatchObject({ + id: 187, + diff: 7, + songname: ".59", + styles: "3rd", + exscore: 100, + scorebreakdown: { + justgreats: 50, + greats: 50, + good: 0, + bad: 0, + poor: 4, + }, + cleartype: "perfect", + date: "2010-10-19 04:54:22", + }); + expect(res.gameGroup).toBe("iidx"); + expect(res.classProvider).toBeNull(); + expect(res.context).toEqual({}); + }); + + it("rejects XML with no scores", () => { + expect(() => + ParseSolidStateXML( + MockMulterFile(GetKTDataBuffer("./s3/no-score-data.xml"), "no-score-data.xml"), + {}, + log, + ), + ).toThrow(/Invalid S3 XML/u); + }); + + it("rejects invalid lamps", () => { + expect(() => + ParseSolidStateXML( + MockMulterFile(GetKTDataBuffer("./s3/invalid-lamp.xml"), "invalid-lamp.xml"), + {}, + log, + ), + ).toThrow(/Invalid S3 XML.*cleartype.*BAD LAMP/u); + }); + + it("rejects malicious mods", () => { + expect(() => + ParseSolidStateXML( + MockMulterFile(GetKTDataBuffer("./s3/malicious-mods.xml"), "malicious-mods.xml"), + {}, + log, + ), + ).toThrow(/Invalid S3 XML.*object.*1/u); + }); + + it("rejects malicious scorebreakdown", () => { + expect(() => + ParseSolidStateXML( + MockMulterFile( + GetKTDataBuffer("./s3/malicious-scorebreakdown.xml"), + "malicious-scorebreakdown.xml", + ), + {}, + log, + ), + ).toThrow(/Invalid S3 XML.*object.*1/u); + }); + + it("rejects invalid exscore", () => { + expect(() => + ParseSolidStateXML( + MockMulterFile(GetKTDataBuffer("./s3/invalid-exscore.xml"), "invalid-exscore.xml"), + {}, + log, + ), + ).toThrow(/Invalid S3 XML.*exscore.*positive integer.*-1/u); + }); + + it("rejects billion laughs expansion", () => { + expect(() => + ParseSolidStateXML( + MockMulterFile( + GetKTDataBuffer("./s3/danger/billion-laughs.xml"), + "billion-laughs.xml", + ), + {}, + log, + ), + ).toThrow(/Invalid S3 XML/u); + }, 5000); + + it("does not expand a specifically crafted payload", () => { + const res = ParseSolidStateXML( + MockMulterFile( + GetKTDataBuffer("./s3/danger/specific-blaugh.xml"), + "specific-blaugh.xml", + ), + {}, + log, + ); + + expect((res.iterable as S3Score[])[0]?.songname).toBe("&lol9;"); + }, 5000); +}); diff --git a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.ts b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.ts index 9871d463e..b2c2d6e0f 100644 --- a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.ts @@ -1,15 +1,15 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; +import type { GamesForGroup } from "tachi-common"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError } from "#utils/prudence"; import { XMLParser } from "fast-xml-parser"; import { p, type PrudenceSchema } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { S3Score } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - const PR_SOLID_STATE: PrudenceSchema = { s3data: { userdata: { @@ -74,7 +74,7 @@ export function ParseSolidStateXML( fileData: Express.Multer.File, body: Record, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { let parsedXML; try { @@ -137,6 +137,6 @@ export function ParseSolidStateXML( classProvider: null, context: {}, iterable: scoreData as Array, - game: "iidx", + gameGroup: "iidx", }; } diff --git a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/class-handler.ts b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/class-handler.ts index 2b736b943..cb5c53c3e 100644 --- a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/class-handler.ts +++ b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/class-handler.ts @@ -1,12 +1,12 @@ import type { ClassProvider } from "#lib/score-import/framework/calculated-data/types"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { WACCA_STAGEUPS } from "tachi-common"; import { WaccaStageUps } from "tachi-common/config/game-support/wacca"; import type { MyPagePlayerStage } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - +// TODO(zk): unused??? const STAGES: Record = { 1: WACCA_STAGEUPS.I, 2: WACCA_STAGEUPS.II, @@ -42,8 +42,8 @@ const STAGE_NAMES: Record = { 14: "XIV", }; -export function CreateMyPageScraperClassProvider(stage: MyPagePlayerStage): ClassProvider { - return (gpt, userID, ratings, log) => { +export function CreateMyPageScraperClassProvider(stage: MyPagePlayerStage): ClassProvider<"wacca"> { + return (_game, _userID, _ratings, log) => { const stageName = STAGE_NAMES[stage.id]; if (stageName === undefined) { diff --git a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.oldtest.ts deleted file mode 100644 index 6d526b6b6..000000000 --- a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.oldtest.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { TestingWaccaPupaExp, TestingWaccaPupaSong } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import type { MyPageRecordsParsedPB } from "./types"; - -import ConvertMyPageScraperRecordsCSV from "./converter"; - -const parsedScore: MyPageRecordsParsedPB = { - // 2085,PUPA,モリモリあつし,4,"[4,8,13+,0]","[0,0,12]","[0,0,996827]","[0,0,2]" - songId: 2085, - songTitle: "PUPA", - diffIndex: 2, - level: "13+", - score: 996827, - lamp: 2, -}; - -t.test("#ConvertMyPageScraperRecordsCSV", (t) => { - t.beforeEach(ResetDBState); - - function conv(g: Partial = {}) { - return ConvertMyPageScraperRecordsCSV( - deepmerge(parsedScore, g), - {}, - "file/mypagescraper-records-csv", - log, - ); - } - - t.test("Should return a dryScore on valid input.", async (t) => { - const res = await conv(); - - t.strictSame(res, { - song: TestingWaccaPupaSong, - chart: TestingWaccaPupaExp, - dryScore: { - service: "mypage-scraper", - game: "wacca", - scoreMeta: {}, - timeAchieved: null, - comment: null, - importType: "file/mypagescraper-records-csv", - scoreData: { - score: 996827, - lamp: "MISSLESS", - judgements: {}, - optional: {}, - }, - }, - }); - - t.end(); - }); - - t.test("Should reject out of bounds diffIndex", (t) => { - t.rejects(() => conv({ diffIndex: 4 }), { - message: /Invalid difficulty index of 4\./u, - }); - - t.end(); - }); - - t.test("Should throw KTDataNotFound on missing song", (t) => { - t.rejects(() => conv({ songTitle: "INVALID SONG" }), { - message: /Could not find song for INVALID SONG\./u, - }); - - t.end(); - }); - - t.test("Should reject incorrect level", (t) => { - t.rejects(() => conv({ level: "12" }), { - message: /PUPA \[EXPERT\] - Should be level 13\+, but found level 12\./u, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.test.ts b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.test.ts new file mode 100644 index 000000000..b4a83e1e4 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.test.ts @@ -0,0 +1,112 @@ +import { log } from "#lib/log/log"; +import DB from "#services/pg/db"; +import { TestingWaccaPupaExp, TestingWaccaPupaSong } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { describe, expect, it } from "vitest"; + +import type { MyPageRecordsParsedPB } from "./types"; + +import ConvertMyPageScraperRecordsCSV from "./converter"; + +const parsedScore: MyPageRecordsParsedPB = { + songId: 2085, + songTitle: "PUPA", + diffIndex: 2, + level: "13+", + score: 996827, + lamp: 2, +}; + +async function seedPupaExpert() { + await DB.insertInto("song") + .values({ + id: TestingWaccaPupaSong.id, + legacy_id: 2085, + game_group: "wacca", + title: TestingWaccaPupaSong.title, + artist: TestingWaccaPupaSong.artist, + search_terms: TestingWaccaPupaSong.searchTerms, + alt_titles: TestingWaccaPupaSong.altTitles, + data: TestingWaccaPupaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingWaccaPupaExp.chartID, + legacy_id: TestingWaccaPupaExp.chartID, + game: "wacca", + song_id: TestingWaccaPupaSong.id, + difficulty: "EXPERT", + level: TestingWaccaPupaExp.level, + level_num: TestingWaccaPupaExp.levelNum, + is_primary: TestingWaccaPupaExp.isPrimary, + versions: TestingWaccaPupaExp.versions, + data: TestingWaccaPupaExp.data, + }) + .execute(); +} + +function conv(g: Partial = {}) { + return ConvertMyPageScraperRecordsCSV( + deepmerge(parsedScore, g), + {}, + "file/mypagescraper-records-csv", + log, + ); +} + +describe("ConvertMyPageScraperRecordsCSV", () => { + it("returns song, chart, and dryScore for valid input", async () => { + await seedPupaExpert(); + + const res = await conv(); + + expect(res).toStrictEqual({ + song: TestingWaccaPupaSong, + chart: TestingWaccaPupaExp, + dryScore: { + service: "mypage-scraper", + game: "wacca", + scoreMeta: {}, + timeAchieved: null, + comment: null, + importType: "file/mypagescraper-records-csv", + scoreData: { + score: 996827, + lamp: "MISSLESS", + judgements: {}, + optional: {}, + }, + }, + }); + }); + + it("rejects out-of-range diffIndex", async () => { + await seedPupaExpert(); + + await expect(conv({ diffIndex: 4 })).rejects.toMatchObject({ + message: expect.stringMatching(/Invalid difficulty index of 4/u) as string, + }); + }); + + it("throws when the song is unknown", async () => { + await seedPupaExpert(); + + await expect(conv({ songTitle: "INVALID SONG" })).rejects.toMatchObject({ + failureType: "SongOrChartNotFound", + message: /Could not find song for INVALID SONG\./u, + }); + }); + + it("rejects incorrect level", async () => { + await seedPupaExpert(); + + await expect(conv({ level: "12" })).rejects.toMatchObject({ + message: expect.stringMatching( + /PUPA \[EXPERT\] - Should be level 13\+, but found level 12/u, + ) as string, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.ts b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.ts index 5b3effe17..a146f4e04 100644 --- a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; import type { Difficulties } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; @@ -7,15 +8,14 @@ import { InvalidScoreFailure, SongOrChartNotFoundFailure, } from "#lib/score-import/framework/common/converter-failures"; -import { FindChartWithPTDF } from "#utils/queries/charts"; +import { FindChartWithSongDifficulty } from "#utils/queries/charts"; import { FindSongOnTitle } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { MyPageRecordsParsedPB } from "./types"; -const DIFFICULTIES: Array = ["NORMAL", "HARD", "EXPERT", "INFERNO"]; +const DIFFICULTIES: Array = ["NORMAL", "HARD", "EXPERT", "INFERNO"]; -const LAMPS: Record> = { +const LAMPS: Record> = { 0: "FAILED", 1: "CLEAR", 2: "MISSLESS", @@ -49,7 +49,7 @@ const ConvertMyPageScraperRecordsCSV: ConverterFunction< const humanisedChartTitle = `${song.title} [${difficulty}]`; - const chart = await FindChartWithPTDF("wacca", song.id, "Single", difficulty); + const chart = await FindChartWithSongDifficulty("wacca", song.id, difficulty); if (chart === null) { throw new SongOrChartNotFoundFailure( @@ -73,7 +73,7 @@ const ConvertMyPageScraperRecordsCSV: ConverterFunction< throw new InvalidScoreFailure(`${humanisedChartTitle} - Invalid lamp of ${data.lamp}.`); } - const dryScore: DryScore<"wacca:Single"> = { + const dryScore: DryScore<"wacca"> = { service: "mypage-scraper", game: "wacca", scoreMeta: {}, diff --git a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.test.ts similarity index 52% rename from typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.oldtest.ts rename to typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.test.ts index ca7421684..f52a29c46 100644 --- a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.oldtest.ts +++ b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.test.ts @@ -1,21 +1,19 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { MockMulterFile } from "#test-utils/mock-multer"; import { TestingWaccaMyPageScraperRecordsCSV } from "#test-utils/test-data"; -import { WACCA_STAGEUPS } from "tachi-common/constants/game"; -import t from "tap"; +import { describe, expect, it } from "vitest"; import type { MyPageRecordsParsedPB } from "./types"; import { ParseMyPageScraperPlayerCSV, ParseMyPageScraperRecordsCSV } from "./parser"; -t.test("#ParseMyPageScraperRecordsCSV", (t) => { - t.test("Valid CSV", (t) => { - // This example file is cg505's actual records dump. +describe("ParseMyPageScraperRecordsCSV", () => { + it("parses the real records dump", () => { const file = MockMulterFile(TestingWaccaMyPageScraperRecordsCSV, "records.csv"); - const { iterable, game } = ParseMyPageScraperRecordsCSV(file, {}, log); + const { iterable, gameGroup } = ParseMyPageScraperRecordsCSV(file, {}, log); - t.equal(game, "wacca"); + expect(gameGroup).toBe("wacca"); const iterableData = iterable as Array; @@ -23,23 +21,21 @@ t.test("#ParseMyPageScraperRecordsCSV", (t) => { // can have up to 4 PBs. There are more than 280 lines in the file, but // this includes songs that are never played. The actual number of // charts with (non-zero) PBs is 280. - t.equal(iterableData.length, 280); - - t.end(); + expect(iterableData.length).toBe(280); }); - t.test("Correctly parse CSV", (t) => { + it("parses a single-row CSV", () => { const buffer = Buffer.from( 'music_id,music_title,music_artist,music_genre,music_levels,music_play_counts,music_scores,music_achieves\n3080,Avenue,aran,6,"[3,7+,12+,0]","[0,0,12]","[0,0,996952]","[0,0,3]"', ); const file = MockMulterFile(buffer, "records.csv"); - const { iterable, game } = ParseMyPageScraperRecordsCSV(file, {}, log); + const { iterable, gameGroup } = ParseMyPageScraperRecordsCSV(file, {}, log); - t.equal(game, "wacca"); + expect(gameGroup).toBe("wacca"); - t.strictSame(iterable, [ + expect(iterable).toStrictEqual([ { songId: 3080, songTitle: "Avenue", @@ -49,148 +45,103 @@ t.test("#ParseMyPageScraperRecordsCSV", (t) => { lamp: 3, }, ]); - - t.end(); }); - t.test("Malformed CSV", (t) => { - // Missing music_id in the record + it("rejects malformed CSV rows", () => { const buffer = Buffer.from( 'music_id,music_title,music_artist,music_genre,music_levels,music_play_counts,music_scores,music_achieves\nAvenue,aran,6,"[3,7+,12+,0]","[0,0,12]","[0,0,996952]","[0,0,3]"', ); const file = MockMulterFile(buffer, "records.csv"); - t.throws( - () => ParseMyPageScraperRecordsCSV(file, {}, log), - new ScoreImportFatalError( - 400, - "Failed to parse CSV: Invalid Record Length: columns length is 8, got 7 on line 2", - ), + expect(() => ParseMyPageScraperRecordsCSV(file, {}, log)).toThrow(ScoreImportFatalError); + expect(() => ParseMyPageScraperRecordsCSV(file, {}, log)).toThrow( + "Failed to parse CSV: Invalid Record Length: columns length is 8, got 7 on line 2", ); - - t.end(); }); - t.test("CSV with wrong headers", (t) => { - // not_music_title instead of music_title + it("rejects CSV with wrong headers", () => { const buffer = Buffer.from( 'music_id,not_music_title,music_artist,music_genre,music_levels,music_play_counts,music_scores,music_achieves\n3080,Avenue,aran,6,"[3,7+,12+,0]","[0,0,12]","[0,0,996952]","[0,0,3]"', ); const file = MockMulterFile(buffer, "records.csv"); - t.throws( - () => ParseMyPageScraperRecordsCSV(file, {}, log), - new ScoreImportFatalError( - 400, - "Malformed CSV, invalid column(s) (music_title: undefined): Expected string.", - ), + expect(() => ParseMyPageScraperRecordsCSV(file, {}, log)).toThrow(ScoreImportFatalError); + expect(() => ParseMyPageScraperRecordsCSV(file, {}, log)).toThrow( + "Malformed CSV, invalid column(s) (music_title: undefined): Expected string.", ); - - t.end(); }); - t.test("CSV with missing headers", (t) => { - // missing music_achieves + it("rejects CSV with missing headers", () => { const buffer = Buffer.from( 'music_id,music_title,music_levels,music_scores\n3080,Avenue,"[3,7+,12+,0]","[0,0,996952]"', ); const file = MockMulterFile(buffer, "records.csv"); - t.throws( - () => ParseMyPageScraperRecordsCSV(file, {}, log), - new ScoreImportFatalError( - 400, - "Malformed CSV, invalid column(s) (music_achieves: undefined): Expected string.", - ), + expect(() => ParseMyPageScraperRecordsCSV(file, {}, log)).toThrow(ScoreImportFatalError); + expect(() => ParseMyPageScraperRecordsCSV(file, {}, log)).toThrow( + "Malformed CSV, invalid column(s) (music_achieves: undefined): Expected string.", ); - - t.end(); }); - - t.end(); }); -t.test("#ParseMyPageScraperPlayerCSV", (t) => { - t.test("Valid CSV", (t) => { - // This file is small so we just inline it. +describe("ParseMyPageScraperPlayerCSV", () => { + it("parses player CSV and exposes a class provider", () => { const buffer = Buffer.from( 'player_name,player_level,player_rate,player_stage,player_play_count,player_play_count_versus,player_play_count_coop,player_total_rp_earned,player_total_rp_spent\ncg505,120,2704,"[12,ステージXII,2]",1274,57,0,2088515,531325', ); const file = MockMulterFile(buffer, "player.csv"); - const { - iterable, - game, - classProvider: classProvider, - } = ParseMyPageScraperPlayerCSV(file, {}, log); + const { iterable, gameGroup, classProvider } = ParseMyPageScraperPlayerCSV(file, {}, log); - t.equal(game, "wacca"); + expect(gameGroup).toBe("wacca"); - t.strictSame(iterable, [], "Should not have any score data."); + expect(iterable).toStrictEqual([]); - t.not(classProvider, null); + expect(classProvider).not.toBeNull(); - // There's no good way to test that the classProvider got a valid - // MyPagePlayerStage, so we just call it to see. - t.strictSame(classProvider!("wacca:Single", 0, {}, log), { + expect(classProvider!("wacca", 0, {}, log)).toStrictEqual({ stageUp: "XII", }); - - t.end(); }); - t.test("Malformed CSV", (t) => { - // Missing player_level in record + it("rejects malformed player CSV", () => { const buffer = Buffer.from( 'player_name,player_level,player_rate,player_stage,player_play_count,player_play_count_versus,player_play_count_coop,player_total_rp_earned,player_total_rp_spent\ncg505,2704,"[12,ステージXII,2]",1274,57,0,2088515,531325', ); const file = MockMulterFile(buffer, "player.csv"); - t.throws( - () => ParseMyPageScraperPlayerCSV(file, {}, log), - new ScoreImportFatalError( - 400, - "Failed to parse CSV: Invalid Record Length: columns length is 9, got 8 on line 2", - ), + expect(() => ParseMyPageScraperPlayerCSV(file, {}, log)).toThrow(ScoreImportFatalError); + expect(() => ParseMyPageScraperPlayerCSV(file, {}, log)).toThrow( + "Failed to parse CSV: Invalid Record Length: columns length is 9, got 8 on line 2", ); - - t.end(); }); - t.test("CSV missing player_stage", (t) => { - // This is the only value we actually care about. + it("rejects CSV missing player_stage", () => { const buffer = Buffer.from( "player_name,player_level,player_rate,player_play_count,player_play_count_versus,player_play_count_coop,player_total_rp_earned,player_total_rp_spent\ncg505,120,2704,1274,57,0,2088515,531325", ); const file = MockMulterFile(buffer, "player.csv"); - t.throws( - () => ParseMyPageScraperPlayerCSV(file, {}, log), - new ScoreImportFatalError(400, "Malformed CSV: no player_stage column."), + expect(() => ParseMyPageScraperPlayerCSV(file, {}, log)).toThrow(ScoreImportFatalError); + expect(() => ParseMyPageScraperPlayerCSV(file, {}, log)).toThrow( + "Malformed CSV: no player_stage column.", ); - - t.end(); }); - t.test("malformed player_stage array", (t) => { - // Missing the stage grade (third element) + it("rejects malformed player_stage", () => { const buffer = Buffer.from('player_stage\n"[12,ステージXII]"'); const file = MockMulterFile(buffer, "player.csv"); - t.throws( - () => ParseMyPageScraperPlayerCSV(file, {}, log), - new ScoreImportFatalError(400, "Malformed player_stage entry."), + expect(() => ParseMyPageScraperPlayerCSV(file, {}, log)).toThrow(ScoreImportFatalError); + expect(() => ParseMyPageScraperPlayerCSV(file, {}, log)).toThrow( + "Malformed player_stage entry.", ); - - t.end(); }); - - t.end(); }); diff --git a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.ts b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.ts index a6aee68cc..321bf91e7 100644 --- a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.ts @@ -1,13 +1,14 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; +import type { GamesForGroup } from "tachi-common"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { CsvError as CSVError, parse } from "csv-parse/sync"; import { p, type PrudenceSchema } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { MyPageRecordsParsedPB, MyPageRecordsRawCSVRecord } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import { CreateMyPageScraperClassProvider } from "./class-handler"; // This should match MyPageRecordsRawCSVRecord. @@ -50,7 +51,7 @@ export function ParseMyPageScraperRecordsCSV( fileData: Express.Multer.File, _body: Record, _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { let rawCSVRecords: Array; try { @@ -111,7 +112,7 @@ export function ParseMyPageScraperRecordsCSV( return { iterable, context: {}, - game: "wacca", + gameGroup: "wacca", classProvider: null, }; } @@ -120,7 +121,7 @@ export function ParseMyPageScraperPlayerCSV( fileData: Express.Multer.File, _body: Record, _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { let csvRecords: Array>; try { @@ -168,7 +169,7 @@ export function ParseMyPageScraperPlayerCSV( return { iterable: [], context: {}, - game: "wacca", + gameGroup: "wacca", classProvider: CreateMyPageScraperClassProvider(stage), }; } diff --git a/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.oldtest.ts deleted file mode 100644 index 0e6c15cd4..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.oldtest.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { MockBarbatosScore, MockBarbatosSDVX6Score } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import type { BarbatosScore } from "./types"; - -import { ConverterIRBarbatos } from "./converter"; - -t.test("#ConverterIRBarbatos", (t) => { - t.beforeEach(ResetDBState); - - const albidaSong = { - title: "ALBIDA Powerless Mix", - artist: "無力P", - id: 1, - altTitles: [], - searchTerms: ["albida_muryoku", "アルビダパワーレスミックス"], - data: { - uscEquiv: null, - displayVersion: "booth", - }, - }; - - const albidaChart = { - chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", - difficulty: "ADV", - songID: 1, - playtype: "Single", - levelNum: 10, - level: "10", - data: { - inGameID: 1, - }, - isPrimary: true, - versions: ["booth", "inf", "gw", "heaven", "vivid", "exceed"], - }; - - t.test("Should convert a BarbatosScore into a Dry Score", async (t) => { - const res = await ConverterIRBarbatos( - MockBarbatosScore, - { timeReceived: 10, version: "vivid" }, - "ir/barbatos", - log, - ); - - t.hasStrict(res, { - song: albidaSong, - chart: albidaChart, - dryScore: { - game: "sdvx", - service: "Barbatos (vivid)", - comment: null, - importType: "ir/barbatos", - - // timeAchieved: , its Date.now() give or take lol - scoreData: { - score: 9000000, - lamp: "CLEAR", - judgements: { - critical: 100, - near: 50, - miss: 5, - }, - optional: { - fast: 40, - slow: 10, - gauge: 90, - maxCombo: 100, - exScore: null, - }, - }, - scoreMeta: { - inSkillAnalyser: false, - }, - }, - }); - - t.end(); - }); - - t.test("Should convert a BarbatosSDVX6Score into a Dry Score", async (t) => { - const res = await ConverterIRBarbatos( - MockBarbatosSDVX6Score, - { timeReceived: 10, version: "exceed" }, - "ir/barbatos", - log, - ); - - t.hasStrict(res, { - song: albidaSong, - chart: albidaChart, - dryScore: { - game: "sdvx", - service: "Barbatos (exceed)", - comment: null, - importType: "ir/barbatos", - - // timeAchieved: , its Date.now() give or take lol - scoreData: { - score: 9000000, - lamp: "CLEAR", - judgements: { - critical: 26, - near: 2, - miss: 17, - }, - optional: { - fast: 6, - slow: 9, - gauge: 90, - maxCombo: 100, - exScore: 1234, - }, - }, - scoreMeta: { - inSkillAnalyser: null, - }, - }, - }); - - t.end(); - }); - - t.test("Should throw SongOrChartNotFound if chart not found.", (t) => { - t.rejects( - () => - ConverterIRBarbatos( - deepmerge(MockBarbatosScore, { song_id: 1000 }) as BarbatosScore, - { timeReceived: 10, version: "vivid" }, - "ir/barbatos", - log, - ), - { - message: /Could not find chart with songID 1000/u, - }, - ); - - t.end(); - }); - - t.test("Should honor provided context.version, and match accordingly.", async (t) => { - // remove vividwave from the set of charts that this chart appears in. - await MONGODB_KILL.charts.sdvx.update( - { - "data.inGameID": 1, - }, - { - $pull: { - versions: "vivid", - }, - }, - ); - - t.rejects( - () => - ConverterIRBarbatos( - MockBarbatosScore, - { timeReceived: 10, version: "vivid" }, - "ir/barbatos", - log, - ), - { - message: /Could not find chart with songID 1/u, - }, - ); - - t.end(); - }); - - t.test("Should throw InternalFailure if song-chart desync.", async (t) => { - // force a song-chart desync - await MONGODB_KILL.songs.sdvx.remove({ id: 1 }); - - t.rejects( - () => - ConverterIRBarbatos( - MockBarbatosScore, - { timeReceived: 10, version: "vivid" }, - "ir/barbatos", - log, - ), - { - message: /Song 1 \(sdvx\) has no parent song/u, - }, - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts b/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts new file mode 100644 index 000000000..1f877d3da --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts @@ -0,0 +1,156 @@ +import { log } from "#lib/log/log"; +import { SongOrChartNotFoundFailure } from "#lib/score-import/framework/common/converter-failures"; +import DB from "#services/pg/db"; +import { + MockBarbatosScore, + MockBarbatosSDVX6Score, + TestingAlbidaADV, + TestingSDVXAlbidaSong, +} from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { BarbatosScore } from "./types"; + +import { ConverterIRBarbatos } from "./converter"; + +async function seedAlbidaAdvVersions() { + await DB.insertInto("song") + .values({ + id: TestingSDVXAlbidaSong.id, + legacy_id: 1, + game_group: "sdvx", + title: TestingSDVXAlbidaSong.title, + artist: TestingSDVXAlbidaSong.artist, + search_terms: TestingSDVXAlbidaSong.searchTerms, + alt_titles: TestingSDVXAlbidaSong.altTitles, + data: TestingSDVXAlbidaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingAlbidaADV.chartID, + legacy_id: TestingAlbidaADV.chartID, + game: "sdvx", + song_id: TestingSDVXAlbidaSong.id, + difficulty: "ADV", + level: TestingAlbidaADV.level, + level_num: TestingAlbidaADV.levelNum, + is_primary: TestingAlbidaADV.isPrimary, + versions: TestingAlbidaADV.versions, + data: TestingAlbidaADV.data, + }) + .execute(); +} + +describe("ConverterIRBarbatos", () => { + beforeEach(seedAlbidaAdvVersions); + + it("converts a BarbatosScore into a dry score", async () => { + const res = await ConverterIRBarbatos( + MockBarbatosScore, + { timeReceived: 10, version: "vivid" }, + "ir/barbatos", + log, + ); + + expect(res.song.id).toBe(TestingSDVXAlbidaSong.id); + expect(res.chart.chartID).toBe(TestingAlbidaADV.chartID); + expect(res.dryScore).toMatchObject({ + game: "sdvx", + service: "Barbatos (vivid)", + comment: null, + importType: "ir/barbatos", + timeAchieved: 10, + scoreData: { + score: 9_000_000, + lamp: "CLEAR", + judgements: { + critical: 100, + near: 50, + miss: 5, + }, + optional: { + fast: 40, + slow: 10, + gauge: 90, + maxCombo: 100, + exScore: null, + }, + }, + scoreMeta: { + inSkillAnalyser: false, + }, + }); + }); + + it("converts a BarbatosSDVX6Score into a dry score", async () => { + const res = await ConverterIRBarbatos( + MockBarbatosSDVX6Score, + { timeReceived: 10, version: "exceed" }, + "ir/barbatos", + log, + ); + + expect(res.song.id).toBe(TestingSDVXAlbidaSong.id); + expect(res.chart.chartID).toBe(TestingAlbidaADV.chartID); + expect(res.dryScore).toMatchObject({ + game: "sdvx", + service: "Barbatos (exceed)", + comment: null, + importType: "ir/barbatos", + timeAchieved: 10, + scoreData: { + score: 9_000_000, + lamp: "CLEAR", + judgements: { + critical: 26, + near: 2, + miss: 17, + }, + optional: { + fast: 6, + slow: 9, + gauge: 90, + maxCombo: 100, + exScore: 1234, + }, + }, + scoreMeta: { + inSkillAnalyser: null, + }, + }); + }); + + it("throws SongOrChartNotFoundFailure when the chart is missing", async () => { + const err = await ConverterIRBarbatos( + deepmerge(MockBarbatosScore, { song_id: 1000 }) as BarbatosScore, + { timeReceived: 10, version: "vivid" }, + "ir/barbatos", + log, + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(SongOrChartNotFoundFailure); + expect((err as Error).message).toMatch(/Could not find chart with songID 1000/u); + }); + + it("requires context.version to be listed on the chart versions", async () => { + await DB.updateTable("chart") + .set({ + versions: TestingAlbidaADV.versions.filter((v) => v !== "vivid"), + }) + .where("id", "=", TestingAlbidaADV.chartID) + .execute(); + + await expect( + ConverterIRBarbatos( + MockBarbatosScore, + { timeReceived: 10, version: "vivid" }, + "ir/barbatos", + log, + ), + ).rejects.toBeInstanceOf(SongOrChartNotFoundFailure); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.ts index 16e2deee2..7d5b16dc9 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/barbatos/converter.ts @@ -1,14 +1,14 @@ -import { FindSDVXChartOnInGameIDVersion } from "#utils/queries/charts"; -import { FindSongOnID } from "#utils/queries/songs"; - -import type { DryScore } from "../../../framework/common/types"; -import type { ConverterFunction } from "../../common/types"; -import type { BarbatosContext, BarbatosScore, BarbatosSDVX6Score } from "./types"; +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import { InternalFailure, SongOrChartNotFoundFailure, -} from "../../../framework/common/converter-failures"; +} from "#lib/score-import/framework/common/converter-failures"; +import { FindSDVXChartOnInGameIDVersion } from "#utils/queries/charts"; +import { FindSongOnID } from "#utils/queries/songs"; + +import type { BarbatosContext, BarbatosScore, BarbatosSDVX6Score } from "./types"; const LAMP_LOOKUP = { 1: "FAILED", @@ -45,17 +45,17 @@ export const ConverterIRBarbatos: ConverterFunction< ); } - const song = await FindSongOnID("sdvx", chart.songID); + const song = await FindSongOnID("sdvx", chart.song.id); if (!song) { - log.error(`Song ${chart.songID} (sdvx) has no parent song?`); - throw new InternalFailure(`Song ${chart.songID} (sdvx) has no parent song?`); + log.error(`Song ${chart.song.id} (sdvx) has no parent song?`); + throw new InternalFailure(`Song ${chart.song.id} (sdvx) has no parent song?`); } const { critical, near, miss } = GetJudgements(data); const { fast, slow } = GetFastSlow(data); - const dryScore: DryScore<"sdvx:Single"> = { + const dryScore: DryScore<"sdvx"> = { game: "sdvx", service: `Barbatos (${context.version})`, comment: null, diff --git a/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.oldtest.ts deleted file mode 100644 index 10bcc152b..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.oldtest.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { MockBarbatosScore } from "#test-utils/test-data"; -import t from "tap"; - -import { ParseBarbatosSingle } from "./parser"; - -t.test("#ParseBarbatosSingle", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the score as a payload", (t) => { - const res = ParseBarbatosSingle( - MockBarbatosScore as unknown as Record, - log, - ); - - t.hasStrict(res, { - game: "sdvx", - context: {}, - iterable: [MockBarbatosScore], - }); - - t.end(); - }); - - t.test("Should reject invalid scores", (t) => { - t.throws(() => ParseBarbatosSingle({}, log), { - message: "Invalid Barbatos Request", - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.test.ts b/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.test.ts new file mode 100644 index 000000000..542693611 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.test.ts @@ -0,0 +1,26 @@ +import { log } from "#lib/log/log"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { MockBarbatosScore } from "#test-utils/test-data"; +import { describe, expect, it } from "vitest"; + +import { ParseBarbatosSingle } from "./parser"; + +describe("ParseBarbatosSingle", () => { + it("returns the score as the iterable payload", () => { + const res = ParseBarbatosSingle( + MockBarbatosScore as unknown as Record, + log, + ); + + expect(res).toMatchObject({ + gameGroup: "sdvx", + iterable: [MockBarbatosScore], + }); + expect(res.context).toMatchObject({ version: "vivid" }); + expect(typeof res.context.timeReceived).toBe("number"); + }); + + it("rejects invalid scores", () => { + expect(() => ParseBarbatosSingle({}, log)).toThrow(ScoreImportFatalError); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.ts index 45c664bef..82db381a0 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.ts @@ -1,13 +1,13 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup } from "tachi-common"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError } from "#utils/prudence"; import { p } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { BarbatosContext, BarbatosScore, BarbatosSDVX6Score } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - const PR_BARBATOS = { difficulty: p.isIn(0, 1, 2, 3, 4), level: p.isBoundedInteger(1, 20), @@ -60,7 +60,11 @@ const PR_BARBATOS_SDVX6 = { export function ParseBarbatosSingle( body: Record, _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns< + BarbatosScore | BarbatosSDVX6Score, + BarbatosContext, + GamesForGroup["sdvx"] +> { // this is an extremely sketchy way of sniffing out whether it's sdvx6 or not. // We could ask barbatos to send a header, but what difference does it make? const schema = "ex_score" in body ? PR_BARBATOS_SDVX6 : PR_BARBATOS; @@ -76,7 +80,7 @@ export function ParseBarbatosSingle( timeReceived: Date.now(), version: "ex_score" in body ? "exceed" : "vivid", }, - game: "sdvx", + gameGroup: "sdvx", iterable: [body] as unknown as Array, classProvider: null, }; diff --git a/typescript/server/src/lib/score-import/import-types/ir/barbatos/types.ts b/typescript/server/src/lib/score-import/import-types/ir/barbatos/types.ts index fe2cfb9f5..12e19ed31 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/barbatos/types.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/barbatos/types.ts @@ -1,4 +1,4 @@ -import type { integer, Versions } from "tachi-common"; +import type { GamesForGroup, integer, Versions } from "tachi-common"; export interface BarbatosScore { difficulty: 0 | 1 | 2 | 3 | 4; @@ -52,5 +52,5 @@ export interface BarbatosSDVX6Score { export interface BarbatosContext { timeReceived: number; - version: Versions["sdvx:Single"]; + version: Versions[GamesForGroup["sdvx"]]; } diff --git a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts index 0de433fe3..dc30fbcfe 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts @@ -1,28 +1,29 @@ import type { KtLogger } from "#lib/log/log"; +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { Mutable } from "#utils/types"; import { HandleOrphanQueue } from "#lib/orphan-queue/orphan-queue"; +import { + InternalFailure, + InvalidScoreFailure, + SongOrChartNotFoundFailure, +} from "#lib/score-import/framework/common/converter-failures"; import { DeorphanScores } from "#lib/score-import/framework/orphans/orphans"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; import { FindChartOnSHA256, FindChartOnSHA256Playtype } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; import { + type BMSGames, + type ChartDocument, CreateChartID, - type MONGO_ChartDocument, - type MONGO_SongDocument, - type Playtypes, + type GamesForGroup, + type LEGACY_Playtypes, + type SongDocument, } from "tachi-common"; -import type { DryScore } from "../../../framework/common/types"; -import type { ConverterFunction } from "../../common/types"; import type { BeatorajaChart, BeatorajaContext, BeatorajaScore } from "./types"; -import { - InternalFailure, - InvalidScoreFailure, - SongOrChartNotFoundFailure, -} from "../../../framework/common/converter-failures"; - const LAMP_LOOKUP = { NoPlay: "NO PLAY", Failed: "FAILED", @@ -46,11 +47,11 @@ const RANDOM_LOOKUP = { } as const; async function HandleOrphanChartProcess( - game: "bms" | "pms", + gameGroup: "bms" | "pms", data: BeatorajaScore, context: BeatorajaContext, log: KtLogger, -) { +): Promise> { const chartName = `${context.chart.artist} (${context.chart.subartist})- ${context.chart.title} (${context.chart.subtitle})`; // -1: unspecified in chart @@ -73,30 +74,27 @@ async function HandleOrphanChartProcess( let chart; let deorphanFilter: - | { chartSha256: string; pmsPlaytype: Playtypes["pms"] } + | { chartSha256: string; game: GamesForGroup["pms"] } | { chartSha256: string }; - if (game === "bms") { + if (gameGroup === "bms") { deorphanFilter = { chartSha256: context.chart.sha256 }; - const gptString = context.chart.mode === "BEAT_7K" ? "bms:7K" : "bms:14K"; + const game = context.chart.mode === "BEAT_7K" ? "bms-7k" : "bms-14k"; - const { chartDoc, songDoc } = ConvertBeatorajaChartToTachi( - context.chart, - context.chart.mode === "BEAT_7K" ? "7K" : "14K", - ); + const { chartDoc, songDoc } = ConvertBeatorajaChartToTachi(context.chart, game); // only try and insert this in the tachi DB if it has a valid MD5. // beatoraja makes it **perfectly valid** for MD5 to be an empty string // if it doesn't feel like md5ing the chart (for whatever reason) if (chartDoc.data.hashMD5.length === "d0f497c0f955e7edfb0278f446cdb6f8".length) { chart = await HandleOrphanQueue( - gptString, - "bms", + game, chartDoc, songDoc, { "chartDoc.data.hashSHA256": context.chart.sha256, + game, }, ServerConfig.BEATORAJA_QUEUE_SIZE, context.userID, @@ -104,27 +102,24 @@ async function HandleOrphanChartProcess( ); } } else { - const playtype: Playtypes["pms"] = - data.deviceType === "BM_CONTROLLER" ? "Controller" : "Keyboard"; + const game: GamesForGroup["pms"] = + data.deviceType === "BM_CONTROLLER" ? "pms-controller" : "pms-keyboard"; - deorphanFilter = { chartSha256: context.chart.sha256, pmsPlaytype: playtype }; + deorphanFilter = { chartSha256: context.chart.sha256, game }; - const gptString = playtype === "Controller" ? "pms:Controller" : "pms:Keyboard"; - - const { chartDoc, songDoc } = ConvertBeatorajaChartToTachi(context.chart, playtype); + const { chartDoc, songDoc } = ConvertBeatorajaChartToTachi(context.chart, game); // only try and insert this in the tachi DB if it has a valid MD5. // beatoraja makes it **perfectly valid** for MD5 to be an empty string // if it doesn't feel like md5ing the chart (for whatever reason) if (chartDoc.data.hashMD5.length === "d0f497c0f955e7edfb0278f446cdb6f8".length) { chart = await HandleOrphanQueue( - gptString, - "pms", + game, chartDoc, songDoc, { "chartDoc.data.hashSHA256": context.chart.sha256, - playtype, + game, }, ServerConfig.BEATORAJA_QUEUE_SIZE, context.userID, @@ -172,14 +167,14 @@ export const ConverterIRBeatoraja: ConverterFunction | null; + let chart: ChartDocument | null; if (game === "bms") { - chart = (await FindChartOnSHA256(game, data.sha256)) as MONGO_ChartDocument< - "bms:7K" | "bms:14K" + chart = (await FindChartOnSHA256(game, data.sha256)) as ChartDocument< + GamesForGroup["bms"] > | null; } else { - let playtype: Playtypes["pms"]; + let playtype: LEGACY_Playtypes["pms"]; // It's still called BM_CONTROLLER even though its popn! if (data.deviceType === "BM_CONTROLLER") { @@ -190,27 +185,23 @@ export const ConverterIRBeatoraja: ConverterFunction | null; + chart = (await FindChartOnSHA256Playtype(game, data.sha256, playtype)) as ChartDocument< + GamesForGroup["pms"] + > | null; } if (!chart) { chart = await HandleOrphanChartProcess(game, data, context, log); } - const song = await FindSongOnID(game, chart.songID); + const song = await FindSongOnID(game, chart.song.id); if (!song) { log.error(`Song-Chart Desync with ${game} ${chart.chartID}.`); throw new InternalFailure(`Song-Chart Desync with ${game} ${chart.chartID}.`); } - const optional: Mutable< - DryScore<"bms:7K" | "bms:14K" | "pms:Controller" | "pms:Keyboard">["scoreData"]["optional"] - > = { + const optional: Mutable["scoreData"]["optional"]> = { bp: data.minbp === -1 ? null : data.minbp, gauge: data.gauge === -1 ? null : data.gauge, gaugeHistoryEasy: data.gaugeHistory?.easy, @@ -251,7 +242,7 @@ export const ConverterIRBeatoraja: ConverterFunction = { + const dryScore: DryScore = { comment: null, - game, + game: chart.game, importType, scoreData: { score: data.exscore, @@ -287,33 +278,11 @@ export const ConverterIRBeatoraja: ConverterFunction = - { - chartID, - difficulty: "CHART", - isPrimary: true, - level: "?", - levelNum: 0, - playtype, - songID: 0, - versions: [], - data: { - hashMD5: chart.md5, - hashSHA256: chart.sha256, - notecount: chart.notes, - tableFolders: {}, - aiLevel: null, - sglEC: null, - sglHC: null, - }, - }; - - const songDoc: MONGO_SongDocument<"bms" | "pms"> = { +function ConvertBeatorajaChartToTachi(chart: BeatorajaChart, game: BMSGames) { + const songDoc: SongDocument<"bms" | "pms"> = { artist: chart.artist, title: chart.title, - id: 0, + id: "s0", altTitles: [], searchTerms: [], data: { @@ -324,5 +293,26 @@ function ConvertBeatorajaChartToTachi(chart: BeatorajaChart, playtype: Playtypes }, }; + const chartID = CreateChartID(); + const chartDoc: ChartDocument = { + game, + chartID, + difficulty: "CHART", + isPrimary: true, + level: "?", + levelNum: 0, + song: songDoc, + versions: [], + data: { + hashMD5: chart.md5, + hashSHA256: chart.sha256, + notecount: chart.notes, + tableFolders: {}, + aiLevel: null, + sglEC: null, + sglHC: null, + }, + }; + return { songDoc, chartDoc }; } diff --git a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts index a3469db46..e5a721306 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts @@ -1,14 +1,13 @@ import type { KtLogger } from "#lib/log/log"; -import type { integer } from "tachi-common"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { BMSGames, integer } from "tachi-common"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError } from "#utils/prudence"; import { p } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { BeatorajaChart, BeatorajaContext, BeatorajaScore } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - const PR_BEATORAJA_SCORE = { // sha256 **MUST** be a 64 char hex string. sha256: p.regex(/^[0-9a-f]{64}$/u), @@ -81,7 +80,7 @@ export function ParseBeatorajaSingle( body: Record, userID: integer, _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { const err = p( body.score, PR_BEATORAJA_SCORE, @@ -136,7 +135,7 @@ export function ParseBeatorajaSingle( userID, timeReceived: Date.now(), }, - game: isPMS ? "pms" : "bms", + gameGroup: isPMS ? "pms" : "bms", iterable: [score], classProvider: null, }; diff --git a/typescript/server/src/lib/score-import/import-types/ir/direct-manual/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/direct-manual/parser.ts index ce818d3db..877358a4b 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/direct-manual/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/direct-manual/parser.ts @@ -1,10 +1,9 @@ import type { KtLogger } from "#lib/log/log"; -import type { BatchManualScore } from "tachi-common"; +import type { BatchManualContext } from "#lib/score-import/import-types/common/batch-manual/types"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { BatchManualScore, V3Game } from "tachi-common"; -import type { BatchManualContext } from "../../common/batch-manual/types"; -import type { ParserFunctionReturns } from "../../common/types"; - -import { ParseBatchManualFromObject } from "../../common/batch-manual/parser"; +import { ParseBatchManualFromObject } from "#lib/score-import/import-types/common/batch-manual/parser"; /** * Parses an object of BATCH-MANUAL data. @@ -15,7 +14,7 @@ function ParseDirectManual( body: Record, inferTimestamp: boolean, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { return ParseBatchManualFromObject(body, "ir/direct-manual", inferTimestamp, log); } diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.oldtest.ts deleted file mode 100644 index 617061c89..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.oldtest.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { IIDXDans } from "tachi-common/config/game-support/iidx"; -import t from "tap"; - -import { CreateFerStaticClassProvider } from "./class-handler"; - -t.test("#FerStaticClassProvider", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should curry a function", (t) => { - const res = CreateFerStaticClassProvider({ sp_dan: 1 }); - - t.equal(typeof res, "function"); - - t.end(); - }); - - t.test("Should work with no dans", (t) => { - const res = CreateFerStaticClassProvider({})("iidx:SP", 1, {}, log); - - t.equal(res, undefined, "Should return nothing."); - - t.end(); - }); - - t.test("Should update the same dan as the playtype.", (t) => { - const fn = CreateFerStaticClassProvider({ sp_dan: 5, dp_dan: 7 }); - const res = fn("iidx:SP", 1, {}, log); - - t.strictSame(res, { dan: IIDXDans[5]!.id }, "Should return SP dan's value."); - - const res2 = fn("iidx:DP", 1, {}, log); - - t.strictSame(res2, { dan: IIDXDans[7]!.id }, "Should return DP dan's value."); - - t.end(); - }); - - t.test("Should skip if dan is invalid.", (t) => { - const fn = CreateFerStaticClassProvider({ sp_dan: -1, dp_dan: 100 }); - const res = fn("iidx:SP", 1, {}, log); - - t.equal(res, undefined, "Should skip SP dan's value."); - - const res2 = fn("iidx:DP", 1, {}, log); - - t.equal(res2, undefined, "Should skip DP dan's value."); - - t.end(); - }); - - t.test("Should skip if gpt is invalid", (t) => { - const fn = CreateFerStaticClassProvider({ sp_dan: 5, dp_dan: 7 }); - const res = fn("bms:7K", 1, {}, log); - - t.equal(res, undefined, "Should skip over as a failsafe."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.test.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.test.ts new file mode 100644 index 000000000..52d52d512 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.test.ts @@ -0,0 +1,46 @@ +import { log } from "#lib/log/log"; +import { describe, expect, it } from "vitest"; + +import { CreateFerStaticClassProvider } from "./class-handler"; + +describe("CreateFerStaticClassProvider", () => { + it("returns a class-provider function", () => { + const res = CreateFerStaticClassProvider({ sp_dan: 1 }); + + expect(typeof res).toBe("function"); + }); + + it("returns nothing when no dans are set", () => { + const res = CreateFerStaticClassProvider({})("iidx-sp", 1, {}, log); + + expect(res).toBeUndefined(); + }); + + it("maps SP and DP dan indices to IIDX dan ids", () => { + const fn = CreateFerStaticClassProvider({ sp_dan: 5, dp_dan: 7 }); + const res = fn("iidx-sp", 1, {}, log); + + expect(res).toEqual({ dan: "KYU_2" }); + + const res2 = fn("iidx-dp", 1, {}, log); + + expect(res2).toEqual({ dan: "DAN_1" }); + }); + + it("skips invalid dan indices", () => { + const fn = CreateFerStaticClassProvider({ sp_dan: -1, dp_dan: 100 }); + const res = fn("iidx-sp", 1, {}, log); + + expect(res).toBeUndefined(); + + const res2 = fn("iidx-dp", 1, {}, log); + + expect(res2).toBeUndefined(); + }); + + it("throws for non-IIDX games (unreachable guard)", () => { + const fn = CreateFerStaticClassProvider({ sp_dan: 5, dp_dan: 7 }); + + expect(() => fn("bms-7k" as any, 1, {}, log)).toThrow(/unreachable/u); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.ts index f25555c79..629be5823 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/class-handler.ts @@ -1,21 +1,24 @@ import type { ClassProvider } from "#lib/score-import/framework/calculated-data/types"; -import type { integer } from "tachi-common"; +import type { GamesForGroup, integer } from "tachi-common"; +import { staticAssertUnreachable } from "#utils/misc"; import { IIDXDans } from "tachi-common/config/game-support/iidx"; -export function CreateFerStaticClassProvider(body: Record): ClassProvider { - return (gptString, userID, ratings, log) => { +export function CreateFerStaticClassProvider( + body: Record, +): ClassProvider { + return (game, _userID, _ratings, log) => { let index; - if (gptString === "iidx:SP") { - index = body.sp_dan; - } else if (gptString === "iidx:DP") { - index = body.dp_dan; - } else { - log.warn( - `Invalid gptString ${gptString} passed to FerStaticClassProvider. Attempting to continue.`, - ); - return; + switch (game) { + case "iidx-sp": + index = body.sp_dan; + break; + case "iidx-dp": + index = body.dp_dan; + break; + default: + staticAssertUnreachable(game); } if (index === undefined) { @@ -23,7 +26,7 @@ export function CreateFerStaticClassProvider(body: Record): Cla } if (!Number.isInteger(index)) { - log.info({ body }, `received invalid fer-static class of ${index} (${gptString}).`); + log.info({ body }, `received invalid fer-static class of ${index} (${game}).`); return; } diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/converter.oldtest.ts deleted file mode 100644 index b241dd1a2..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/converter.oldtest.ts +++ /dev/null @@ -1,5 +0,0 @@ -import t from "tap"; - -t.todo("#ConverterIRFervidexStatic", (t) => { - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/converter.ts index 2f2c1f542..de2c28f39 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/converter.ts @@ -1,14 +1,15 @@ -import { FindChartOnInGameIDVersion } from "#utils/queries/charts"; -import { FindSongOnID } from "#utils/queries/songs"; - -import type { DryScore } from "../../../framework/common/types"; -import type { ConverterFunction } from "../../common/types"; -import type { FervidexStaticContext, FervidexStaticScore } from "./types"; +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import { InternalFailure, SongOrChartNotFoundFailure, -} from "../../../framework/common/converter-failures"; +} from "#lib/score-import/framework/common/converter-failures"; +import { FindChartOnInGameIDVersion } from "#utils/queries/charts"; +import { FindSongOnID } from "#utils/queries/songs"; + +import type { FervidexStaticContext, FervidexStaticScore } from "./types"; + import { FERVIDEX_LAMP_LOOKUP, SplitFervidexChartRef } from "../fervidex/converter"; export const ConverterIRFervidexStatic: ConverterFunction< @@ -16,7 +17,7 @@ export const ConverterIRFervidexStatic: ConverterFunction< FervidexStaticContext > = async (data, context, importType, log) => { // eslint-disable-next-line prefer-const - let { difficulty, playtype } = SplitFervidexChartRef(data.chart); + let { difficulty, game } = SplitFervidexChartRef(data.chart); // Scripted Long used to be an ANOTHER with id 21201 // @@ -27,28 +28,22 @@ export const ConverterIRFervidexStatic: ConverterFunction< difficulty = "LEGGENDARIA"; } - const chart = await FindChartOnInGameIDVersion( - "iidx", - data.song_id, - playtype, - difficulty, - context.version, - ); + const chart = await FindChartOnInGameIDVersion(game, data.song_id, difficulty, context.version); if (!chart) { throw new SongOrChartNotFoundFailure( - `Could not find chart with songID ${data.song_id} (${playtype} ${difficulty} Version ${context.version})`, + `Could not find chart with songID ${data.song_id} (${game} ${difficulty} Version ${context.version})`, importType, data, context, ); } - const song = await FindSongOnID("iidx", chart.songID); + const song = await FindSongOnID("iidx", chart.song.id); if (!song) { - log.error(`Song ${chart.songID} (iidx) has no parent song?`); - throw new InternalFailure(`Song ${chart.songID} (iidx) has no parent song?`); + log.error(`Song ${chart.song.id} (iidx) has no parent song?`); + throw new InternalFailure(`Song ${chart.song.id} (iidx) has no parent song?`); } const optional: { bp?: number | null } = {}; @@ -57,8 +52,8 @@ export const ConverterIRFervidexStatic: ConverterFunction< optional.bp = data.miss_count === -1 ? null : data.miss_count; } - const dryScore: DryScore<"iidx:DP" | "iidx:SP"> = { - game: "iidx", + const dryScore: DryScore = { + game, service: "Fervidex Static", comment: null, importType: "ir/fervidex-static", diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.oldtest.ts deleted file mode 100644 index fedf322fe..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.oldtest.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { FervidexStaticBase, GetKTDataJSON } from "#test-utils/test-data"; -import t from "tap"; - -import { ParseFervidexStatic } from "./parser"; - -t.test("#ParseFervidexStatic", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should parse static data from body", (t) => { - const res = ParseFervidexStatic( - FervidexStaticBase, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ); - - t.strictSame(res.iterable, [ - { - song_id: 1000, - chart: "spa", - clear_type: 4, - ex_score: 1180, - miss_count: 40, - }, - { - song_id: 1000, - chart: "spn", - clear_type: 7, - ex_score: 158, - miss_count: 0, - }, - { - song_id: 1001, - chart: "dph", - clear_type: 3, - ex_score: 15, - miss_count: 1, - }, - ]); - - t.hasStrict(res, { - context: { version: "27" }, - game: "iidx", - }); - - t.type(res.classProvider, "function", "Should have a function ready for class handling"); - - t.end(); - }); - - t.test("Should not have anything in its iterable if shouldImportScores is false.", (t) => { - const res = ParseFervidexStatic( - FervidexStaticBase, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: false }, - log, - ); - - t.strictSame(res.iterable, []); - - t.hasStrict(res, { - context: { version: "27" }, - game: "iidx", - }); - - t.type( - res.classProvider, - "function", - "Should have a function ready for class handling, regardless of shouldImportScores being false", - ); - - t.end(); - }); - - t.test("Should throw an error if no body.scores is present", (t) => { - t.throws( - () => - ParseFervidexStatic( - {}, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid body.scores", - ); - - t.end(); - }); - - t.test("Should throw an error if songID or its value is nonsense", (t) => { - t.throws( - () => - ParseFervidexStatic( - { scores: { nonsenseKey: {} } }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid songID nonsenseKey", - ); - - t.throws( - () => - ParseFervidexStatic( - { scores: { 1000: null } }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid score with songID 1000", - ); - - t.end(); - }); - - t.test("Should throw an error if songID has no score", (t) => { - t.throws( - () => - ParseFervidexStatic( - { scores: { 1000: { spn: null } } }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid score with songID 1000", - ); - - t.throws( - () => - ParseFervidexStatic( - { scores: { 1000: { spn: undefined } } }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid score with songID 1000", - ); - - t.throws( - () => - ParseFervidexStatic( - { scores: { 1000: { spn: "foo" } } }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid score with songID 1000", - ); - - t.end(); - }); - - t.test("Should throw an error if the score->chart document is invalid", (t) => { - t.throws( - () => - ParseFervidexStatic( - { - scores: { - 1000: { spn: { ex_score: -1, miss_count: null, clear_type: 0 } }, - }, - }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid score with songID 1000 at chart spn", - ); - - t.throws( - () => - ParseFervidexStatic( - { - scores: { - 1000: { spn: { ex_score: 1000, miss_count: "foo", clear_type: 0 } }, - }, - }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid score with songID 1000 at chart spn", - ); - - t.throws( - () => - ParseFervidexStatic( - { - scores: { - 1000: { spn: { ex_score: 1000, miss_count: null, clear_type: -1 } }, - }, - }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid score with songID 1000 at chart spn", - ); - - t.end(); - }); - - t.test("Should throw an error if the chart name is invalid", (t) => { - t.throws( - () => - ParseFervidexStatic( - { - scores: { - 1000: { spx: { ex_score: 1000, miss_count: null, clear_type: 0 } }, - }, - }, - { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, - log, - ), - "Invalid chart spx", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.test.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.test.ts new file mode 100644 index 000000000..ce5dcb029 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.test.ts @@ -0,0 +1,169 @@ +import { log } from "#lib/log/log"; +import { FervidexStaticBase } from "#test-utils/test-data"; +import { describe, expect, it } from "vitest"; + +import { ParseFervidexStatic } from "./parser"; + +describe("ParseFervidexStatic", () => { + it("parses static score data from the body", () => { + const res = ParseFervidexStatic( + FervidexStaticBase, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ); + + expect(res.iterable).toEqual([ + { + song_id: 1000, + chart: "spa", + clear_type: 4, + ex_score: 1180, + miss_count: 40, + }, + { + song_id: 1000, + chart: "spn", + clear_type: 7, + ex_score: 158, + miss_count: 0, + }, + { + song_id: 1001, + chart: "dph", + clear_type: 3, + ex_score: 15, + miss_count: 1, + }, + ]); + + expect(res).toMatchObject({ + context: { version: "27" }, + gameGroup: "iidx", + }); + + expect(typeof res.classProvider).toBe("function"); + }); + + it("returns an empty iterable when shouldImportScores is false", () => { + const res = ParseFervidexStatic( + FervidexStaticBase, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: false }, + log, + ); + + expect(res.iterable).toEqual([]); + + expect(res).toMatchObject({ + context: { version: "27" }, + gameGroup: "iidx", + }); + + expect(typeof res.classProvider).toBe("function"); + }); + + it("requires body.scores", () => { + expect(() => + ParseFervidexStatic( + {}, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid body\.scores/u); + }); + + it("rejects invalid song ids and score shapes", () => { + expect(() => + ParseFervidexStatic( + { scores: { nonsenseKey: {} } }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid songID nonsenseKey/u); + + expect(() => + ParseFervidexStatic( + { scores: { 1000: null } }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid score with songID 1000/u); + }); + + it("rejects missing or invalid per-chart score objects", () => { + expect(() => + ParseFervidexStatic( + { scores: { 1000: { spn: null } } }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid score with songID 1000/u); + + expect(() => + ParseFervidexStatic( + { scores: { 1000: { spn: undefined } } }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid score with songID 1000/u); + + expect(() => + ParseFervidexStatic( + { scores: { 1000: { spn: "foo" } } }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid score with songID 1000/u); + }); + + it("rejects invalid chart metric values", () => { + expect(() => + ParseFervidexStatic( + { + scores: { + 1000: { spn: { ex_score: -1, miss_count: null, clear_type: 0 } }, + }, + }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid Score with songID 1000 at chart spn/u); + + expect(() => + ParseFervidexStatic( + { + scores: { + 1000: { spn: { ex_score: 1000, miss_count: "foo", clear_type: 0 } }, + }, + }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid Score with songID 1000 at chart spn/u); + + expect(() => + ParseFervidexStatic( + { + scores: { + 1000: { spn: { ex_score: 1000, miss_count: null, clear_type: -1 } }, + }, + }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid Score with songID 1000 at chart spn/u); + }); + + it("rejects unknown chart keys", () => { + expect(() => + ParseFervidexStatic( + { + scores: { + 1000: { spx: { ex_score: 1000, miss_count: null, clear_type: 0 } }, + }, + }, + { model: "LDJ:J:B:A:2020092900", shouldImportScores: true }, + log, + ), + ).toThrow(/Invalid chart spx/u); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.ts index d00bad690..8eb3f1531 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.ts @@ -1,14 +1,15 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup } from "tachi-common"; +import { AssertStrAsPositiveInt } from "#lib/score-import/framework/common/string-asserts"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { IsRecord } from "#utils/misc"; import { FormatPrError } from "#utils/prudence"; import { p, type PrudenceSchema } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { FervidexStaticContext, FervidexStaticHeaders, FervidexStaticScore } from "./types"; -import { AssertStrAsPositiveInt } from "../../../framework/common/string-asserts"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import { SoftwareIDToVersion } from "../fervidex/parser"; import { CreateFerStaticClassProvider } from "./class-handler"; @@ -22,7 +23,7 @@ export function ParseFervidexStatic( body: Record, headers: FervidexStaticHeaders, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { const version = SoftwareIDToVersion(headers.model, log); const classProvider = CreateFerStaticClassProvider(body); @@ -30,7 +31,7 @@ export function ParseFervidexStatic( if (!headers.shouldImportScores) { return { context: { version }, - game: "iidx", + gameGroup: "iidx", iterable: [], classProvider, }; @@ -90,7 +91,7 @@ export function ParseFervidexStatic( // asserted using prudence. return { context: { version }, - game: "iidx", + gameGroup: "iidx", iterable: scores, classProvider, }; diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/types.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/types.ts index bed08a40f..83cdc29d4 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/types.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/types.ts @@ -1,4 +1,4 @@ -import type { integer, Versions } from "tachi-common"; +import type { GamesForGroup, integer, Versions } from "tachi-common"; export interface FervidexStaticScore { clear_type: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; @@ -11,7 +11,7 @@ export interface FervidexStaticScore { } export interface FervidexStaticContext { - version: Versions["iidx:DP" | "iidx:SP"]; + version: Versions[GamesForGroup["iidx"]]; } export interface FervidexStaticHeaders { diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.oldtest.ts deleted file mode 100644 index 635fdd82e..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.oldtest.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { CreateScoreID } from "#lib/score-import/framework/score-importing/score-id"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkFakeScoreIIDXSP } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { Testing511Song, Testing511SPA, TestingIIDXSPDryScore } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import type { FervidexScore } from "./types"; - -import { InternalFailure, SkipScoreFailure } from "../../../framework/common/converter-failures"; -import { - ConverterIRFervidex, - SplitFervidexChartRef, - TachifyAssist, - TachifyGauge, - TachifyRandom, - TachifyRange, -} from "./converter"; - -t.test("#SplitFervidexChartRef", (t) => { - t.strictSame(SplitFervidexChartRef("spn"), { playtype: "SP", difficulty: "NORMAL" }); - t.strictSame(SplitFervidexChartRef("sph"), { playtype: "SP", difficulty: "HYPER" }); - t.strictSame(SplitFervidexChartRef("spa"), { playtype: "SP", difficulty: "ANOTHER" }); - t.strictSame(SplitFervidexChartRef("spl"), { playtype: "SP", difficulty: "LEGGENDARIA" }); - t.strictSame(SplitFervidexChartRef("dpn"), { playtype: "DP", difficulty: "NORMAL" }); - t.strictSame(SplitFervidexChartRef("dph"), { playtype: "DP", difficulty: "HYPER" }); - t.strictSame(SplitFervidexChartRef("dpa"), { playtype: "DP", difficulty: "ANOTHER" }); - t.strictSame(SplitFervidexChartRef("dpl"), { playtype: "DP", difficulty: "LEGGENDARIA" }); - - t.throws( - () => SplitFervidexChartRef("INVALID" as "spn"), - new InternalFailure(`Invalid fervidex difficulty of INVALID`), - ); - - t.throws( - () => SplitFervidexChartRef("spb"), - new SkipScoreFailure(`BEGINNER charts are not supported.`), - ); - - t.end(); -}); - -t.test("#TachifyAssist", (t) => { - t.equal(TachifyAssist("ASCR_LEGACY"), "FULL ASSIST"); - t.equal(TachifyAssist("AUTO_SCRATCH"), "AUTO SCRATCH"); - t.equal(TachifyAssist("FULL_ASSIST"), "FULL ASSIST"); - t.equal(TachifyAssist("LEGACY_NOTE"), "LEGACY NOTE"); - t.equal(TachifyAssist(null), "NO ASSIST"); - t.equal(TachifyAssist(undefined), "NO ASSIST"); - - t.end(); -}); - -t.test("#TachifyGauge", (t) => { - t.equal(TachifyGauge("ASSISTED_EASY"), "ASSISTED EASY"); - t.equal(TachifyGauge("EASY"), "EASY"); - t.equal(TachifyGauge("HARD"), "HARD"); - t.equal(TachifyGauge("EX_HARD"), "EX-HARD"); - t.equal(TachifyGauge(null), "NORMAL"); - t.equal(TachifyGauge(undefined), "NORMAL"); - - t.end(); -}); - -t.test("#TachifyRange", (t) => { - t.equal(TachifyRange("HIDDEN_PLUS"), "HIDDEN+"); - t.equal(TachifyRange("LIFT"), "LIFT"); - t.equal(TachifyRange("LIFT_SUD_PLUS"), "LIFT SUD+"); - t.equal(TachifyRange("SUDDEN_PLUS"), "SUDDEN+"); - t.equal(TachifyRange("SUD_PLUS_HID_PLUS"), "SUD+ HID+"); - t.equal(TachifyRange(null), "NONE"); - t.equal(TachifyRange(undefined), "NONE"); - - t.end(); -}); - -t.test("#TachifyRandom", (t) => { - t.equal(TachifyRandom("MIRROR"), "MIRROR"); - t.equal(TachifyRandom("R_RANDOM"), "R-RANDOM"); - t.equal(TachifyRandom("S_RANDOM"), "S-RANDOM"); - t.equal(TachifyRandom("RANDOM"), "RANDOM"); - t.equal(TachifyRandom(null), "NONRAN"); - t.equal(TachifyRandom(undefined), "NONRAN"); - - t.end(); -}); - -const baseFervidexScore: FervidexScore = { - bad: 0, - chart: "spa", - clear_type: 1, - combo_break: 6, - custom: false, - chart_sha256: "asdfasdf", - entry_id: 1000, - ex_score: 68, - fast: 0, - gauge: [100, 50], - ghost: [0, 2], - good: 0, - great: 0, - max_combo: 34, - option: { - gauge: "HARD", - range: "SUDDEN_PLUS", - style: "RANDOM", - }, - pacemaker: { - name: "", - score: 363, - type: "PACEMAKER_A", - }, - pgreat: 34, - poor: 6, - slow: 0, -}; - -const baseDryScore = { - game: "iidx", - service: "Fervidex", - comment: null, - importType: "ir/fervidex", - scoreData: { - score: 68, - lamp: "FAILED", - judgements: { - pgreat: 34, - great: 0, - good: 0, - bad: 0, - poor: 6, - }, - optional: { - fast: 0, - slow: 0, - maxCombo: null, - gaugeHistory: [100, 50], - gauge: 50, - bp: 6, - comboBreak: 6, - }, - }, - scoreMeta: { - assist: "NO ASSIST", - gauge: "HARD", - random: "RANDOM", - range: "SUDDEN+", - }, -}; - -t.test("#ConverterIRFervidex", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should convert a valid fervidex score into a dry score.", async (t) => { - const res = await ConverterIRFervidex( - baseFervidexScore, - { version: "27", timeReceived: 10, userID: 1 }, - "ir/fervidex", - log, - ); - - t.hasStrict( - res, - { - song: Testing511Song, - chart: Testing511SPA, - dryScore: baseDryScore, - }, - "Should return a dry score.", - ); - - t.end(); - }); - - t.test("Should turn DP randoms into tuples.", async (t) => { - // lazily clone the SPA as a DPA. - - const Testing511DPA = dmf(Testing511SPA, { - // @ts-expect-error lolol - playtype: "DP", - chartID: "dp_test", - data: {}, - }); - - await MONGODB_KILL.charts.iidx.insert(Testing511DPA); - - const res = await ConverterIRFervidex( - deepmerge(baseFervidexScore, { option: { style_2p: "R_RANDOM" }, chart: "dpa" }), - { version: "27", timeReceived: 10, userID: 1 }, - "ir/fervidex", - log, - ); - - t.hasStrict( - res, - { - song: Testing511Song, - chart: Testing511DPA, - dryScore: deepmerge(baseDryScore, { - scoreMeta: { random: ["RANDOM", "R-RANDOM"] }, - }), - }, - "Should return a dry score.", - ); - - t.end(); - }); - - t.test("Should null BP if the user died pre-emptively.", async (t) => { - const res = await ConverterIRFervidex( - deepmerge(baseFervidexScore, { dead: { measure: 1, note: 10 } }), - { version: "27", timeReceived: 10, userID: 1 }, - "ir/fervidex", - log, - ); - - t.hasStrict( - res, - { - song: Testing511Song, - chart: Testing511SPA, - dryScore: deepmerge(baseDryScore, { scoreData: { optional: { bp: null } } }), - }, - "Should return a dry score.", - ); - - t.end(); - }); - - t.test("Should reject scores on unknown charts", (t) => { - t.rejects( - ConverterIRFervidex( - deepmerge(baseFervidexScore, { chart: "spl" }), - { version: "27", timeReceived: 10, userID: 1 }, - "ir/fervidex", - log, - ), - /could not find chart/giu, - ); - - t.end(); - }); - - t.test("Should throw internal failure on chart-song desync", async (t) => { - // this forces desync - await MONGODB_KILL.songs.iidx.remove({}); - - t.rejects( - ConverterIRFervidex( - baseFervidexScore, - { version: "27", timeReceived: 10, userID: 1 }, - "ir/fervidex", - log, - ), - /Song 1 \(iidx\) has no parent song/giu, - ); - - t.end(); - }); - - t.test("Should convert underflow gauge to null.", async (t) => { - const res = await ConverterIRFervidex( - deepmerge(baseFervidexScore, { gauge: [10, 5, 249, 248] }) as FervidexScore, - { version: "27", timeReceived: 10, userID: 1 }, - "ir/fervidex", - log, - ); - - t.hasStrict( - res, - { - song: Testing511Song, - chart: Testing511SPA, - dryScore: deepmerge(baseDryScore, { - scoreData: { optional: { gauge: null, gaugeHistory: [10, 5, null, null] } }, - }), - }, - "Should return a dry score.", - ); - - t.end(); - }); - - t.test("Should highlight existing scores.", async (t) => { - const fakeScore = mkFakeScoreIIDXSP({ - scoreID: CreateScoreID( - "iidx:SP", - 1, - TestingIIDXSPDryScore, - "c2311194e3897ddb5745b1760d2c0141f933e683", - ), - highlight: false, - }); - - // add a score with an existing scoreID that is not highlighted. - await MONGODB_KILL.scores.insert(fakeScore); - - t.equal(fakeScore.highlight, false, "The score should not be highlighted."); - - // this should mutate the state of the score in the DB. - const res = await ConverterIRFervidex( - deepmerge(baseFervidexScore, { - ex_score: TestingIIDXSPDryScore.scoreData.score, - clear_type: 4, - chart: "spa", - entry_id: 1000, - - // highlight an existing score - highlight: true, - }), - { version: "27", timeReceived: 10, userID: 1 }, - "ir/fervidex", - log, - ); - - const dbRes = await MONGODB_KILL.scores.findOne({ scoreID: fakeScore.scoreID }); - - t.equal(dbRes?.highlight, true, "The score should now be highlighted."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts new file mode 100644 index 000000000..f0aecf153 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts @@ -0,0 +1,345 @@ +import { log } from "#lib/log/log"; +import { + InternalFailure, + SkipScoreFailure, + SongOrChartNotFoundFailure, +} from "#lib/score-import/framework/common/converter-failures"; +import { HydrateScore } from "#lib/score-import/framework/score-importing/hydrate-score"; +import { CreateScoreID } from "#lib/score-import/framework/score-importing/score-id"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA, TestingIIDXSPDryScore } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { FervidexScore } from "./types"; + +import { + ConverterIRFervidex, + SplitFervidexChartRef, + TachifyAssist, + TachifyGauge, + TachifyRandom, + TachifyRange, +} from "./converter"; + +const baseFervidexScore: FervidexScore = { + bad: 0, + chart: "spa", + clear_type: 1, + combo_break: 6, + custom: false, + chart_sha256: "asdfasdf", + entry_id: 1000, + ex_score: 68, + fast: 0, + gauge: [100, 50], + ghost: [0, 2], + good: 0, + great: 0, + max_combo: 34, + option: { + gauge: "HARD", + range: "SUDDEN_PLUS", + style: "RANDOM", + }, + pacemaker: { + name: "", + score: 363, + type: "PACEMAKER_A", + }, + pgreat: 34, + poor: 6, + slow: 0, +}; + +const baseDryScore = { + game: "iidx-sp" as const, + service: "Fervidex", + comment: null, + importType: "ir/fervidex", + scoreData: { + score: 68, + lamp: "FAILED", + judgements: { + pgreat: 34, + great: 0, + good: 0, + bad: 0, + poor: 6, + }, + optional: { + fast: 0, + slow: 0, + maxCombo: null, + gaugeHistory: [100, 50], + scoreHistory: [0, 2], + gauge: 50, + bp: 6, + comboBreak: 6, + }, + }, + scoreMeta: { + assist: "NO ASSIST", + gauge: "HARD", + random: "RANDOM", + range: "SUDDEN+", + }, +}; + +async function seed511SpaAndDpa() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 511, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: "ANOTHER", + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); + + const dpa = dmf(Testing511SPA, { + game: "iidx-dp", + chartID: "dp_test", + data: { inGameID: 1000 }, + } as never); + + await DB.insertInto("chart") + .values({ + id: dpa.chartID, + legacy_id: dpa.chartID, + game: "iidx-dp", + song_id: Testing511Song.id, + difficulty: "ANOTHER", + level: dpa.level, + level_num: dpa.levelNum, + is_primary: dpa.isPrimary, + versions: Testing511SPA.versions, + data: dpa.data, + }) + .execute(); +} + +describe("SplitFervidexChartRef", () => { + it("maps fervidex chart codes to game and difficulty", () => { + expect(SplitFervidexChartRef("spn")).toEqual({ game: "iidx-sp", difficulty: "NORMAL" }); + expect(SplitFervidexChartRef("sph")).toEqual({ game: "iidx-sp", difficulty: "HYPER" }); + expect(SplitFervidexChartRef("spa")).toEqual({ game: "iidx-sp", difficulty: "ANOTHER" }); + expect(SplitFervidexChartRef("spl")).toEqual({ + game: "iidx-sp", + difficulty: "LEGGENDARIA", + }); + expect(SplitFervidexChartRef("dpn")).toEqual({ game: "iidx-dp", difficulty: "NORMAL" }); + expect(SplitFervidexChartRef("dph")).toEqual({ game: "iidx-dp", difficulty: "HYPER" }); + expect(SplitFervidexChartRef("dpa")).toEqual({ game: "iidx-dp", difficulty: "ANOTHER" }); + expect(SplitFervidexChartRef("dpl")).toEqual({ + game: "iidx-dp", + difficulty: "LEGGENDARIA", + }); + }); + + it("throws InternalFailure on invalid difficulty", () => { + expect(() => SplitFervidexChartRef("INVALID" as "spn")).toThrow(InternalFailure); + expect(() => SplitFervidexChartRef("INVALID" as "spn")).toThrow( + /Invalid fervidex difficulty of INVALID/u, + ); + }); + + it("throws SkipScoreFailure for BEGINNER charts", () => { + expect(() => SplitFervidexChartRef("spb")).toThrow(SkipScoreFailure); + expect(() => SplitFervidexChartRef("spb")).toThrow(/BEGINNER charts are not supported/u); + }); +}); + +describe("TachifyAssist", () => { + it("normalizes assist options", () => { + expect(TachifyAssist("ASCR_LEGACY")).toBe("FULL ASSIST"); + expect(TachifyAssist("AUTO_SCRATCH")).toBe("AUTO SCRATCH"); + expect(TachifyAssist("FULL_ASSIST")).toBe("FULL ASSIST"); + expect(TachifyAssist("LEGACY_NOTE")).toBe("LEGACY NOTE"); + expect(TachifyAssist(null)).toBe("NO ASSIST"); + expect(TachifyAssist(undefined)).toBe("NO ASSIST"); + }); +}); + +describe("TachifyGauge", () => { + it("normalizes gauge options", () => { + expect(TachifyGauge("ASSISTED_EASY")).toBe("ASSISTED EASY"); + expect(TachifyGauge("EASY")).toBe("EASY"); + expect(TachifyGauge("HARD")).toBe("HARD"); + expect(TachifyGauge("EX_HARD")).toBe("EX-HARD"); + expect(TachifyGauge(null)).toBe("NORMAL"); + expect(TachifyGauge(undefined)).toBe("NORMAL"); + }); +}); + +describe("TachifyRange", () => { + it("normalizes range options", () => { + expect(TachifyRange("HIDDEN_PLUS")).toBe("HIDDEN+"); + expect(TachifyRange("LIFT")).toBe("LIFT"); + expect(TachifyRange("LIFT_SUD_PLUS")).toBe("LIFT SUD+"); + expect(TachifyRange("SUDDEN_PLUS")).toBe("SUDDEN+"); + expect(TachifyRange("SUD_PLUS_HID_PLUS")).toBe("SUD+ HID+"); + expect(TachifyRange(null)).toBe("NONE"); + expect(TachifyRange(undefined)).toBe("NONE"); + }); +}); + +describe("TachifyRandom", () => { + it("normalizes random options", () => { + expect(TachifyRandom("MIRROR")).toBe("MIRROR"); + expect(TachifyRandom("R_RANDOM")).toBe("R-RANDOM"); + expect(TachifyRandom("S_RANDOM")).toBe("S-RANDOM"); + expect(TachifyRandom("RANDOM")).toBe("RANDOM"); + expect(TachifyRandom(null)).toBe("NONRAN"); + expect(TachifyRandom(undefined)).toBe("NONRAN"); + }); +}); + +describe("ConverterIRFervidex", () => { + beforeEach(seed511SpaAndDpa); + + it("converts a valid fervidex score into a dry score", async () => { + const res = await ConverterIRFervidex( + baseFervidexScore, + { version: "27", timeReceived: 10, userID: 1 }, + "ir/fervidex", + log, + ); + + expect(res.song.id).toBe(Testing511Song.id); + expect(res.chart.chartID).toBe(Testing511SPA.chartID); + expect(res.dryScore).toMatchObject(baseDryScore); + }); + + it("turns DP randoms into tuples", async () => { + const res = await ConverterIRFervidex( + deepmerge(baseFervidexScore, { option: { style_2p: "R_RANDOM" }, chart: "dpa" }), + { version: "27", timeReceived: 10, userID: 1 }, + "ir/fervidex", + log, + ); + + expect(res.chart.chartID).toBe("dp_test"); + expect(res.dryScore).toMatchObject( + deepmerge(baseDryScore, { + game: "iidx-dp", + scoreMeta: { random: ["RANDOM", "R-RANDOM"] }, + }), + ); + }); + + it("nulls BP when the player died pre-emptively", async () => { + const res = await ConverterIRFervidex( + deepmerge(baseFervidexScore, { dead: { measure: 1, note: 10 } }), + { version: "27", timeReceived: 10, userID: 1 }, + "ir/fervidex", + log, + ); + + expect((res.dryScore.scoreData.optional as { bp: number | null }).bp).toBeNull(); + }); + + it("rejects scores on unknown charts", async () => { + await expect( + ConverterIRFervidex( + deepmerge(baseFervidexScore, { chart: "spl" }), + { version: "27", timeReceived: 10, userID: 1 }, + "ir/fervidex", + log, + ), + ).rejects.toBeInstanceOf(SongOrChartNotFoundFailure); + }); + + it("maps overflow gauge samples to null", async () => { + const res = await ConverterIRFervidex( + { ...baseFervidexScore, gauge: [10, 5, 249, 248] }, + { version: "27", timeReceived: 10, userID: 1 }, + "ir/fervidex", + log, + ); + + expect(res.dryScore.scoreData.optional).toMatchObject({ + gauge: null, + gaugeHistory: [10, 5, null, null], + }); + }); + + it("sets highlight on an existing score when fervidex marks highlight", async () => { + const { id: userId } = await seedUser({ username: "fervidex_highlight" }); + + const scoreForId = deepmerge(baseFervidexScore, { + ex_score: TestingIIDXSPDryScore.scoreData.score, + clear_type: 4, + chart: "spa", + entry_id: 1000, + }); + + const { dryScore, chart, song } = await ConverterIRFervidex( + scoreForId as FervidexScore, + { version: "27", timeReceived: 10, userID: userId }, + "ir/fervidex", + log, + ); + + const scoreID = CreateScoreID("iidx-sp", userId, dryScore, chart.chartID); + const hydrated = HydrateScore(userId, dryScore, chart, song, scoreID, log); + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", hydrated.scoreData); + const now = new Date().toISOString(); + + await DB.insertInto("score") + .values({ + id: scoreID, + user_id: userId, + chart_id: chart.chartID, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(hydrated.calculatedData), + meta: JSON.stringify(hydrated.scoreMeta ?? {}), + time_achieved: now, + time_added: now, + highlight: false, + comment: null, + }) + .execute(); + + await ConverterIRFervidex( + deepmerge(scoreForId, { highlight: true }) as FervidexScore, + { version: "27", timeReceived: 10, userID: userId }, + "ir/fervidex", + log, + ); + + const row = await DB.selectFrom("score") + .select("highlight") + .where("id", "=", scoreID) + .executeTakeFirst(); + + expect(row?.highlight).toBe(true); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.ts index 4ec6f1a67..7529acebf 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.ts @@ -1,20 +1,20 @@ -import { CreateScoreID } from "#lib/score-import/framework/score-importing/score-id"; -import DB from "#services/pg/db"; -import { DeleteUndefinedProps, IsNullishOrEmptyStr } from "#utils/misc"; -import { FindIIDXChartOnInGameIDVersion, FindIIDXChartWith2DXtraHash } from "#utils/queries/charts"; -import { FindSongOnID } from "#utils/queries/songs"; -import { type Difficulties, GetGPTString, MongoChartLegacyId, type Playtypes } from "tachi-common"; - -import type { DryScore } from "../../../framework/common/types"; -import type { ConverterFunction } from "../../common/types"; -import type { FervidexContext, FervidexScore } from "./types"; +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import { InternalFailure, InvalidScoreFailure, SkipScoreFailure, SongOrChartNotFoundFailure, -} from "../../../framework/common/converter-failures"; +} from "#lib/score-import/framework/common/converter-failures"; +import { CreateScoreID } from "#lib/score-import/framework/score-importing/score-id"; +import DB from "#services/pg/db"; +import { DeleteUndefinedProps, IsNullishOrEmptyStr } from "#utils/misc"; +import { FindIIDXChartOnInGameIDVersion, FindIIDXChartWith2DXtraHash } from "#utils/queries/charts"; +import { FindSongOnID } from "#utils/queries/songs"; +import { type Difficulties, type GamesForGroup } from "tachi-common"; + +import type { FervidexContext, FervidexScore } from "./types"; export const FERVIDEX_LAMP_LOOKUP = { 0: "NO PLAY", @@ -29,7 +29,7 @@ export const FERVIDEX_LAMP_LOOKUP = { export function TachifyAssist( assist: Required["option"]["assist"], -): DryScore<"iidx:DP" | "iidx:SP">["scoreMeta"]["assist"] { +): DryScore["scoreMeta"]["assist"] { switch (assist) { case "FULL_ASSIST": case "ASCR_LEGACY": @@ -46,7 +46,7 @@ export function TachifyAssist( export function TachifyGauge( gauge: Required["option"]["gauge"], -): DryScore<"iidx:DP" | "iidx:SP">["scoreMeta"]["gauge"] { +): DryScore["scoreMeta"]["gauge"] { switch (gauge) { case "ASSISTED_EASY": return "ASSISTED EASY"; @@ -69,7 +69,7 @@ export function TachifyGauge( export function TachifyRange( gauge: Required["option"]["range"], -): DryScore<"iidx:DP" | "iidx:SP">["scoreMeta"]["range"] { +): DryScore["scoreMeta"]["range"] { switch (gauge) { case "HIDDEN_PLUS": return "HIDDEN+"; @@ -104,15 +104,15 @@ export function TachifyRandom(gauge: Required["option"]["style"]) } export function SplitFervidexChartRef(ferDif: FervidexScore["chart"]) { - let playtype: Playtypes["iidx"]; + let game: "iidx-dp" | "iidx-sp"; if (ferDif.startsWith("sp")) { - playtype = "SP"; + game = "iidx-sp"; } else { - playtype = "DP"; + game = "iidx-dp"; } - let difficulty: Difficulties["iidx:DP" | "iidx:SP"]; + let difficulty: Difficulties[GamesForGroup["iidx"]]; switch (ferDif[ferDif.length - 1]) { case "b": @@ -142,7 +142,7 @@ export function SplitFervidexChartRef(ferDif: FervidexScore["chart"]) { throw new InternalFailure(`Invalid fervidex difficulty of ${ferDif}`); } - return { playtype, difficulty }; + return { game, difficulty }; } export const ConverterIRFervidex: ConverterFunction = async ( @@ -152,7 +152,7 @@ export const ConverterIRFervidex: ConverterFunction { // eslint-disable-next-line prefer-const - let { difficulty, playtype } = SplitFervidexChartRef(data.chart); + let { difficulty, game } = SplitFervidexChartRef(data.chart); // Scripted Long used to be an ANOTHER with id 21201 // @@ -173,8 +173,8 @@ export const ConverterIRFervidex: ConverterFunction (e > 200 ? null : e)); @@ -212,8 +212,8 @@ export const ConverterIRFervidex: ConverterFunction = { - game: "iidx", + const dryScore: DryScore = { + game, service: "Fervidex", comment: null, importType: "ir/fervidex", @@ -248,7 +248,7 @@ export const ConverterIRFervidex: ConverterFunction { const scoreRow = await trx diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.oldtest.ts deleted file mode 100644 index 5242dd95b..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.oldtest.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { log } from "#lib/log/log.js"; -import t from "tap"; - -import { SoftwareIDToVersion } from "./parser"; - -t.test("#SoftwareIDToVersion", (t) => { - const f = (sid: string) => SoftwareIDToVersion(sid, log); - - t.throws(() => f("a"), {}, "Should throw on invalid string."); - t.throws(() => f("LDJ:J:B:Q:2020092900"), {}, "Should throw on unknown HV revision."); - t.throws(() => f("LDJ:J:B:Q:2021091500"), {}, "Should throw on unknown BST revision."); - t.throws(() => f("XDJ:J:B:A:2020092900"), {}, "Should throw on unknown model."); - t.throws(() => f("LDJ:J:B:A:2099092900"), {}, "Should throw on unknown ext."); - - t.equal(f("LDJ:J:B:A:2020092900"), "27"); - t.equal(f("LDJ:J:B:X:2020092900"), "27-omni"); - t.equal(f("LDJ:J:B:E:2020092900"), "27-2dxtra"); - t.equal(f("LDJ:J:B:A:2021091500"), "28"); - t.equal(f("LDJ:J:B:X:2021091500"), "28-omni"); - t.equal(f("LDJ:J:B:E:2021091500"), "28-2dxtra"); - - t.equal(f("TDJ:J:B:A:2020092900"), "27"); - t.equal(f("TDJ:J:B:X:2020092900"), "27-omni"); - t.equal(f("TDJ:J:B:E:2020092900"), "27-2dxtra"); - t.equal(f("TDJ:J:B:A:2021091500"), "28"); - t.equal(f("TDJ:J:B:X:2021091500"), "28-omni"); - t.equal(f("TDJ:J:B:E:2021091500"), "28-2dxtra"); - - t.equal(f("P2D:J:B:A:2020092900"), "inf"); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.test.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.test.ts new file mode 100644 index 000000000..10134cce9 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.test.ts @@ -0,0 +1,34 @@ +import { log } from "#lib/log/log"; +import { describe, expect, it } from "vitest"; + +import { SoftwareIDToVersion } from "./parser"; + +describe("SoftwareIDToVersion", () => { + const f = (sid: string) => SoftwareIDToVersion(sid, log); + + it("throws on invalid input", () => { + expect(() => f("a")).toThrow(); + expect(() => f("LDJ:J:B:Q:2020092900")).toThrow(); + expect(() => f("LDJ:J:B:Q:2021091500")).toThrow(); + expect(() => f("XDJ:J:B:A:2020092900")).toThrow(); + expect(() => f("LDJ:J:B:A:2099092900")).toThrow(); + }); + + it("maps known LDJ / TDJ software ids to display versions", () => { + expect(f("LDJ:J:B:A:2020092900")).toBe("27"); + expect(f("LDJ:J:B:X:2020092900")).toBe("27-omni"); + expect(f("LDJ:J:B:E:2020092900")).toBe("27-2dxtra"); + expect(f("LDJ:J:B:A:2021091500")).toBe("28"); + expect(f("LDJ:J:B:X:2021091500")).toBe("28-omni"); + expect(f("LDJ:J:B:E:2021091500")).toBe("28-2dxtra"); + + expect(f("TDJ:J:B:A:2020092900")).toBe("27"); + expect(f("TDJ:J:B:X:2020092900")).toBe("27-omni"); + expect(f("TDJ:J:B:E:2020092900")).toBe("27-2dxtra"); + expect(f("TDJ:J:B:A:2021091500")).toBe("28"); + expect(f("TDJ:J:B:X:2021091500")).toBe("28-omni"); + expect(f("TDJ:J:B:E:2021091500")).toBe("28-2dxtra"); + + expect(f("P2D:J:B:A:2020092900")).toBe("inf"); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.ts index cc8e949c7..064916487 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.ts @@ -1,5 +1,6 @@ import type { KtLogger } from "#lib/log/log"; -import type { integer, Versions } from "tachi-common"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup, integer, Versions } from "tachi-common"; import { EXT_BISTROVER, @@ -17,15 +18,13 @@ import { REV_NORMAL, REV_OMNIMIX, } from "#lib/constants/ea3id"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { ParseEA3SoftID } from "#utils/ea3id"; import { FormatPrError, optNull } from "#utils/prudence"; import { p, type PrudenceSchema, type ValidSchemaValue } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { FervidexContext, FerHeaders as FervidexHeaders, FervidexScore } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - const PR_FERVIDEX: PrudenceSchema = { chart: p.isIn("spb", "spn", "dpn", "sph", "dph", "spa", "dpa", "spl", "dpl"), entry_id: p.isPositiveInteger, @@ -95,7 +94,7 @@ const PR_FERVIDEX: PrudenceSchema = { /** * Converts a string of the form LDJ:X:X:X:2020092900 into a game version. */ -export function SoftwareIDToVersion(model: string, log: KtLogger): Versions["iidx:DP" | "iidx:SP"] { +export function SoftwareIDToVersion(model: string, log: KtLogger): Versions[GamesForGroup["iidx"]] { try { const data = ParseEA3SoftID(model); @@ -173,7 +172,7 @@ export function ParseFervidexSingle( headers: FervidexHeaders, userID: integer, log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { const version = SoftwareIDToVersion(headers.model, log); // more mods may be added in the future, so lets ignore excess keys. @@ -190,7 +189,7 @@ export function ParseFervidexSingle( // this import method needs to know the user making the request // in order to highlight existing scores. Neat! context: { version, timeReceived: Date.now(), userID }, - game: "iidx", + gameGroup: "iidx", iterable: [score], classProvider: null, }; diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/types.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/types.ts index 8b711a6d1..45a49c71f 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex/types.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex/types.ts @@ -1,4 +1,4 @@ -import type { integer, Versions } from "tachi-common"; +import type { GamesForGroup, integer, Versions } from "tachi-common"; export interface FervidexScore { chart: `${"dp" | "sp"}${"a" | "b" | "h" | "l" | "n"}`; @@ -71,7 +71,7 @@ export interface FervidexScore { } export interface FervidexContext { - version: Versions["iidx:DP" | "iidx:SP"]; + version: Versions[GamesForGroup["iidx"]]; timeReceived: number; userID: integer; } diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.oldtest.ts deleted file mode 100644 index 4ec07210a..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.oldtest.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { TestingKsHookSV6CStaticScore } from "#test-utils/test-data"; -import t from "tap"; - -import { ConverterKsHookSV6CStatic } from "./converter"; - -t.test("#ConverterKsHookSV6CStatic", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should match a score with its song and chart.", async (t) => { - const res = await ConverterKsHookSV6CStatic( - TestingKsHookSV6CStaticScore, - {}, - "ir/kshook-sv6c-static", - log, - ); - - t.hasStrict(res, { - song: { - id: 1, - }, - chart: { - data: { - inGameID: 1, - }, - difficulty: "ADV", - }, - dryScore: { - scoreData: { - score: 9_579_365, - lamp: "EXCESSIVE CLEAR", - judgements: {}, - optional: { - maxCombo: 158, - exScore: 1334, - }, - }, - timeAchieved: TestingKsHookSV6CStaticScore.timestamp * 1000, - game: "sdvx", - importType: "ir/kshook-sv6c", - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should throw an error if song or chart can't be found.", (t) => { - t.rejects( - () => - ConverterKsHookSV6CStatic( - { ...TestingKsHookSV6CStaticScore, music_id: 10000 }, - {}, - "ir/kshook-sv6c", - log, - ), - "Should throw a SongOrChartNotFoundError if chart can't be found.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts new file mode 100644 index 000000000..128e892e5 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts @@ -0,0 +1,88 @@ +import { log } from "#lib/log/log"; +import { SongOrChartNotFoundFailure } from "#lib/score-import/framework/common/converter-failures"; +import DB from "#services/pg/db"; +import { + TestingAlbidaADV, + TestingKsHookSV6CStaticScore, + TestingSDVXAlbidaSong, +} from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ConverterKsHookSV6CStatic } from "./converter"; + +async function seedAlbidaAdvKonaste() { + await DB.insertInto("song") + .values({ + id: TestingSDVXAlbidaSong.id, + legacy_id: 1, + game_group: "sdvx", + title: TestingSDVXAlbidaSong.title, + artist: TestingSDVXAlbidaSong.artist, + search_terms: TestingSDVXAlbidaSong.searchTerms, + alt_titles: TestingSDVXAlbidaSong.altTitles, + data: TestingSDVXAlbidaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingAlbidaADV.chartID, + legacy_id: TestingAlbidaADV.chartID, + game: "sdvx", + song_id: TestingSDVXAlbidaSong.id, + difficulty: "ADV", + level: TestingAlbidaADV.level, + level_num: TestingAlbidaADV.levelNum, + is_primary: TestingAlbidaADV.isPrimary, + versions: TestingAlbidaADV.versions, + data: TestingAlbidaADV.data, + }) + .execute(); +} + +describe("ConverterKsHookSV6CStatic", () => { + beforeEach(seedAlbidaAdvKonaste); + + it("matches score to song and chart", async () => { + const res = await ConverterKsHookSV6CStatic( + TestingKsHookSV6CStaticScore, + {}, + "ir/kshook-sv6c-static", + log, + ); + + expect(res.song.id).toBe(TestingSDVXAlbidaSong.id); + expect(res.chart).toMatchObject({ + data: { inGameID: 1 }, + difficulty: "ADV", + }); + expect(res.dryScore).toMatchObject({ + timeAchieved: TestingKsHookSV6CStaticScore.timestamp * 1000, + game: "sdvx", + importType: "ir/kshook-sv6c", + service: "kshook SV6C Static", + scoreData: { + score: 9_579_365, + lamp: "EXCESSIVE CLEAR", + judgements: {}, + optional: { + maxCombo: 158, + exScore: 1334, + }, + }, + scoreMeta: {}, + }); + }); + + it("throws when chart is missing", async () => { + await expect( + ConverterKsHookSV6CStatic( + { ...TestingKsHookSV6CStaticScore, music_id: 10_000 }, + {}, + "ir/kshook-sv6c-static", + log, + ), + ).rejects.toBeInstanceOf(SongOrChartNotFoundFailure); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.ts index 345c306d8..1b879e793 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; import { @@ -8,7 +9,6 @@ import { import { FindSDVXChartOnInGameIDVersion } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { KsHookSV6CStaticScore } from "./types"; import { SV6CConvertDifficulty, SV6CConvertLamp } from "../kshook-sv6c/converter"; @@ -30,14 +30,14 @@ export const ConverterKsHookSV6CStatic: ConverterFunction< ); } - const song = await FindSongOnID("sdvx", chart.songID); + const song = await FindSongOnID("sdvx", chart.song.id); if (!song) { - log.error(`Song ${chart.songID} (sdvx) has no parent song?`); - throw new InternalFailure(`Song ${chart.songID} (sdvx) has no parent song?`); + log.error(`Song ${chart.song.id} (sdvx) has no parent song?`); + throw new InternalFailure(`Song ${chart.song.id} (sdvx) has no parent song?`); } - const dryScore: DryScore<"sdvx:Single"> = { + const dryScore: DryScore<"sdvx"> = { game: "sdvx", service: "kshook SV6C Static", comment: null, diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.oldtest.ts deleted file mode 100644 index 411371064..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.oldtest.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { TestingKsHookSV6CScore, TestingKsHookSV6CStaticScore } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ParseKsHookSV6CStatic } from "./parser"; - -t.test("#ParseKsHookSV6CStatic", (t) => { - const assertFail = (data: any, message: string) => { - t.throws(() => ParseKsHookSV6CStatic(data, log), message); - }; - - const assertSuccess = (data: any, message: string) => { - try { - t.doesNotThrow(() => ParseKsHookSV6CStatic(data, log), message); - - const res = ParseKsHookSV6CStatic(data, log); - - t.equal(res.game, "sdvx"); - t.ok(Array.isArray(res.iterable)); - } catch (err) { - t.fail(`[${message}] ${(err as Error).message}`); - } - }; - - const dm = (data: any) => ({ scores: [deepmerge(TestingKsHookSV6CStaticScore, data)] }); - - assertSuccess({ scores: [TestingKsHookSV6CStaticScore] }, "Should parse a valid score."); - assertSuccess( - dm({ unexpectedField: "foo" }), - "Should allow excess keys that we do not recognise.", - ); - - assertFail({}, "Should reject an empty object"); - assertFail({ scores: TestingKsHookSV6CStaticScore }, "Should reject non-array scores"); - assertFail(dm({ clear: "invalid_clear" }), "Should reject invalid clears."); - assertFail(dm({ difficulty: "invalid_difficulty" }), "Should reject invalid difficulties."); - - assertFail(dm({ grade: "invalid_grade" }), "Should reject invalid grades."); - - assertFail(dm({ max_chain: -1 }), "Should reject negative max_chains."); - assertFail(dm({ max_chain: 100.5 }), "Should reject non-integer max_chains."); - - assertFail(dm({ score: -1 }), "Should reject negative scores."); - assertFail(dm({ score: 10_000_001 }), "Should reject > 10m scores."); - assertSuccess(dm({ score: 10_000_000 }), "Should allow scores of 10m."); - assertSuccess(dm({ score: 0 }), "Should allow scores of 0."); - - assertSuccess( - { scores: [TestingKsHookSV6CStaticScore, TestingKsHookSV6CStaticScore] }, - "Should allow multiple scores in the scores array.", - ); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.test.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.test.ts new file mode 100644 index 000000000..07fa88976 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { log } from "#lib/log/log"; +import { TestingKsHookSV6CStaticScore } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { describe, expect, it } from "vitest"; + +import { ParseKsHookSV6CStatic } from "./parser"; + +describe("ParseKsHookSV6CStatic", () => { + const assertFail = (data: any) => { + expect(() => ParseKsHookSV6CStatic(data, log)).toThrow(); + }; + + const assertSuccess = (data: any) => { + expect(() => ParseKsHookSV6CStatic(data, log)).not.toThrow(); + const res = ParseKsHookSV6CStatic(data, log); + expect(res.gameGroup).toBe("sdvx"); + expect(Array.isArray(res.iterable)).toBe(true); + }; + + const dm = (data: any) => ({ scores: [deepmerge(TestingKsHookSV6CStaticScore, data)] }); + + it("parses static KsHook payloads", () => { + assertSuccess({ scores: [TestingKsHookSV6CStaticScore] }); + assertSuccess(dm({ unexpectedField: "foo" })); + + assertFail({}); + assertFail({ scores: TestingKsHookSV6CStaticScore }); + assertFail(dm({ clear: "invalid_clear" })); + assertFail(dm({ difficulty: "invalid_difficulty" })); + + assertFail(dm({ grade: "invalid_grade" })); + + assertFail(dm({ max_chain: -1 })); + assertFail(dm({ max_chain: 100.5 })); + + assertFail(dm({ score: -1 })); + assertFail(dm({ score: 10_000_001 })); + assertSuccess(dm({ score: 10_000_000 })); + assertSuccess(dm({ score: 0 })); + + assertSuccess({ + scores: [TestingKsHookSV6CStaticScore, TestingKsHookSV6CStaticScore], + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts index 9805ce91f..9cc98a401 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts @@ -1,11 +1,12 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { EmptyObject } from "#utils/types"; +import type { GamesForGroup } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError } from "#utils/prudence"; import { p, type PrudenceSchema } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { KsHookSV6CStaticBody, KsHookSV6CStaticScore } from "./types"; import { PR_KSHOOK_SV6C } from "../kshook-sv6c/parser"; @@ -29,7 +30,7 @@ const PR_KSHOOK_SV6C_STATIC: PrudenceSchema = { export function ParseKsHookSV6CStatic( body: Record, _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { // Ignore excess keys, as SV6C might add more features in the future. const err = p(body, PR_KSHOOK_SV6C_STATIC, undefined, { allowExcessKeys: true }); @@ -40,7 +41,7 @@ export function ParseKsHookSV6CStatic( const data = body as unknown as KsHookSV6CStaticBody; return { - game: "sdvx", + gameGroup: "sdvx", iterable: data.scores, context: {}, classProvider: null, diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.oldtest.ts deleted file mode 100644 index 01abdf777..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.oldtest.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { log } from "#lib/log/log.js"; -import ResetDBState from "#test-utils/resets"; -import { TestingKsHookSV6CScore } from "#test-utils/test-data"; -import t from "tap"; - -import { ConverterIRKsHookSV6C } from "./converter"; - -t.test("#ConverterIRKsHookSV6C", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should match a score with its song and chart.", async (t) => { - const res = await ConverterIRKsHookSV6C( - TestingKsHookSV6CScore, - { timeReceived: 10 }, - "ir/kshook-sv6c", - log, - ); - - t.hasStrict(res, { - song: { - id: 1, - }, - chart: { - data: { - inGameID: 1, - }, - difficulty: "ADV", - }, - dryScore: { - scoreData: { - score: 9_579_365, - lamp: "EXCESSIVE CLEAR", - judgements: { - critical: 1184, - near: 46, - miss: 30, - }, - optional: { - maxCombo: 158, - exScore: 1334, - }, - }, - game: "sdvx", - importType: "ir/kshook-sv6c", - scoreMeta: {}, - }, - }); - - t.end(); - }); - - t.test("Should throw an error if song or chart can't be found.", (t) => { - t.rejects( - () => - ConverterIRKsHookSV6C( - { ...TestingKsHookSV6CScore, music_id: 10000 }, - { timeReceived: 10 }, - "ir/kshook-sv6c", - log, - ), - "Should throw a SongOrChartNotFoundError if chart can't be found.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.test.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.test.ts new file mode 100644 index 000000000..f415a2d85 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.test.ts @@ -0,0 +1,94 @@ +import { log } from "#lib/log/log"; +import { SongOrChartNotFoundFailure } from "#lib/score-import/framework/common/converter-failures"; +import DB from "#services/pg/db"; +import { + TestingAlbidaADV, + TestingKsHookSV6CScore, + TestingSDVXAlbidaSong, +} from "#test-utils/test-data"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ConverterIRKsHookSV6C } from "./converter"; + +async function seedAlbidaAdvKonaste() { + await DB.insertInto("song") + .values({ + id: TestingSDVXAlbidaSong.id, + legacy_id: 1, + game_group: "sdvx", + title: TestingSDVXAlbidaSong.title, + artist: TestingSDVXAlbidaSong.artist, + search_terms: TestingSDVXAlbidaSong.searchTerms, + alt_titles: TestingSDVXAlbidaSong.altTitles, + data: TestingSDVXAlbidaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingAlbidaADV.chartID, + legacy_id: TestingAlbidaADV.chartID, + game: "sdvx", + song_id: TestingSDVXAlbidaSong.id, + difficulty: "ADV", + level: TestingAlbidaADV.level, + level_num: TestingAlbidaADV.levelNum, + is_primary: TestingAlbidaADV.isPrimary, + versions: TestingAlbidaADV.versions, + data: TestingAlbidaADV.data, + }) + .execute(); +} + +describe("ConverterIRKsHookSV6C", () => { + beforeEach(seedAlbidaAdvKonaste); + + it("matches score to song and chart", async () => { + const res = await ConverterIRKsHookSV6C( + TestingKsHookSV6CScore, + { timeReceived: 10 }, + "ir/kshook-sv6c", + log, + ); + + expect(res.song.id).toBe(TestingSDVXAlbidaSong.id); + expect(res.chart).toMatchObject({ + data: { inGameID: 1 }, + difficulty: "ADV", + }); + expect(res.dryScore).toMatchObject({ + game: "sdvx", + importType: "ir/kshook-sv6c", + service: "kshook SV6C", + comment: null, + timeAchieved: 10, + scoreData: { + score: 9_579_365, + lamp: "EXCESSIVE CLEAR", + judgements: { + critical: 1184, + near: 46, + miss: 30, + }, + optional: { + maxCombo: 158, + exScore: 1334, + gauge: 0.71, + }, + }, + scoreMeta: {}, + }); + }); + + it("throws when chart is missing", async () => { + await expect( + ConverterIRKsHookSV6C( + { ...TestingKsHookSV6CScore, music_id: 10_000 }, + { timeReceived: 10 }, + "ir/kshook-sv6c", + log, + ), + ).rejects.toBeInstanceOf(SongOrChartNotFoundFailure); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.ts index 341e394a1..934f56f54 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.ts @@ -1,4 +1,5 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { GetEnumValue } from "tachi-common/types/metrics"; import { @@ -9,7 +10,6 @@ import { import { FindSDVXChartOnInGameIDVersion } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { KsHookSV6CContext, KsHookSV6CScore } from "./types"; export const ConverterIRKsHookSV6C: ConverterFunction = async ( @@ -31,14 +31,14 @@ export const ConverterIRKsHookSV6C: ConverterFunction = { + const dryScore: DryScore<"sdvx"> = { game: "sdvx", service: "kshook SV6C", comment: null, @@ -64,9 +64,7 @@ export const ConverterIRKsHookSV6C: ConverterFunction { +export function SV6CConvertLamp(clear: KsHookSV6CScore["clear"]): GetEnumValue<"sdvx", "lamp"> { switch (clear) { case "CLEAR_PLAYED": return "FAILED"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.oldtest.ts deleted file mode 100644 index 6e9c4415d..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.oldtest.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { TestingKsHookSV6CScore } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ParseKsHookSV6C } from "./parser"; - -t.test("#ParseKsHookSV6C", (t) => { - const assertFail = (data: any, message: string) => { - t.throws(() => ParseKsHookSV6C(data, log), message); - }; - - const assertSuccess = (data: any, message: string) => { - try { - t.doesNotThrow(() => ParseKsHookSV6C(data, log), message); - - const res = ParseKsHookSV6C(data, log); - - t.equal(res.game, "sdvx"); - t.type(res.context.timeReceived, "number"); - t.ok(Array.isArray(res.iterable)); - } catch (err) { - t.fail(`[${message}] ${(err as Error).message}`); - } - }; - - const dm = (data: any) => deepmerge(TestingKsHookSV6CScore, data); - - assertSuccess(TestingKsHookSV6CScore, "Should parse a valid score."); - assertSuccess( - dm({ unexpectedField: "foo" }), - "Should allow excess keys that we do not recognise.", - ); - - assertFail({}, "Should reject an empty object"); - assertFail(dm({ clear: "invalid_clear" }), "Should reject invalid clears."); - assertFail(dm({ difficulty: "invalid_difficulty" }), "Should reject invalid difficulties."); - - assertFail(dm({ gauge: -1 }), "Should reject negative gauge values."); - assertFail(dm({ gauge: 10001 }), "Should reject gauge values over 10000."); - assertSuccess(dm({ gauge: 0 }), "Should allow gauge values of 0."); - assertSuccess(dm({ gauge: 10000 }), "Should allow gauge values of 10000."); - - assertFail(dm({ grade: "invalid_grade" }), "Should reject invalid grades."); - - assertFail(dm({ max_chain: -1 }), "Should reject negative max_chains."); - assertFail(dm({ max_chain: 100.5 }), "Should reject non-integer max_chains."); - - assertFail(dm({ score: -1 }), "Should reject negative scores."); - assertFail(dm({ score: 10_000_001 }), "Should reject > 10m scores."); - assertSuccess(dm({ score: 10_000_000 }), "Should allow scores of 10m."); - assertSuccess(dm({ score: 0 }), "Should allow scores of 0."); - - assertFail(dm({ rate: "invalid_rate" }), "Should reject invalid rates."); - - assertFail(dm({ track_no: -1 }), "Should reject negative track_no's."); - assertFail(dm({ track_no: 50.5 }), "Should reject non-int track_no's."); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.test.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.test.ts new file mode 100644 index 000000000..23291a594 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.test.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { log } from "#lib/log/log"; +import { TestingKsHookSV6CScore } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { describe, expect, it } from "vitest"; + +import { ParseKsHookSV6C } from "./parser"; + +describe("ParseKsHookSV6C", () => { + const assertFail = (data: any) => { + expect(() => ParseKsHookSV6C(data, log)).toThrow(); + }; + + const assertSuccess = (data: any) => { + expect(() => ParseKsHookSV6C(data, log)).not.toThrow(); + const res = ParseKsHookSV6C(data, log); + expect(res.gameGroup).toBe("sdvx"); + expect(typeof res.context.timeReceived).toBe("number"); + expect(Array.isArray(res.iterable)).toBe(true); + }; + + const dm = (data: any) => deepmerge(TestingKsHookSV6CScore, data); + + it("parses valid KsHook payloads and rejects invalid ones", () => { + assertSuccess(TestingKsHookSV6CScore); + assertSuccess(dm({ unexpectedField: "foo" })); + + assertFail({}); + assertFail(dm({ clear: "invalid_clear" })); + assertFail(dm({ difficulty: "invalid_difficulty" })); + + assertFail(dm({ gauge: -1 })); + assertFail(dm({ gauge: 10001 })); + assertSuccess(dm({ gauge: 0 })); + assertSuccess(dm({ gauge: 10000 })); + + assertFail(dm({ grade: "invalid_grade" })); + + assertFail(dm({ max_chain: -1 })); + assertFail(dm({ max_chain: 100.5 })); + + assertFail(dm({ score: -1 })); + assertFail(dm({ score: 10_000_001 })); + assertSuccess(dm({ score: 10_000_000 })); + assertSuccess(dm({ score: 0 })); + + assertFail(dm({ rate: "invalid_rate" })); + + assertFail(dm({ track_no: -1 })); + assertFail(dm({ track_no: 50.5 })); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.ts index 889ec0168..d2f949957 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.ts @@ -1,10 +1,11 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError } from "#utils/prudence"; import { p, type PrudenceSchema } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { KsHookSV6CContext, KsHookSV6CScore } from "./types"; export const PR_KSHOOK_SV6C: PrudenceSchema = { @@ -61,7 +62,7 @@ export const PR_KSHOOK_SV6C: PrudenceSchema = { export function ParseKsHookSV6C( body: Record, _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { // Ignore excess keys, as SV6C might add more features in the future. const err = p(body, PR_KSHOOK_SV6C, undefined, { allowExcessKeys: true }); @@ -72,7 +73,7 @@ export function ParseKsHookSV6C( const score = body as unknown as KsHookSV6CScore; return { - game: "sdvx", + gameGroup: "sdvx", iterable: [score], context: { timeReceived: Date.now(), diff --git a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.oldtest.ts deleted file mode 100644 index 748acae60..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.oldtest.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { dmf } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { TestingLR2HookScore } from "#test-utils/test-data"; -import { ApplyNTimes } from "#utils/misc"; -import t from "tap"; - -import { ConverterLR2Hook } from "./converter"; - -t.test("#ConverterLR2Hook", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should match a score with its song and chart.", async (t) => { - const res = await ConverterLR2Hook( - TestingLR2HookScore, - { timeReceived: 10_000 }, - "ir/lr2hook", - log, - ); - - t.hasStrict(res, { - song: { - id: 27339, - }, - chart: { - chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", - data: { - hashMD5: TestingLR2HookScore.md5, - }, - }, - dryScore: { - timeAchieved: 10_000, - scoreData: { - score: TestingLR2HookScore.scoreData.exScore, - optional: { - bp: 56, - epg: undefined, - lpg: undefined, - egr: undefined, - lgr: undefined, - egd: undefined, - lgd: undefined, - ebd: undefined, - lbd: undefined, - epr: undefined, - lpr: undefined, - fast: undefined, - slow: undefined, - }, - }, - game: "bms", - importType: "ir/lr2hook", - scoreMeta: { - client: "LR2", - }, - }, - }); - - t.end(); - }); - - t.test("Should use optional and extended properties.", async (t) => { - const res = await ConverterLR2Hook( - dmf(TestingLR2HookScore, { - scoreData: { - extendedJudgements: { - epg: 1, - lpg: 2, - egr: 3, - lgr: 4, - egd: 5, - lgd: 6, - ebd: 7, - lbd: 8, - epr: 9, - lpr: 10, - cb: 11, - fast: 12, - slow: 13, - notesPlayed: TestingLR2HookScore.scoreData.notesPlayed, - }, - extendedHpGraphs: { - groove: ApplyNTimes(1000, () => 1), - hard: ApplyNTimes(1000, () => 2), - hazard: ApplyNTimes(1000, () => 3), - easy: ApplyNTimes(1000, () => 4), - pattack: ApplyNTimes(1000, () => 5), - gattack: ApplyNTimes(1000, () => 6), - }, - }, - unixTimestamp: 8, - } as any), - { timeReceived: 10_000 }, - "ir/lr2hook", - log, - ); - - t.hasStrict(res, { - song: { - id: 27339, - }, - chart: { - chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", - data: { - hashMD5: TestingLR2HookScore.md5, - }, - }, - dryScore: { - timeAchieved: 8_000, - scoreData: { - score: TestingLR2HookScore.scoreData.exScore, - optional: { - bp: 56, - epg: 1, - lpg: 2, - egr: 3, - lgr: 4, - egd: 5, - lgd: 6, - ebd: 7, - lbd: 8, - epr: 9, - lpr: 10, - fast: 12, - slow: 13, - gaugeHistoryGroove: ApplyNTimes(1000, () => 1), - gaugeHistoryHard: ApplyNTimes(1000, () => 2), - gaugeHistoryEasy: ApplyNTimes(1000, () => 4), - }, - }, - game: "bms", - importType: "ir/lr2hook", - scoreMeta: { - client: "LR2", - }, - }, - }); - - t.end(); - }); - - t.test("Should null BP if the score was exited early.", async (t) => { - const res = await ConverterLR2Hook( - dmf(TestingLR2HookScore, { - scoreData: { - notesPlayed: TestingLR2HookScore.scoreData.notesTotal - 1, - }, - } as any), - { timeReceived: 10 }, - "ir/lr2hook", - log, - ); - - t.hasStrict(res, { - song: { - id: 27339, - }, - chart: { - chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", - data: { - hashMD5: TestingLR2HookScore.md5, - }, - }, - dryScore: { - scoreData: { - score: TestingLR2HookScore.scoreData.exScore, - optional: { - bp: null, - }, - }, - game: "bms", - importType: "ir/lr2hook", - scoreMeta: { - client: "LR2", - }, - }, - }); - - t.end(); - }); - - t.test( - "Should calculate BP if the score was exited early but there is extendedJudgements.", - async (t) => { - const res = await ConverterLR2Hook( - dmf(TestingLR2HookScore, { - scoreData: { - notesPlayed: TestingLR2HookScore.scoreData.notesTotal - 1, - extendedJudgements: { - notesPlayed: TestingLR2HookScore.scoreData.notesTotal - 1, - }, - }, - } as any), - { timeReceived: 10 }, - "ir/lr2hook", - log, - ); - - t.hasStrict(res, { - song: { - id: 27339, - }, - chart: { - chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", - data: { - hashMD5: TestingLR2HookScore.md5, - }, - }, - dryScore: { - scoreData: { - score: TestingLR2HookScore.scoreData.exScore, - optional: { - bp: 57, - }, - }, - game: "bms", - importType: "ir/lr2hook", - scoreMeta: { - client: "LR2", - }, - }, - }); - - t.end(); - }, - ); - - t.test("Should throw an error if song or chart can't be found.", (t) => { - t.rejects( - () => - ConverterLR2Hook( - { ...TestingLR2HookScore, md5: "nonsense_md5" }, - { timeReceived: 10 }, - "ir/lr2hook", - log, - ), - "Should throw a SongOrChartNotFoundError if chart can't be found.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.test.ts b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.test.ts new file mode 100644 index 000000000..e9f874753 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.test.ts @@ -0,0 +1,193 @@ +import { log } from "#lib/log/log"; +import { SongOrChartNotFoundFailure } from "#lib/score-import/framework/common/converter-failures"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { BMSGazerChart, BMSGazerSong, TestingLR2HookScore } from "#test-utils/test-data"; +import { ApplyNTimes } from "#utils/misc"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ConverterLR2Hook } from "./converter"; + +async function seedGazerChart() { + await DB.insertInto("song") + .values({ + id: BMSGazerSong.id, + legacy_id: 27_339, + game_group: "bms", + title: BMSGazerSong.title, + artist: BMSGazerSong.artist, + search_terms: BMSGazerSong.searchTerms, + alt_titles: BMSGazerSong.altTitles, + data: BMSGazerSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: BMSGazerChart.chartID, + legacy_id: BMSGazerChart.chartID, + game: "bms-7k", + song_id: BMSGazerSong.id, + difficulty: "CHART", + level: BMSGazerChart.level, + level_num: BMSGazerChart.levelNum, + is_primary: BMSGazerChart.isPrimary, + versions: BMSGazerChart.versions, + data: BMSGazerChart.data, + }) + .execute(); +} + +describe("ConverterLR2Hook", () => { + beforeEach(seedGazerChart); + + it("matches score to song and chart", async () => { + const res = await ConverterLR2Hook( + TestingLR2HookScore, + { timeReceived: 10_000 }, + "ir/lr2hook", + log, + ); + + expect(res.song.id).toBe(BMSGazerSong.id); + expect(res.chart.chartID).toBe(BMSGazerChart.chartID); + expect(res.chart.data).toMatchObject({ + hashMD5: TestingLR2HookScore.md5, + }); + expect(res.dryScore).toMatchObject({ + game: "bms-7k", + importType: "ir/lr2hook", + service: "LR2Hook", + comment: null, + timeAchieved: 10_000, + scoreData: { + score: TestingLR2HookScore.scoreData.exScore, + lamp: "HARD CLEAR", + judgements: { + pgreat: TestingLR2HookScore.scoreData.pgreat, + great: TestingLR2HookScore.scoreData.great, + good: TestingLR2HookScore.scoreData.good, + bad: TestingLR2HookScore.scoreData.bad, + poor: TestingLR2HookScore.scoreData.poor, + }, + optional: { + bp: 56, + maxCombo: TestingLR2HookScore.scoreData.maxCombo, + }, + }, + scoreMeta: { + client: "LR2", + gauge: "NORMAL", + random: "RANDOM", + }, + }); + expect( + (res.dryScore.scoreData.optional as { gaugeHistory: unknown }).gaugeHistory, + ).toHaveLength(1000); + }); + + it("includes extended judgements and selected HP graph series", async () => { + const res = await ConverterLR2Hook( + dmf(TestingLR2HookScore, { + scoreData: { + extendedJudgements: { + epg: 1, + lpg: 2, + egr: 3, + lgr: 4, + egd: 5, + lgd: 6, + ebd: 7, + lbd: 8, + epr: 9, + lpr: 10, + cb: 11, + fast: 12, + slow: 13, + notesPlayed: TestingLR2HookScore.scoreData.notesPlayed, + }, + extendedHpGraphs: { + groove: ApplyNTimes(1000, () => 1), + hard: ApplyNTimes(1000, () => 2), + hazard: ApplyNTimes(1000, () => 3), + easy: ApplyNTimes(1000, () => 4), + pattack: ApplyNTimes(1000, () => 5), + gattack: ApplyNTimes(1000, () => 6), + }, + }, + unixTimestamp: 8, + } as { unixTimestamp: number } & typeof TestingLR2HookScore), + { timeReceived: 10_000 }, + "ir/lr2hook", + log, + ); + + expect(res.song.id).toBe(BMSGazerSong.id); + expect(res.chart.data).toMatchObject({ hashMD5: TestingLR2HookScore.md5 }); + expect(res.dryScore.timeAchieved).toBe(8000); + expect(res.dryScore.scoreData.optional).toMatchObject({ + bp: 56, + epg: 1, + lpg: 2, + egr: 3, + lgr: 4, + egd: 5, + lgd: 6, + ebd: 7, + lbd: 8, + epr: 9, + lpr: 10, + fast: 12, + slow: 13, + gaugeHistoryGroove: ApplyNTimes(1000, () => 1), + gaugeHistoryHard: ApplyNTimes(1000, () => 2), + gaugeHistoryEasy: ApplyNTimes(1000, () => 4), + }); + }); + + it("nulls BP when the chart was exited early", async () => { + const res = await ConverterLR2Hook( + dmf(TestingLR2HookScore, { + scoreData: { + notesPlayed: TestingLR2HookScore.scoreData.notesTotal - 1, + }, + } as typeof TestingLR2HookScore), + { timeReceived: 10 }, + "ir/lr2hook", + log, + ); + + expect(res.song.id).toBe(BMSGazerSong.id); + expect((res.dryScore.scoreData.optional as { bp: number | null }).bp).toBeNull(); + }); + + it("computes BP from extendedJudgements when exited early", async () => { + const res = await ConverterLR2Hook( + dmf(TestingLR2HookScore, { + scoreData: { + notesPlayed: TestingLR2HookScore.scoreData.notesTotal - 1, + extendedJudgements: { + notesPlayed: TestingLR2HookScore.scoreData.notesTotal - 1, + }, + }, + } as typeof TestingLR2HookScore), + { timeReceived: 10 }, + "ir/lr2hook", + log, + ); + + expect((res.dryScore.scoreData.optional as { bp: number }).bp).toBe(57); + }); + + it("throws when no chart matches the MD5", async () => { + await expect( + ConverterLR2Hook( + { ...TestingLR2HookScore, md5: "nonsense_md5" }, + { timeReceived: 10 }, + "ir/lr2hook", + log, + ), + ).rejects.toBeInstanceOf(SongOrChartNotFoundFailure); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.ts index c0f04b744..d5077b78c 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/converter.ts @@ -1,5 +1,6 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; -import type { MONGO_ScoreDocument } from "tachi-common"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup, ScoreDocument } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; import { @@ -9,7 +10,6 @@ import { import { FindBMSChartOnHash } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; -import type { ConverterFunction } from "../../common/types"; import type { LR2HookContext, LR2HookScore } from "./types"; export const ConverterLR2Hook: ConverterFunction = async ( @@ -29,11 +29,11 @@ export const ConverterLR2Hook: ConverterFunction = ); } - const song = await FindSongOnID("bms", chart.songID); + const song = await FindSongOnID("bms", chart.song.id); if (!song) { - log.error(`Song ${chart.songID} (bms) has no parent song?`); - throw new InternalFailure(`Song ${chart.songID} (bms) has no parent song?`); + log.error(`Song ${chart.song.id} (bms) has no parent song?`); + throw new InternalFailure(`Song ${chart.song.id} (bms) has no parent song?`); } const gauge = ConvertGauge(data.playerData.gauge); @@ -59,8 +59,8 @@ export const ConverterLR2Hook: ConverterFunction = : null; })(); - const dryScore: DryScore<"bms:7K" | "bms:14K"> = { - game: "bms", + const dryScore: DryScore = { + game: chart.game, service: "LR2Hook", comment: null, importType: "ir/lr2hook", @@ -100,7 +100,7 @@ export const ConverterLR2Hook: ConverterFunction = }, scoreMeta: { gauge, - random: chart.playtype === "7K" ? ConvertRandom(data.playerData.random) : null, + random: chart.game === "bms-7k" ? ConvertRandom(data.playerData.random) : null, client: "LR2", }, }; @@ -110,7 +110,7 @@ export const ConverterLR2Hook: ConverterFunction = function ConvertGauge( gauge: LR2HookScore["playerData"]["gauge"], -): MONGO_ScoreDocument<"bms:7K" | "bms:14K">["scoreMeta"]["gauge"] { +): ScoreDocument["scoreMeta"]["gauge"] { switch (gauge) { case "EASY": return "EASY"; @@ -127,7 +127,7 @@ function ConvertGauge( function ConvertRandom( random: LR2HookScore["playerData"]["random"], -): MONGO_ScoreDocument<"bms:7K">["scoreMeta"]["random"] { +): ScoreDocument["scoreMeta"]["random"] { switch (random) { case "NORAN": return "NONRAN"; @@ -142,7 +142,7 @@ function ConvertRandom( function ConvertLamp( lamp: LR2HookScore["scoreData"]["lamp"], -): GetEnumValue<"bms:7K" | "bms:14K", "lamp"> { +): GetEnumValue { switch (lamp) { case "EASY": return "EASY CLEAR"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.oldtest.ts deleted file mode 100644 index 72da17ab7..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.oldtest.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { TestingLR2HookScore } from "#test-utils/test-data"; -import { ApplyNTimes } from "#utils/misc"; -import deepmerge from "deepmerge"; -import t from "tap"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ParseLR2Hook } from "./parser"; - -t.test("#ParseLR2Hook", (t) => { - const assertFail = (data: any, message: string) => { - t.throws(() => ParseLR2Hook(data, log), message); - }; - - const assertSuccess = (data: any, message: string) => { - try { - t.doesNotThrow(() => ParseLR2Hook(data, log), message); - - const res = ParseLR2Hook(data, log); - - t.equal(res.game, "bms"); - t.type(res.context.timeReceived, "number"); - t.ok(Array.isArray(res.iterable)); - t.equal(res.classProvider, null); - } catch (err) { - t.fail(`[${message}] ${(err as Error).message}`); - } - }; - - const dm = (data: any) => deepmerge(TestingLR2HookScore, data); - const dms = (data: any) => - deepmerge( - TestingLR2HookScore, - { scoreData: data }, - { - arrayMerge: (a, b) => b, - }, - ); - const dmse = (data: any) => - deepmerge( - deepmerge(TestingLR2HookScore, { - scoreData: { - extendedJudgements: { - epg: 0, - lpg: 0, - egr: 0, - lgr: 0, - egd: 0, - lgd: 0, - ebd: 0, - lbd: 0, - epr: 0, - lpr: 0, - cb: 0, - fast: 0, - slow: 0, - notesPlayed: 0, - }, - }, - }), - { scoreData: data }, - { - arrayMerge: (a, b) => b, - }, - ); - - assertSuccess(TestingLR2HookScore, "Should parse a valid score."); - assertSuccess( - dm({ unexpectedField: "foo" }), - "Should allow excess keys that we do not recognise.", - ); - assertSuccess( - dm({ scoreData: { unexpectedField: "foo" } }), - "Should allow excess keys inside scoreData that we do not recognise.", - ); - assertSuccess( - dm({ playerData: { unexpectedField: "foo" } }), - "Should allow excess keys inside playerData that we do not recognise.", - ); - - assertFail( - dm({ playerData: { autoScr: true } }), - "Should reject scores where autoScr is set to true.", - ); - assertFail( - dm({ playerData: { random: "H-RAN" } }), - "Should reject scores where random is set to H-RAN.", - ); - assertFail( - dm({ playerData: { random: "ALLSCR" } }), - "Should reject scores where random is set to ALLSCR", - ); - - assertFail({}, "Should reject an empty object"); - - assertFail(dm({ playerData: { rseed: "0" } }), "Should reject string rseed."); - assertFail(dm({ playerData: { rseed: 0.5 } }), "Should reject decimal rseed."); - - assertFail(dm({ unixTimestamp: "foo" }), "Should reject string unixTimestamp."); - assertFail(dm({ unixTimestamp: 0.5 }), "Should reject decimal unixTimestamp."); - - for (const key of [ - "pgreat", - "good", - "bad", - "poor", - "great", - "maxCombo", - "exScore", - "notesTotal", - "notesPlayed", - ]) { - assertFail(dms({ [key]: -1 }), `Should reject negative ${key}`); - assertFail(dms({ [key]: 0.5 }), `Should reject decimal ${key}`); - assertFail(dms({ [key]: "0" }), `Should reject string ${key}`); - assertFail(dms({ [key]: null }), `Should reject nonsense ${key}`); - } - - assertFail(dms({ lamp: "UNKNOWN_LAMP" }), "Should reject unknown lamp."); - assertFail(dms({ lamp: null }), "Should reject null lamp."); - assertFail(dms({ lamp: undefined }), "Should reject no lamp."); - - for (const key of [ - "epg", - "lpg", - "egr", - "lgr", - "egd", - "lgd", - "ebd", - "lbd", - "epr", - "lpr", - "cb", - "fast", - "slow", - "notesPlayed", - ]) { - assertFail(dmse({ extendedJudgements: { [key]: -1 } }), `Should reject negative ${key}`); - assertFail(dmse({ extendedJudgements: { [key]: 0.5 } }), `Should reject decimal ${key}`); - assertFail(dmse({ extendedJudgements: { [key]: "0" } }), `Should reject string ${key}`); - assertFail(dmse({ extendedJudgements: { [key]: null } }), `Should reject nonsense ${key}`); - } - - assertFail( - dms({ hpGraph: ApplyNTimes(999, () => 50) }), - "Should disallow hp graph with <1000 elements.", - ); - assertFail(dms({ hpGraph: [] }), "Should disallow hp graph with 0 elements."); - assertFail( - dms({ hpGraph: ApplyNTimes(1001, () => 50) }), - "Should disallow hp graph with >1000 elements.", - ); - assertFail( - dms({ hpGraph: ApplyNTimes(1000, () => 101) }), - "Should disallow hp graph with elements larger than 100.", - ); - assertFail( - dms({ hpGraph: ApplyNTimes(1000, () => -1) }), - "Should disallow hp graph with negative element values.", - ); - - assertFail( - dms({ - extendedHpGraphs: { - groove: [], - hard: ApplyNTimes(1000, () => 100), - hazard: ApplyNTimes(1000, () => 100), - easy: ApplyNTimes(1000, () => 100), - pattack: ApplyNTimes(1000, () => 100), - gattack: ApplyNTimes(1000, () => 100), - }, - }), - "Should disallow any extendedJudgements with 0 elements.", - ); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.test.ts b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.test.ts new file mode 100644 index 000000000..249c9085f --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.test.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { log } from "#lib/log/log"; +import { TestingLR2HookScore } from "#test-utils/test-data"; +import { ApplyNTimes } from "#utils/misc"; +import deepmerge from "deepmerge"; +import { describe, expect, it } from "vitest"; + +import { ParseLR2Hook } from "./parser"; + +describe("ParseLR2Hook", () => { + const assertFail = (data: any) => { + expect(() => ParseLR2Hook(data, log)).toThrow(); + }; + + const assertSuccess = (data: any) => { + expect(() => ParseLR2Hook(data, log)).not.toThrow(); + const res = ParseLR2Hook(data, log); + expect(res.gameGroup).toBe("bms"); + expect(typeof res.context.timeReceived).toBe("number"); + expect(Array.isArray(res.iterable)).toBe(true); + expect(res.classProvider).toBeNull(); + }; + + const dm = (data: any) => deepmerge(TestingLR2HookScore, data); + const dms = (data: any) => + deepmerge( + TestingLR2HookScore, + { scoreData: data }, + { + arrayMerge: (_a, b) => b, + }, + ); + const dmse = (data: any) => + deepmerge( + deepmerge(TestingLR2HookScore, { + scoreData: { + extendedJudgements: { + epg: 0, + lpg: 0, + egr: 0, + lgr: 0, + egd: 0, + lgd: 0, + ebd: 0, + lbd: 0, + epr: 0, + lpr: 0, + cb: 0, + fast: 0, + slow: 0, + notesPlayed: 0, + }, + }, + }), + { scoreData: data }, + { + arrayMerge: (_a, b) => b, + }, + ); + + it("parses valid scores and rejects invalid payloads", () => { + assertSuccess(TestingLR2HookScore); + assertSuccess(dm({ unexpectedField: "foo" })); + assertSuccess(dm({ scoreData: { unexpectedField: "foo" } })); + assertSuccess(dm({ playerData: { unexpectedField: "foo" } })); + + assertFail(dm({ playerData: { autoScr: true } })); + assertFail(dm({ playerData: { random: "H-RAN" } })); + assertFail(dm({ playerData: { random: "ALLSCR" } })); + + assertFail({}); + + assertFail(dm({ playerData: { rseed: "0" } })); + assertFail(dm({ playerData: { rseed: 0.5 } })); + + assertFail(dm({ unixTimestamp: "foo" })); + assertFail(dm({ unixTimestamp: 0.5 })); + + for (const key of [ + "pgreat", + "good", + "bad", + "poor", + "great", + "maxCombo", + "exScore", + "notesTotal", + "notesPlayed", + ]) { + assertFail(dms({ [key]: -1 })); + assertFail(dms({ [key]: 0.5 })); + assertFail(dms({ [key]: "0" })); + assertFail(dms({ [key]: null })); + } + + assertFail(dms({ lamp: "UNKNOWN_LAMP" })); + assertFail(dms({ lamp: null })); + assertFail(dms({ lamp: undefined })); + + for (const key of [ + "epg", + "lpg", + "egr", + "lgr", + "egd", + "lgd", + "ebd", + "lbd", + "epr", + "lpr", + "cb", + "fast", + "slow", + "notesPlayed", + ]) { + assertFail(dmse({ extendedJudgements: { [key]: -1 } })); + assertFail(dmse({ extendedJudgements: { [key]: 0.5 } })); + assertFail(dmse({ extendedJudgements: { [key]: "0" } })); + assertFail(dmse({ extendedJudgements: { [key]: null } })); + } + + assertFail(dms({ hpGraph: ApplyNTimes(999, () => 50) })); + assertFail(dms({ hpGraph: [] })); + assertFail(dms({ hpGraph: ApplyNTimes(1001, () => 50) })); + assertFail(dms({ hpGraph: ApplyNTimes(1000, () => 101) })); + assertFail(dms({ hpGraph: ApplyNTimes(1000, () => -1) })); + + assertFail( + dms({ + extendedHpGraphs: { + groove: [], + hard: ApplyNTimes(1000, () => 100), + hazard: ApplyNTimes(1000, () => 100), + easy: ApplyNTimes(1000, () => 100), + pattack: ApplyNTimes(1000, () => 100), + gattack: ApplyNTimes(1000, () => 100), + }, + }), + ); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts index a5b6da28e..120b1990b 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts @@ -1,10 +1,11 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; +import type { GamesForGroup } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError, optNull } from "#utils/prudence"; import { p, type PrudenceSchema } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { LR2HookContext, LR2HookScore } from "./types"; const SUPPORTED_RANDOMS = ["NORAN", "MIRROR", "RAN", "S-RAN"]; @@ -83,7 +84,7 @@ export const PR_LR2HOOK: PrudenceSchema = { export function ParseLR2Hook( body: Record, _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { // Ignore excess keys, as lr2hook is likely to add more features in the future. const err = p( body, @@ -107,7 +108,7 @@ export function ParseLR2Hook( const score = body as unknown as LR2HookScore; return { - game: "bms", + gameGroup: "bms", iterable: [score], context: { timeReceived: Date.now(), diff --git a/typescript/server/src/lib/score-import/import-types/ir/usc/converter.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/usc/converter.oldtest.ts deleted file mode 100644 index 9fc903cca..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/usc/converter.oldtest.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { USCClientScore } from "#server/router/ir/usc/_playtype/types"; - -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { TestingUSCChart, uscScore } from "#test-utils/test-data"; -import d from "deepmerge"; -import t from "tap"; - -import { ConverterIRUSC, DeriveLamp, DeriveNoteMod } from "./converter"; - -t.test("#DeriveLamp", (t) => { - t.equal(DeriveLamp(uscScore), "EXCESSIVE CLEAR"); - - t.equal(DeriveLamp(d(uscScore, { options: { gaugeType: 0 }, gauge: 0.5 })), "FAILED"); - - t.equal(DeriveLamp(d(uscScore, { options: { gaugeType: 0 }, gauge: 0.7 })), "CLEAR"); - - t.equal(DeriveLamp(d(uscScore, { options: { gaugeType: 2 }, gauge: 1 })), "FAILED"); - t.equal(DeriveLamp(d(uscScore, { options: { gaugeType: 2 }, gauge: 0 })), "FAILED"); - t.equal( - DeriveLamp(d(uscScore, { options: { gaugeType: 2 }, gauge: 1, score: 10_000_000 })), - "PERFECT ULTIMATE CHAIN", - ); - t.equal( - DeriveLamp(d(uscScore, { options: { gaugeType: 2 }, gauge: 1, error: 0 })), - "ULTIMATE CHAIN", - ); - - t.equal(DeriveLamp(d(uscScore, { options: { gaugeType: 1 }, gauge: 0.1 })), "EXCESSIVE CLEAR"); - - t.equal(DeriveLamp(d(uscScore, { options: { gaugeType: 1 }, gauge: 0 })), "FAILED"); - - t.equal( - DeriveLamp(d(uscScore, { score: 10_000_000, options: { gaugeType: 1 }, gauge: 0.1 })), - "PERFECT ULTIMATE CHAIN", - ); - - // error: 0 => ultimate chain should not take priority over score 10million - t.equal( - DeriveLamp( - d(uscScore, { - score: 10_000_000, - options: { gaugeType: 1 }, - gauge: 0.1, - error: 0, - }), - ), - "PERFECT ULTIMATE CHAIN", - ); - - t.equal( - DeriveLamp( - d(uscScore, { - score: 9_000_000, - options: { gaugeType: 0 }, - gauge: 0.15, - error: 0, - }), - ), - "ULTIMATE CHAIN", - ); - - t.throws(() => DeriveLamp(d(uscScore, { options: { gaugeType: 3 } }))); - - t.end(); -}); - -t.test("#DeriveNoteMod", (t) => { - t.equal(DeriveNoteMod(uscScore), "MIRROR"); - - t.equal(DeriveNoteMod(d(uscScore, { options: { random: true } })), "MIR-RAN"); - - t.equal(DeriveNoteMod(d(uscScore, { options: { random: false, mirror: false } })), "NORMAL"); - t.equal(DeriveNoteMod(d(uscScore, { options: { random: true, mirror: false } })), "RANDOM"); - - t.end(); -}); - -const dm = (p: Partial) => - ConverterIRUSC( - d(uscScore, p), - { - chartHash: TestingUSCChart.data.hashSHA1 as string, - playtype: "Controller", - timeReceived: 10, - }, - "ir/usc", - log, - ); - -t.test("#ConverterIRUSC", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should convert a score", async (t) => { - const res = await dm({}); - - t.hasStrict(res, { - song: { - id: 1, - }, - chart: { - chartID: TestingUSCChart.chartID, - }, - dryScore: {}, - }); - - t.end(); - }); - - t.test("Should reject scores with invalid hit windows", (t) => { - t.rejects(dm({ windows: { perfect: 0 } } as USCClientScore)); - t.rejects(dm({ windows: { good: 0 } } as USCClientScore)); - t.rejects(dm({ windows: { hold: 0 } } as USCClientScore)); - t.rejects(dm({ windows: { miss: 0 } } as USCClientScore)); - t.rejects(dm({ windows: { slam: 0 } } as USCClientScore)); - - t.end(); - }); - - t.test("Should reject scores with invalid autoflags", (t) => { - t.rejects(dm({ options: { autoFlags: 1 } } as USCClientScore)); - - t.end(); - }); - - t.test("Should throw an InternalFailure on song-chart desync", async (t) => { - await MONGODB_KILL.songs.usc.remove({}); - t.rejects(dm({})); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/usc/converter.test.ts b/typescript/server/src/lib/score-import/import-types/ir/usc/converter.test.ts new file mode 100644 index 000000000..6bd7c027b --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/usc/converter.test.ts @@ -0,0 +1,195 @@ +import type { USCClientScore } from "#server/router/ir/usc/_playtype/types"; + +import { log } from "#lib/log/log"; +import { InvalidScoreFailure } from "#lib/score-import/framework/common/converter-failures"; +import DB from "#services/pg/db"; +import { TestingUSCChart, TestingUSCSong, uscScore } from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ConverterIRUSC, DeriveLamp, DeriveNoteMod } from "./converter"; + +const USC_CTRL_CHART_ID = "usc_ir_test_chart_controller"; + +async function seedUscControllerChart() { + await DB.insertInto("song") + .values({ + id: TestingUSCSong.id, + legacy_id: 1, + game_group: "usc", + title: TestingUSCSong.title, + artist: TestingUSCSong.artist, + search_terms: TestingUSCSong.searchTerms, + alt_titles: TestingUSCSong.altTitles, + data: TestingUSCSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: USC_CTRL_CHART_ID, + legacy_id: USC_CTRL_CHART_ID, + game: "usc-controller", + song_id: TestingUSCSong.id, + difficulty: TestingUSCChart.difficulty, + level: TestingUSCChart.level, + level_num: TestingUSCChart.levelNum, + is_primary: TestingUSCChart.isPrimary, + versions: TestingUSCChart.versions, + data: TestingUSCChart.data, + }) + .execute(); +} + +const d = (base: T, patch: Partial): T => deepmerge(base, patch); + +describe("DeriveLamp", () => { + it("maps USC gauge and score rules", () => { + expect(DeriveLamp(uscScore)).toBe("EXCESSIVE CLEAR"); + + expect( + DeriveLamp(d(uscScore, { options: { ...uscScore.options, gaugeType: 0 }, gauge: 0.5 })), + ).toBe("FAILED"); + expect( + DeriveLamp(d(uscScore, { options: { ...uscScore.options, gaugeType: 0 }, gauge: 0.7 })), + ).toBe("CLEAR"); + + expect( + DeriveLamp(d(uscScore, { options: { ...uscScore.options, gaugeType: 2 }, gauge: 1 })), + ).toBe("FAILED"); + expect( + DeriveLamp(d(uscScore, { options: { ...uscScore.options, gaugeType: 2 }, gauge: 0 })), + ).toBe("FAILED"); + expect( + DeriveLamp( + d(uscScore, { + options: { ...uscScore.options, gaugeType: 2 }, + gauge: 1, + score: 10_000_000, + }), + ), + ).toBe("PERFECT ULTIMATE CHAIN"); + expect( + DeriveLamp( + d(uscScore, { options: { ...uscScore.options, gaugeType: 2 }, gauge: 1, error: 0 }), + ), + ).toBe("ULTIMATE CHAIN"); + + expect( + DeriveLamp(d(uscScore, { options: { ...uscScore.options, gaugeType: 1 }, gauge: 0.1 })), + ).toBe("EXCESSIVE CLEAR"); + expect( + DeriveLamp(d(uscScore, { options: { ...uscScore.options, gaugeType: 1 }, gauge: 0 })), + ).toBe("FAILED"); + + expect( + DeriveLamp( + d(uscScore, { + score: 10_000_000, + options: { ...uscScore.options, gaugeType: 1 }, + gauge: 0.1, + }), + ), + ).toBe("PERFECT ULTIMATE CHAIN"); + + expect( + DeriveLamp( + d(uscScore, { + score: 10_000_000, + options: { ...uscScore.options, gaugeType: 1 }, + gauge: 0.1, + error: 0, + }), + ), + ).toBe("PERFECT ULTIMATE CHAIN"); + + expect( + DeriveLamp( + d(uscScore, { + score: 9_000_000, + options: { ...uscScore.options, gaugeType: 0 }, + gauge: 0.15, + error: 0, + }), + ), + ).toBe("ULTIMATE CHAIN"); + + expect(() => + DeriveLamp( + d(uscScore, { + options: { ...uscScore.options, gaugeType: 3 as never }, + }) as unknown as USCClientScore, + ), + ).toThrow(/Invalid gaugeType/u); + }); +}); + +describe("DeriveNoteMod", () => { + it("maps mirror / random flags", () => { + expect(DeriveNoteMod(uscScore)).toBe("MIRROR"); + expect(DeriveNoteMod(d(uscScore, { options: { ...uscScore.options, random: true } }))).toBe( + "MIR-RAN", + ); + expect( + DeriveNoteMod( + d(uscScore, { options: { ...uscScore.options, random: false, mirror: false } }), + ), + ).toBe("NORMAL"); + expect( + DeriveNoteMod( + d(uscScore, { options: { ...uscScore.options, random: true, mirror: false } }), + ), + ).toBe("RANDOM"); + }); +}); + +describe("ConverterIRUSC", () => { + beforeEach(seedUscControllerChart); + + const dm = (p: Partial) => + ConverterIRUSC( + d(uscScore, p), + { + chartHash: TestingUSCChart.data.hashSHA1 as string, + playtype: "Controller", + timeReceived: 10, + }, + "ir/usc", + log, + ); + + it("converts a score", async () => { + const res = await dm({}); + + expect(res.song.id).toBe(TestingUSCSong.id); + expect(res.chart.chartID).toBe(USC_CTRL_CHART_ID); + expect(res.dryScore.game).toBe("usc-controller"); + expect(res.dryScore.importType).toBe("ir/usc"); + expect((res.dryScore.scoreData as { score: number }).score).toBe(uscScore.score); + }); + + it("rejects modified hit windows", async () => { + await expect(dm({ windows: { perfect: 0 } } as USCClientScore)).rejects.toThrow( + InvalidScoreFailure, + ); + await expect(dm({ windows: { good: 0 } } as USCClientScore)).rejects.toThrow( + InvalidScoreFailure, + ); + await expect(dm({ windows: { hold: 0 } } as USCClientScore)).rejects.toThrow( + InvalidScoreFailure, + ); + await expect(dm({ windows: { miss: 0 } } as USCClientScore)).rejects.toThrow( + InvalidScoreFailure, + ); + await expect(dm({ windows: { slam: 0 } } as USCClientScore)).rejects.toThrow( + InvalidScoreFailure, + ); + }); + + it("rejects non-zero autoFlags", async () => { + await expect(dm({ options: { autoFlags: 1 } } as USCClientScore)).rejects.toThrow( + InvalidScoreFailure, + ); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/usc/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/usc/converter.ts index 48e43d739..486c71ce3 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/usc/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/usc/converter.ts @@ -1,3 +1,5 @@ +import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { ConverterFunction } from "#lib/score-import/import-types/common/types"; import type { USCClientScore } from "#server/router/ir/usc/_playtype/types"; import type { GetEnumValue } from "tachi-common/types/metrics"; @@ -8,18 +10,16 @@ import { USC_DEFAULT_PERFECT, USC_DEFAULT_SLAM, } from "#lib/constants/usc-ir"; -import { FindUSCChartOnSHA1Playtype } from "#utils/queries/charts"; -import { FindSongOnID } from "#utils/queries/songs"; - -import type { DryScore } from "../../../framework/common/types"; -import type { ConverterFunction } from "../../common/types"; -import type { IRUSCContext } from "./types"; - import { InternalFailure, InvalidScoreFailure, SongOrChartNotFoundFailure, -} from "../../../framework/common/converter-failures"; +} from "#lib/score-import/framework/common/converter-failures"; +import { FindUSCChartOnSHA1 } from "#utils/queries/charts"; +import { FindSongOnID } from "#utils/queries/songs"; +import { type GamesForGroup } from "tachi-common"; + +import type { IRUSCContext } from "./types"; /** * Interprets the "note mod" used based on the USC score. @@ -39,9 +39,7 @@ export function DeriveNoteMod(data: USCClientScore): "MIR-RAN" | "MIRROR" | "NOR /** * Determines the lamp of a USC score. */ -export function DeriveLamp( - scoreDoc: USCClientScore, -): GetEnumValue<"usc:Controller" | "usc:Keyboard", "lamp"> { +export function DeriveLamp(scoreDoc: USCClientScore): GetEnumValue { if (scoreDoc.score === 10_000_000) { return "PERFECT ULTIMATE CHAIN"; } else if (scoreDoc.error === 0) { @@ -101,7 +99,9 @@ export const ConverterIRUSC: ConverterFunction = a throw new InvalidScoreFailure(`Autoplay was enabled - Score is invalid.`); } - const chartDoc = await FindUSCChartOnSHA1Playtype(context.chartHash, context.playtype); + const game = context.playtype === "Controller" ? "usc-controller" : "usc-keyboard"; + + const chartDoc = await FindUSCChartOnSHA1(context.chartHash, game); if (!chartDoc) { throw new SongOrChartNotFoundFailure( @@ -112,18 +112,18 @@ export const ConverterIRUSC: ConverterFunction = a ); } - const song = await FindSongOnID("usc", chartDoc.songID); + const song = await FindSongOnID("usc", chartDoc.song.id); if (!song) { - log.error(`Song-Chart desync on USCIR ${chartDoc.songID}.`); - throw new InternalFailure(`Song-Chart desync on USCIR ${chartDoc.songID}.`); + log.error(`Song-Chart desync on USCIR ${chartDoc.song.id}.`); + throw new InternalFailure(`Song-Chart desync on USCIR ${chartDoc.song.id}.`); } const lamp = DeriveLamp(data); - const dryScore: DryScore<"usc:Controller" | "usc:Keyboard"> = { + const dryScore: DryScore = { comment: null, - game: "usc", + game, importType, timeAchieved: context.timeReceived, service: "USC-IR", diff --git a/typescript/server/src/lib/score-import/import-types/ir/usc/parser.oldtest.ts b/typescript/server/src/lib/score-import/import-types/ir/usc/parser.oldtest.ts deleted file mode 100644 index 0c4da05f6..000000000 --- a/typescript/server/src/lib/score-import/import-types/ir/usc/parser.oldtest.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { log } from "#lib/log/log.js"; -import { TestingUSCChart, uscScore } from "#test-utils/test-data"; -import t from "tap"; - -import { ParseIRUSC } from "./parser"; - -t.test("#ParseIRUSC", (t) => { - t.test("Should validate and convert a score into an iterable", (t) => { - const res = ParseIRUSC( - { score: uscScore } as unknown as Record, - TestingUSCChart.data.hashSHA1 as string, - "Controller", - log, - ); - - t.hasStrict(res, { - game: "usc", - context: { - chartHash: TestingUSCChart.data.hashSHA1, - }, - iterable: [uscScore], - }); - - t.end(); - }); - - t.test("Should reject empty bodies", (t) => { - t.throws(() => ParseIRUSC({}, TestingUSCChart.data.hashSHA1 as string, "Controller", log), { - statusCode: 400, - message: /invalid usc score/iu, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/usc/parser.test.ts b/typescript/server/src/lib/score-import/import-types/ir/usc/parser.test.ts new file mode 100644 index 000000000..6f6fd24bc --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/ir/usc/parser.test.ts @@ -0,0 +1,37 @@ +import { log } from "#lib/log/log"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; +import { TestingUSCChart, uscScore } from "#test-utils/test-data"; +import { describe, expect, it } from "vitest"; + +import { ParseIRUSC } from "./parser"; + +describe("ParseIRUSC", () => { + it("validates and wraps a score in an iterable", () => { + const res = ParseIRUSC( + { score: uscScore } as unknown as Record, + TestingUSCChart.data.hashSHA1 as string, + "Keyboard", + log, + ); + + expect(res).toMatchObject({ + gameGroup: "usc", + context: { + chartHash: TestingUSCChart.data.hashSHA1, + playtype: "Keyboard", + }, + iterable: [uscScore], + }); + expect(typeof res.context.timeReceived).toBe("number"); + }); + + it("rejects empty bodies", () => { + expect(() => + ParseIRUSC({}, TestingUSCChart.data.hashSHA1 as string, "Keyboard", log), + ).toThrow(ScoreImportFatalError); + + expect(() => + ParseIRUSC({}, TestingUSCChart.data.hashSHA1 as string, "Keyboard", log), + ).toThrow(/Invalid USC Score/iu); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/ir/usc/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/usc/parser.ts index f94bf9b2b..e5734db75 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/usc/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/usc/parser.ts @@ -1,15 +1,14 @@ import type { KtLogger } from "#lib/log/log"; +import type { ParserFunctionReturns } from "#lib/score-import/import-types/common/types"; import type { USCClientScore } from "#server/router/ir/usc/_playtype/types"; -import type { Playtypes } from "tachi-common"; +import type { GamesForGroup, LEGACY_Playtypes } from "tachi-common"; +import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError } from "#utils/prudence"; import { p, type PrudenceSchema } from "prudence"; -import type { ParserFunctionReturns } from "../../common/types"; import type { IRUSCContext } from "./types"; -import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; - const PR_USCIR_SCORE: PrudenceSchema = { score: p.isBoundedInteger(0, 10_000_000), gauge: p.isBetween(0, 1), @@ -38,9 +37,9 @@ const PR_USCIR_SCORE: PrudenceSchema = { export function ParseIRUSC( body: Record, chartHash: string, - playtype: Playtypes["usc"], + playtype: LEGACY_Playtypes["usc"], _log: KtLogger, -): ParserFunctionReturns { +): ParserFunctionReturns { const err = p( body.score, PR_USCIR_SCORE, @@ -67,7 +66,7 @@ export function ParseIRUSC( playtype, timeReceived: Date.now(), }, - game: "usc", + gameGroup: "usc", iterable: [score] as Array, classProvider: null, }; diff --git a/typescript/server/src/lib/score-import/import-types/ir/usc/types.ts b/typescript/server/src/lib/score-import/import-types/ir/usc/types.ts index b95483c74..b8d70e502 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/usc/types.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/usc/types.ts @@ -1,7 +1,8 @@ -import type { Playtypes } from "tachi-common"; +import type { LEGACY_Playtypes } from "tachi-common"; export interface IRUSCContext { chartHash: string; - playtype: Playtypes["usc"]; + // This has to stay here - for orphan backwards compatibility... + playtype: LEGACY_Playtypes["usc"]; timeReceived: number; } diff --git a/typescript/server/src/lib/score-import/worker/types.ts b/typescript/server/src/lib/score-import/worker/types.ts index 9b3d2ee0f..8856d86a0 100644 --- a/typescript/server/src/lib/score-import/worker/types.ts +++ b/typescript/server/src/lib/score-import/worker/types.ts @@ -1,5 +1,5 @@ import type { Job } from "bullmq"; -import type { ImportTypes, integer, MONGO_ImportDocument } from "tachi-common"; +import type { ImportDocument, ImportTypes, integer } from "tachi-common"; import type { Parsers } from "../import-types/parsers"; @@ -50,6 +50,6 @@ export type ScoreImportWorkerReturns = success: false; } | { - MONGO_ImportDocument: MONGO_ImportDocument; + ImportDocument: ImportDocument; success: true; }; diff --git a/typescript/server/src/lib/score-import/worker/worker.ts b/typescript/server/src/lib/score-import/worker/worker.ts index ac382e564..9d833b6ce 100644 --- a/typescript/server/src/lib/score-import/worker/worker.ts +++ b/typescript/server/src/lib/score-import/worker/worker.ts @@ -86,7 +86,7 @@ export const worker = new Worker( }); try { - const MONGO_ImportDocument = await ScoreImportMain( + const ImportDocument = await ScoreImportMain( user.id, job.data.userIntent, job.data.importType, @@ -98,7 +98,7 @@ export const worker = new Worker( log.debug(`Finished import.`); - return { success: true, MONGO_ImportDocument }; + return { success: true, ImportDocument }; } catch (e) { const err = e as Error | ScoreImportFatalError; diff --git a/typescript/server/src/lib/score-mutation/delete-scores.ts b/typescript/server/src/lib/score-mutation/delete-scores.ts index 7ba7f92c7..5a1f3818d 100644 --- a/typescript/server/src/lib/score-mutation/delete-scores.ts +++ b/typescript/server/src/lib/score-mutation/delete-scores.ts @@ -12,22 +12,15 @@ import { ProcessPBs } from "#lib/score-import/framework/pb/process-pbs"; import { UpdateUsersQuests } from "#lib/score-import/framework/quests/quests"; import { UpdateUsersGamePlaytypeStats } from "#lib/score-import/framework/ugpt-stats/update-ugpt-stats"; import DB from "#services/pg/db"; -import { - type GameGroup, - GetGPTString, - type MONGO_ScoreDocument, - type Playtype, - type V3Game, - V3ToGamePT, -} from "tachi-common"; +import { type ScoreDocument } from "tachi-common"; /* eslint-disable no-await-in-loop */ -export function DeleteScore(score: MONGO_ScoreDocument, blacklist = false): Promise { +export function DeleteScore(score: ScoreDocument, blacklist = false): Promise { return DeleteMultipleScores([score], blacklist); } export async function DeleteMultipleScores( - scores: Array, + scores: Array, blacklist = false, ): Promise { if (scores.length === 0) { @@ -80,9 +73,7 @@ export async function DeleteMultipleScores( } const scoreDocs = remainingRows.map((r) => ToScoreDocument(r as ScoreDocumentJoinRow)); - const { game, playtype } = V3ToGamePT(sessionRow.game as V3Game); - const gpt = GetGPTString(game, playtype); - const calculatedData = CreateSessionCalcData(gpt, scoreDocs); + const calculatedData = CreateSessionCalcData(sessionRow.game, scoreDocs); await DB.updateTable("session") .set({ @@ -94,7 +85,8 @@ export async function DeleteMultipleScores( } for (const score of scores) { - await ProcessPBs(score.game, score.playtype, score.userID, new Set([score.chartID]), log); + const v3Game = score.game; + await ProcessPBs(v3Game, score.userID, new Set([score.chartID]), log); await clearPbDirtyForUser(score.userID, [score.chartID]); if (blacklist) { @@ -116,24 +108,30 @@ export async function DeleteMultipleScores( } } - const ugpts = [...new Set(scores.map((e) => `${e.game}-${e.playtype}-${e.userID}`))]; + const userGamePairs = [ + ...new Map(scores.map((s) => [`${s.game}\x1f${s.userID}`, s] as const)).values(), + ]; - for (const ugpt of ugpts) { - const [game, playtype, strUserID] = ugpt.split("-") as [GameGroup, Playtype, string]; - - const userID = Number(strUserID); + for (const sample of userGamePairs) { + const game = sample.game; + const userID = sample.userID; const pertinentChartIDs = scores - .filter((e) => e.game === game && e.playtype === playtype && e.userID === userID) + .filter((e) => e.game === game && e.userID === userID) .map((e) => e.chartID); if (pertinentChartIDs.length > 0) { - const goalInfo = await GetAndUpdateUsersGoals(game, userID, new Set(chartIDs), log); + const goalInfo = await GetAndUpdateUsersGoals( + game, + userID, + new Set(pertinentChartIDs), + log, + ); - await UpdateUsersQuests(goalInfo, game, [playtype], userID, log); + await UpdateUsersQuests(goalInfo, game, userID, log); } - await UpdateUsersGamePlaytypeStats(game, playtype, userID, null, log); + await UpdateUsersGamePlaytypeStats(game, userID, null, log); } log.info(`Finished deleting ${scores.length} scores (Postgres).`); diff --git a/typescript/server/src/lib/search/folders.ts b/typescript/server/src/lib/search/folders.ts index 7f43dc6b6..69a534586 100644 --- a/typescript/server/src/lib/search/folders.ts +++ b/typescript/server/src/lib/search/folders.ts @@ -1,39 +1,18 @@ import type { Game } from "tachi-db"; -import { LoadFolderDocumentsByIds } from "#lib/db-formats/folders.js"; -import { SHORT_QUERY_LEN, SHORT_QUERY_STRICT_MAX_LEN } from "#lib/search/songs.js"; +import { LoadFolderDocumentsByIds } from "#lib/db-formats/folders"; +import { SHORT_QUERY_LEN, SHORT_QUERY_STRICT_MAX_LEN } from "#lib/search/songs"; +import { AllEnabledGames } from "#lib/setup/config"; import DB from "#services/pg/db"; import { EscapeForILIKE } from "#utils/misc"; import { sql } from "kysely"; -import { - type GameGroup, - GamePTToV3, - type integer, - type MONGO_FolderDocument, - type Playtype, - v3AllGames, - V3ToGamePT, -} from "tachi-common"; +import { type FolderDocument, type integer, type V3Game } from "tachi-common"; type FolderSearchRow = { id: string; rank: number; }; -function mongoStyleGamePlaytypeFilterToV3Games( - games: GameGroup[], - playtypes: Playtype[], -): Array { - if (games.length === 0 || playtypes.length === 0) { - return []; - } - - return v3AllGames.filter((v) => { - const { game, playtype } = V3ToGamePT(v); - return games.includes(game) && playtypes.includes(playtype); - }) as Array; -} - function gameFilterSql(allowedV3Games: Array | null) { if (allowedV3Games === null) { return sql``; @@ -48,13 +27,13 @@ function gameFilterSql(allowedV3Games: Array | null) { async function finalizeFolderSearchRows( rows: Array, -): Promise> { +): Promise> { if (rows.length === 0) { return []; } const byIdMap = await LoadFolderDocumentsByIds(rows.map((r) => r.id)); - const out: Array<{ __textScore: number } & MONGO_FolderDocument> = []; + const out: Array<{ __textScore: number } & FolderDocument> = []; for (const r of rows) { const doc = byIdMap.get(r.id); @@ -70,11 +49,11 @@ async function finalizeFolderSearchRows( async function searchFoldersFtsAndTrgmCore( search: string, opts: { - allowedV3Games: Array | null; + games: Array | null; limit: integer; onlyActiveFolders: boolean; }, -): Promise> { +): Promise> { const q = search.trim(); if (q.length === 0) { @@ -83,7 +62,7 @@ async function searchFoldersFtsAndTrgmCore( const cap = Math.min(Math.max(1, opts.limit), 500); const inactiveSql = opts.onlyActiveFolders ? sql`AND f.inactive = false` : sql``; - const v3FilterSql = gameFilterSql(opts.allowedV3Games); + const v3FilterSql = gameFilterSql(opts.games); const folderTsvec = sql`( setweight(to_tsvector('simple', coalesce(f.title, '')), 'A') || @@ -234,25 +213,19 @@ async function searchFoldersFtsAndTrgmCore( * * Inactive folders are included (legacy global search behavior). */ -export async function SearchFoldersFtsAndTrgmGlobal( +export function SearchFoldersFtsAndTrgmGlobal( search: string, opts: { - games?: GameGroup[]; + games?: Array; limit?: integer; - playtypes?: Playtype[]; } = {}, -): Promise> { +): Promise> { const limit = opts.limit ?? 500; - let allowedV3Games: Array | null = null; - - if (opts.games !== undefined && opts.playtypes !== undefined) { - allowedV3Games = mongoStyleGamePlaytypeFilterToV3Games(opts.games, opts.playtypes); - } return searchFoldersFtsAndTrgmCore(search, { limit, onlyActiveFolders: false, - allowedV3Games, + games: opts.games ?? AllEnabledGames(), }); } @@ -262,17 +235,14 @@ export async function SearchFoldersFtsAndTrgmGlobal( * * @param onlyActiveFolders - When true, exclude rows with `inactive = true`. When false, include all. */ -export async function SearchFoldersForGameFtsAndTrgm( - game: GameGroup, - playtype: Playtype, +export function SearchFoldersForGameFtsAndTrgm( + game: V3Game, search: string, opts: { limit: integer; onlyActiveFolders: boolean }, -): Promise> { - const v3Game = GamePTToV3(game, playtype) as Game; - +): Promise> { return searchFoldersFtsAndTrgmCore(search, { limit: opts.limit, onlyActiveFolders: opts.onlyActiveFolders, - allowedV3Games: [v3Game], + games: [game], }); } diff --git a/typescript/server/src/lib/search/search.ts b/typescript/server/src/lib/search/search.ts index b6b3d4a1c..172317205 100644 --- a/typescript/server/src/lib/search/search.ts +++ b/typescript/server/src/lib/search/search.ts @@ -1,5 +1,3 @@ -import type { FilterQuery } from "mongodb"; - import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; import { TachiConfig } from "#lib/setup/config"; import DB from "#services/pg/db"; @@ -14,28 +12,30 @@ import { import { UnixMillisecondsToISO8601 } from "#utils/time"; import { GetOnlineCutoff } from "#utils/user"; import { + type ChartDocument, + type FolderDocument, type GameGroup, - GamePTToV3, - type GPTStrings, type integer, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, - type Playtype, + LEGACY_GameGroupPTToGame, + LEGACY_GameToGameGroupPT, + type LEGACY_GPTStrings, + type LEGACY_Playtype, + type SongDocument, + type UserDocument, + type V3Game, } from "tachi-common"; import { type Game } from "tachi-db"; -import { SearchFoldersFtsAndTrgmGlobal } from "./folders.js"; -import { type SearchSessionHit, SearchSessionsForUserGptFtsAndTrgm } from "./session-search.js"; -import { SearchSpecificGameSongs, type SongSearchReturn } from "./songs.js"; +import { SearchFoldersFtsAndTrgmGlobal } from "./folders"; +import { type SearchSessionHit, SearchSessionsForUserGptFtsAndTrgm } from "./session-search"; +import { SearchSpecificGameSongs, type SongSearchReturn } from "./songs"; -export type { SearchSessionHit } from "./session-search.js"; +export type { SearchSessionHit } from "./session-search"; export function SearchSessions( search: string, game?: GameGroup, - playtype?: Playtype, + playtype?: LEGACY_Playtype, userID?: integer, limit = 100, ): Promise> { @@ -43,7 +43,7 @@ export function SearchSessions( return Promise.resolve([]); } - const v3Game = GamePTToV3(game, playtype) as Game; + const v3Game = LEGACY_GameGroupPTToGame(game, playtype) as Game; return SearchSessionsForUserGptFtsAndTrgm(userID, v3Game, search, limit); } @@ -59,7 +59,7 @@ export function SearchSessions( export function SearchUsersRegExp( search: string, matchOnline = false, -): Promise> { +): Promise> { const likeEsc = EscapeForILIKE(search.toLowerCase()); const onlineCutoff = UnixMillisecondsToISO8601(GetOnlineCutoff()); @@ -102,7 +102,7 @@ async function SearchAllGamesSingleGame(game: GameGroup, search: string) { export async function SearchGamesSongs(search: string, games: Array | null) { const promises = []; - for (const game of games ?? TachiConfig.GAMES) { + for (const game of games ?? TachiConfig.GAME_GROUPS) { promises.push(SearchAllGamesSingleGame(game, search)); } @@ -120,11 +120,11 @@ export async function SearchForChartHash(search: string) { ]); const output: Record< - GPTStrings["bms" | "itg" | "pms" | "usc"], + LEGACY_GPTStrings["bms" | "itg" | "pms" | "usc"], Array<{ - chart: MONGO_ChartDocument; + chart: ChartDocument; playcount: null; - song: MONGO_SongDocument; + song: SongDocument; }> > = { "bms:7K": [], @@ -136,24 +136,37 @@ export async function SearchForChartHash(search: string) { "itg:Stamina": [], }; - const zip = [ - ["bms", bmsCharts], - ["pms", pmsCharts], - ["itg", itgCharts], - ["usc", uscCharts], - ] as const; + const push = async (chart: ChartDocument) => { + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(chart.game); + const key = `${gameGroup}:${playtype}` as keyof typeof output; - for (const [game, charts] of zip) { - for (const chart of charts as Array) { - const song = await GetSongForIDGuaranteed(game, chart.songID); - - // @ts-expect-error ts doesn't like this hack but it'll work. - output[`${game}:${chart.playtype}`]!.push({ - song, - chart, - playcount: null, - }); + if (!(key in output)) { + return; } + + const song = await GetSongForIDGuaranteed(chart.song.id); + + output[key].push({ + song, + chart, + playcount: null, + }); + }; + + for (const chart of bmsCharts) { + await push(chart); + } + + for (const chart of pmsCharts) { + await push(chart); + } + + for (const chart of itgCharts) { + await push(chart); + } + + for (const chart of uscCharts) { + await push(chart); } return output; @@ -161,17 +174,13 @@ export async function SearchForChartHash(search: string) { export function SearchFolders( search: string, - existingMatch?: FilterQuery, + games: Array | null = null, limit = 500, -): Promise> { - const gameIn = existingMatch?.game as { $in?: Array } | undefined; - const playtypeIn = existingMatch?.playtype as { $in?: Array } | undefined; - - if (gameIn?.$in !== undefined && playtypeIn?.$in !== undefined) { +): Promise> { + if (games !== null) { return SearchFoldersFtsAndTrgmGlobal(search, { limit, - games: gameIn.$in, - playtypes: playtypeIn.$in, + games, }); } diff --git a/typescript/server/src/lib/search/session-search.test.ts b/typescript/server/src/lib/search/session-search.test.ts index 216527ec4..fefc8ce96 100644 --- a/typescript/server/src/lib/search/session-search.test.ts +++ b/typescript/server/src/lib/search/session-search.test.ts @@ -2,7 +2,7 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { describe, expect, it } from "vitest"; -import { SearchSessionsForUserGptFtsAndTrgm } from "./session-search.js"; +import { SearchSessionsForUserGptFtsAndTrgm } from "./session-search"; describe("SearchSessionsForUserGptFtsAndTrgm", () => { it("returns [] for empty or whitespace query", async () => { diff --git a/typescript/server/src/lib/search/session-search.ts b/typescript/server/src/lib/search/session-search.ts index 4c3ecaa66..1f488b36d 100644 --- a/typescript/server/src/lib/search/session-search.ts +++ b/typescript/server/src/lib/search/session-search.ts @@ -3,15 +3,15 @@ import DB from "#services/pg/db"; import { EscapeForILIKE } from "#utils/misc"; import { GetScoreIdsGroupedBySessionId } from "#utils/queries/sessions"; import { type Selection, sql } from "kysely"; -import { type integer, type MONGO_SessionDocument } from "tachi-common"; +import { type integer, type SessionDocument } from "tachi-common"; import { type Database, type Game } from "tachi-db"; -import { SHORT_QUERY_LEN, SHORT_QUERY_STRICT_MAX_LEN } from "./songs.js"; +import { SHORT_QUERY_LEN, SHORT_QUERY_STRICT_MAX_LEN } from "./songs"; /** One ranked hit from session search (Postgres FTS + trgm). */ export type SearchSessionHit = { rank: number; - session: MONGO_SessionDocument; + session: SessionDocument; }; /** @@ -50,7 +50,7 @@ async function finalizeHits( * short-query exact match, then pg_trgm / ILIKE — same strategy as * {@link SearchSongsForGameFtsAndTrgm} / {@link SearchFoldersForGameFtsAndTrgm}. * - * Returns full {@link MONGO_SessionDocument}s (no follow-up `WHERE id IN (...)` on `session`). + * Returns full {@link SessionDocument}s (no follow-up `WHERE id IN (...)` on `session`). */ export async function SearchSessionsForUserGptFtsAndTrgm( userId: integer, diff --git a/typescript/server/src/lib/search/song-charts.ts b/typescript/server/src/lib/search/song-charts.ts index ed5579400..d61c12bbe 100644 --- a/typescript/server/src/lib/search/song-charts.ts +++ b/typescript/server/src/lib/search/song-charts.ts @@ -1,46 +1,27 @@ import { GetChartsBySongId } from "#lib/db-formats/chart"; +import { AllEnabledGames } from "#lib/setup/config"; import DB from "#services/pg/db"; import { sql } from "kysely"; import { - type GameGroup, - GamePTToV3, - type GPTString, + type ChartDocument, + GameToGameGroup, type integer, - type MONGO_ChartDocument, - type MONGO_SongDocument, - MongoChartLegacyId, - type Playtype, - SplitGPT, + type SongDocument, + type V3Game, } from "tachi-common"; -import { MAX_SONG_SEARCH_RESULTS_PER_GAME, searchSpecificGameSongsWithPgIds } from "./songs.js"; +import { MAX_SONG_SEARCH_RESULTS_PER_GAME, searchSpecificGameSongs } from "./songs"; -export async function SearchSpecificGameSongsAndCharts( - game: GameGroup, - search: string, - playtype?: Playtype, - limit = 100, -) { - const { songs, pgIdByLegacyId } = await searchSpecificGameSongsWithPgIds(game, search, limit); - - if (!playtype) { - throw new Error("SearchSpecificGameSongsAndCharts requires playtype"); - } - - const v3Game = GamePTToV3(game, playtype); +export async function SearchSpecificGameSongsAndCharts(game: V3Game, search: string, limit = 100) { + const { songs } = await searchSpecificGameSongs(GameToGameGroup(game), search, limit); + // TODO(zk): this is ridiculously slow. We can do a fucking join. const chartLists = await Promise.all( - songs.map((song) => { - const songID = pgIdByLegacyId.get(song.id); - - if (!songID) { - return Promise.resolve([] as Array); - } - - return GetChartsBySongId(v3Game, songID, { - omit2dxtraCharts: game === "iidx", - }); - }), + songs.map((song) => + GetChartsBySongId(game, song.id, { + omit2dxtraCharts: GameToGameGroup(game) === "iidx", + }), + ), ); const charts = chartLists.flat(); @@ -55,27 +36,16 @@ export async function SearchSpecificGameSongsAndCharts( * N matched songs × charts per song could return a huge payload from `/api/v1/search`. */ export async function SearchGlobalGameSongsAndCharts( - game: GameGroup, + game: V3Game, search: string, - playtype?: Playtype, limit = MAX_SONG_SEARCH_RESULTS_PER_GAME, -): Promise> { - const { songs, pgIdByLegacyId: songIdByLegacyId } = await searchSpecificGameSongsWithPgIds( - game, - search, - limit, - ); - - if (!playtype) { - throw new Error("SearchGlobalGameSongsAndCharts requires playtype"); - } - - const v3Game = GamePTToV3(game, playtype); +): Promise> { + const { songs } = await searchSpecificGameSongs(GameToGameGroup(game), search, limit); const output: Array<{ - chart: MONGO_ChartDocument; + chart: ChartDocument; playcount: integer; - song: MONGO_SongDocument; + song: SongDocument; }> = []; for (const song of songs) { @@ -83,15 +53,9 @@ export async function SearchGlobalGameSongsAndCharts( break; } - const songID = songIdByLegacyId.get(song.id); - - if (!songID) { - continue; - } - // eslint-disable-next-line no-await-in-loop -- stop after enough charts; avoids loading every song's charts when the cap is already reached. - const songCharts = await GetChartsBySongId(v3Game, songID, { - omit2dxtraCharts: game === "iidx", + const songCharts = await GetChartsBySongId(game, song.id, { + omit2dxtraCharts: GameToGameGroup(game) === "iidx", }); for (const chart of songCharts) { @@ -111,42 +75,37 @@ export async function SearchGlobalGameSongsAndCharts( return []; } - const chartLegacyIds = output.map((o) => MongoChartLegacyId(o.chart)); + const chartIDs = output.map((o) => o.chart.chartID); const playcountRows = await DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") - .select(["chart.legacy_id", sql`count(score.id)::int`.as("playcount")]) - .where("chart.legacy_id", "in", chartLegacyIds) - .groupBy("chart.legacy_id") + .select(["chart.id", sql`count(score.id)::int`.as("playcount")]) + .where("chart.id", "in", chartIDs) + .groupBy("chart.id") .execute(); - const playcountLookup = Object.fromEntries( - playcountRows.map((r) => [r.legacy_id, r.playcount]), - ); + const playcountLookup = Object.fromEntries(playcountRows.map((r) => [r.id, r.playcount])); for (const row of output) { - row.playcount = playcountLookup[MongoChartLegacyId(row.chart)] ?? 0; + row.playcount = playcountLookup[row.chart.chartID] ?? 0; } return output; } -export async function SearchGamesSongsCharts(search: string, gpts: Array) { +export async function SearchGamesSongsCharts(search: string, games: Array | null) { + games = games ?? AllEnabledGames(); + const promises = []; const results: Partial< - Record< - GPTString, - Array<{ chart: MONGO_ChartDocument; playcount: integer; song: MONGO_SongDocument }> - > + Record> > = {}; - for (const gpt of gpts) { - const [game, playtype] = SplitGPT(gpt); - + for (const game of games) { promises.push( - SearchGlobalGameSongsAndCharts(game, search, playtype).then((res) => { - results[gpt] = res; + SearchGlobalGameSongsAndCharts(game, search).then((res) => { + results[game] = res; }), ); } diff --git a/typescript/server/src/lib/search/songs.bench.ts b/typescript/server/src/lib/search/songs.bench.ts index 520af91d5..ecbea6ab7 100644 --- a/typescript/server/src/lib/search/songs.bench.ts +++ b/typescript/server/src/lib/search/songs.bench.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { beforeAll, bench, describe } from "vitest"; -import { LoadSongChildrenForPgIds, SearchSongsForGameFtsAndTrgm } from "./songs.js"; +import { LoadSongChildrenForPgIds, SearchSongsForGameFtsAndTrgm } from "./songs"; /** Default: repo `db/seeds` (override with `SEEDS_DIR` for custom trees). */ const SEEDS_DIR = diff --git a/typescript/server/src/lib/search/songs.test.ts b/typescript/server/src/lib/search/songs.test.ts index 4057ba39e..c09c81bf7 100644 --- a/typescript/server/src/lib/search/songs.test.ts +++ b/typescript/server/src/lib/search/songs.test.ts @@ -1,11 +1,11 @@ import { GetChartsBySongId } from "#lib/db-formats/chart"; -import { SearchGlobalGameSongsAndCharts } from "#lib/search/song-charts.js"; +import { SearchGlobalGameSongsAndCharts } from "#lib/search/song-charts"; import DB from "#services/pg/db"; import { importSeedsSubset } from "#services/pg/seeds"; import { resolveSeedsDir, seedsJsonAvailable } from "#test-utils/seed-paths"; import { FindChartsOnPopularity } from "#utils/queries/charts"; import { sql } from "kysely"; -import { GamePTToV3 } from "tachi-common"; +import { LEGACY_GameGroupPTToGame } from "tachi-common"; import { describe, expect, it } from "vitest"; import { @@ -14,7 +14,7 @@ import { SearchSongsForGameFtsAndTrgm, SearchSpecificGameSongs, SHORT_QUERY_STRICT_MAX_LEN, -} from "./songs.js"; +} from "./songs"; function makeSongId(n: number): string { return `S${n.toString(16).padStart(20, "0")}`; @@ -569,8 +569,8 @@ describe("IIDX 2dxtraSet exclusion from search", () => { ]) .execute(); - const all = await GetChartsBySongId(GamePTToV3("iidx", "SP"), sid); - const filtered = await GetChartsBySongId(GamePTToV3("iidx", "SP"), sid, { + const all = await GetChartsBySongId(LEGACY_GameGroupPTToGame("iidx", "SP"), sid); + const filtered = await GetChartsBySongId(LEGACY_GameGroupPTToGame("iidx", "SP"), sid, { omit2dxtraCharts: true, }); @@ -625,12 +625,7 @@ describe("IIDX 2dxtraSet exclusion from search", () => { ]) .execute(); - const rows = await SearchGlobalGameSongsAndCharts( - "iidx", - "GlobalSearch2dxtraToken", - "SP", - 20, - ); + const rows = await SearchGlobalGameSongsAndCharts("iidx-sp", "GlobalSearch2dxtraToken", 20); expect(rows.length).toBeGreaterThan(0); expect( @@ -644,11 +639,11 @@ describe("IIDX 2dxtraSet exclusion from search", () => { }); it("FindChartsOnPopularity excludes 2dxtra charts for iidx", async () => { - const sid = makeSongId(890); + const songID = makeSongId(890); await DB.insertInto("song") .values({ - id: sid, + id: songID, legacy_id: 9_000_890, game_group: "iidx", title: "Pop2dxtraToken", @@ -666,7 +661,7 @@ describe("IIDX 2dxtraSet exclusion from search", () => { id: makeChartId(890), legacy_id: "1".repeat(40), game: "iidx-sp", - song_id: sid, + song_id: songID, level: "12", level_num: 12, is_primary: true, @@ -678,7 +673,7 @@ describe("IIDX 2dxtraSet exclusion from search", () => { id: makeChartId(891), legacy_id: "2".repeat(40), game: "iidx-sp", - song_id: sid, + song_id: songID, level: "11", level_num: 11, is_primary: false, @@ -689,7 +684,12 @@ describe("IIDX 2dxtraSet exclusion from search", () => { ]) .execute(); - const charts = await FindChartsOnPopularity("iidx", "SP", [9_000_890], 0, 100); + const charts = await FindChartsOnPopularity( + "iidx-sp", + { songIDs: [songID], chartIDs: undefined }, + 0, + 100, + ); expect(charts.length).toBe(1); expect(charts[0]?.difficulty).toBe("HYPER"); diff --git a/typescript/server/src/lib/search/songs.ts b/typescript/server/src/lib/search/songs.ts index 36571477e..c3ce56bd8 100644 --- a/typescript/server/src/lib/search/songs.ts +++ b/typescript/server/src/lib/search/songs.ts @@ -1,12 +1,7 @@ import DB from "#services/pg/db"; import { EscapeForILIKE } from "#utils/misc"; import { sql } from "kysely"; -import { - type GameGroup, - type integer, - type MONGO_SongDocument, - type SongDocumentData, -} from "tachi-common"; +import { type GameGroup, type SongDocument, type SongDocumentData } from "tachi-common"; /** Hard cap on song hits per `game_group` (FTS + trgm combined). */ export const MAX_SONG_SEARCH_RESULTS_PER_GAME = 100; @@ -255,47 +250,43 @@ export async function LoadSongChildrenForPgIds( export type SongSearchReturn = { __textScore: number; -} & MONGO_SongDocument; +} & SongDocument; /** - * Fuzzy song search over Postgres `song` metadata (same behaviour as legacy Mongo SearchCollection). + * Fuzzy song search over `song` metadata. */ -export async function searchSpecificGameSongsWithPgIds( - game: GameGroup, +export async function searchSpecificGameSongs( + gameGroup: GameGroup, search: string, limit = 100, ): Promise<{ - pgIdByLegacyId: Map; songs: Array; }> { - const rows = await SearchSongsForGameFtsAndTrgm(game, search, limit); + const rows = await SearchSongsForGameFtsAndTrgm(gameGroup, search, limit); if (rows.length === 0) { - return { songs: [], pgIdByLegacyId: new Map() }; + return { songs: [] }; } const children = await LoadSongChildrenForPgIds(rows.map((r) => r.id)); - const pgIdByLegacyId = new Map(); const songs: Array = []; for (const row of rows) { const ch = children.get(row.id); - pgIdByLegacyId.set(row.legacy_id, row.id); - songs.push({ - id: row.legacy_id, + id: row.id, title: row.title, artist: row.artist, searchTerms: ch?.searchTerms ?? [], altTitles: ch?.altTitles ?? [], - data: row.data as SongDocumentData[typeof game], + data: row.data as SongDocumentData[typeof gameGroup], __textScore: Math.round(1000 * row.rank), }); } - return { songs, pgIdByLegacyId }; + return { songs }; } export async function SearchSpecificGameSongs( @@ -303,7 +294,7 @@ export async function SearchSpecificGameSongs( search: string, limit = 100, ): Promise> { - const { songs } = await searchSpecificGameSongsWithPgIds(game, search, limit); + const { songs } = await searchSpecificGameSongs(game, search, limit); return songs; } diff --git a/typescript/server/src/lib/setup/build-email-config.test.ts b/typescript/server/src/lib/setup/build-email-config.test.ts new file mode 100644 index 000000000..2e421f506 --- /dev/null +++ b/typescript/server/src/lib/setup/build-email-config.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { buildEmailConfig } from "./config"; + +describe("buildEmailConfig", () => { + it("throws when TACHI_EMAIL_FROM is unset", () => { + expect(() => buildEmailConfig({})).toThrow(/TACHI_EMAIL_FROM is required/u); + }); + + it("builds Postmark transport when TACHI_EMAIL_AUTH_POSTMARK is true", () => { + const cfg = buildEmailConfig({ + TACHI_EMAIL_FROM: "from@example.com", + TACHI_EMAIL_AUTH_POSTMARK: "true", + TACHI_EMAIL_AUTH_PASS: "pm-token", + }); + expect(cfg.TRANSPORT_OPS).toMatchObject({ + host: "smtp.postmarkapp.com", + port: 587, + secure: false, + auth: { user: "pm-token", pass: "pm-token" }, + }); + }); + + it("uses TACHI_EMAIL_AUTH_USER when POSTMARK and only user is set", () => { + const cfg = buildEmailConfig({ + TACHI_EMAIL_FROM: "from@example.com", + TACHI_EMAIL_AUTH_POSTMARK: "1", + TACHI_EMAIL_AUTH_USER: "only-user-token", + }); + expect(cfg.TRANSPORT_OPS).toMatchObject({ + auth: { user: "only-user-token", pass: "only-user-token" }, + }); + }); + + it("throws when POSTMARK is true but no token is given", () => { + expect(() => + buildEmailConfig({ + TACHI_EMAIL_FROM: "a@b.com", + TACHI_EMAIL_AUTH_POSTMARK: "true", + }), + ).toThrow(/TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER/u); + }); + + it("builds generic SMTP without auth when POSTMARK is false", () => { + const cfg = buildEmailConfig({ + TACHI_EMAIL_FROM: "dev@localhost", + TACHI_EMAIL_AUTH_POSTMARK: "false", + TACHI_EMAIL_HOST: "tachi-mailpit", + TACHI_EMAIL_PORT: "1025", + TACHI_EMAIL_SECURE: "false", + }); + expect(cfg.TRANSPORT_OPS).toEqual({ + host: "tachi-mailpit", + port: 1025, + secure: false, + }); + }); + + it("includes auth when user and/or pass are set for generic SMTP", () => { + const cfg = buildEmailConfig({ + TACHI_EMAIL_FROM: "a@b.com", + TACHI_EMAIL_AUTH_POSTMARK: "false", + TACHI_EMAIL_HOST: "smtp.example", + TACHI_EMAIL_PORT: "587", + TACHI_EMAIL_SECURE: "false", + TACHI_EMAIL_AUTH_USER: "u", + TACHI_EMAIL_AUTH_PASS: "p", + }); + expect(cfg.TRANSPORT_OPS).toMatchObject({ + host: "smtp.example", + port: 587, + secure: false, + auth: { user: "u", pass: "p" }, + }); + }); + + it("defaults TACHI_EMAIL_SECURE to false", () => { + const cfg = buildEmailConfig({ + TACHI_EMAIL_FROM: "a@b.com", + TACHI_EMAIL_AUTH_POSTMARK: "false", + TACHI_EMAIL_HOST: "h", + TACHI_EMAIL_PORT: "25", + }); + expect(cfg.TRANSPORT_OPS).toMatchObject({ secure: false }); + }); + + it("throws when generic SMTP is missing host", () => { + expect(() => + buildEmailConfig({ + TACHI_EMAIL_FROM: "a@b.com", + TACHI_EMAIL_AUTH_POSTMARK: "false", + TACHI_EMAIL_PORT: "1025", + }), + ).toThrow(/TACHI_EMAIL_HOST is required/u); + }); + + it("throws when generic SMTP is missing port", () => { + expect(() => + buildEmailConfig({ + TACHI_EMAIL_FROM: "a@b.com", + TACHI_EMAIL_AUTH_POSTMARK: "false", + TACHI_EMAIL_HOST: "localhost", + }), + ).toThrow(/TACHI_EMAIL_PORT is required/u); + }); + + it("throws when port is not a number", () => { + expect(() => + buildEmailConfig({ + TACHI_EMAIL_FROM: "a@b.com", + TACHI_EMAIL_AUTH_POSTMARK: "false", + TACHI_EMAIL_HOST: "localhost", + TACHI_EMAIL_PORT: "nope", + }), + ).toThrow(/TACHI_EMAIL_PORT must be a number/u); + }); +}); diff --git a/typescript/server/src/lib/setup/config.ts b/typescript/server/src/lib/setup/config.ts index 86ee4f98e..3c3e5ec57 100644 --- a/typescript/server/src/lib/setup/config.ts +++ b/typescript/server/src/lib/setup/config.ts @@ -1,6 +1,13 @@ import { log } from "#lib/log/log"; import { loadServerEnvFile } from "#lib/setup/load-server-env"; -import { allSupportedGameGroups, type GameGroup, type ImportTypes } from "tachi-common"; +import { + ALL_GAMES, + allSupportedGameGroups, + type GameGroup, + GameToGameGroup, + type ImportTypes, + type V3Game, +} from "tachi-common"; import { allImportTypes } from "tachi-common/constants/import-types"; import { z } from "zod"; @@ -54,13 +61,10 @@ const configSchema = z.object({ EXTERNAL_SCORE_IMPORT_WORKER_CONCURRENCY: z.number().int().positive().optional(), ALLOW_RUNNING_OFFLINE: z.boolean().optional(), ENABLE_METRICS: z.boolean().default(true), - EMAIL_CONFIG: z - .object({ - FROM: z.string(), - DKIM: z.any().optional(), - TRANSPORT_OPS: z.any().optional(), - }) - .optional(), + EMAIL_CONFIG: z.object({ + FROM: z.string(), + TRANSPORT_OPS: z.any(), + }), USC_QUEUE_SIZE: z.number().int().gte(2).default(3), BEATORAJA_QUEUE_SIZE: z.number().int().gte(2).default(3), MAX_GOAL_SUBSCRIPTIONS: z.number().int().positive().default(1_000), @@ -80,7 +84,7 @@ const configSchema = z.object({ TACHI_CONFIG: z.object({ NAME: z.string(), TYPE: z.enum(["kamai", "boku", "omni"]), - GAMES: z.array(z.enum(allSupportedGameGroups as [GameGroup, ...GameGroup[]])), + GAME_GROUPS: z.array(z.enum(allSupportedGameGroups as [GameGroup, ...GameGroup[]])), IMPORT_TYPES: z.array(z.enum(allImportTypes as [ImportTypes, ...ImportTypes[]])), SIGNUPS_ENABLED: z.boolean().default(true), }), @@ -236,28 +240,117 @@ function seedsConfig(): TachiServerConfig["SEEDS_CONFIG"] { throw new Error(`Invalid TACHI_SEEDS_TYPE "${type}". Expected LOCAL_FILES or GIT_REPO.`); } -function emailConfig(): TachiServerConfig["EMAIL_CONFIG"] { - const from = opt("TACHI_EMAIL_FROM"); - if (from === undefined) { +function envOptFrom(env: NodeJS.ProcessEnv, key: string): string | undefined { + const v = env[key]; + if (v === undefined || v === "") { return undefined; } - const dkimRaw = opt("TACHI_EMAIL_DKIM_JSON"); - const transportRaw = opt("TACHI_EMAIL_TRANSPORT_OPS_JSON"); - let dkim: unknown; - let transportOps: unknown; - if (dkimRaw !== undefined) { - dkim = JSON.parse(dkimRaw) as unknown; + return v; +} + +function envBoolFrom(env: NodeJS.ProcessEnv, key: string, defaultVal: boolean): boolean { + const v = envOptFrom(env, key); + if (v === undefined) { + return defaultVal; } - if (transportRaw !== undefined) { - transportOps = JSON.parse(transportRaw) as unknown; + if (v === "true" || v === "1") { + return true; } + if (v === "false" || v === "0") { + return false; + } + return defaultVal; +} + +/** + * SMTP settings from env. Required for every deployment. + * + * - `TACHI_EMAIL_FROM` — `From` header (must match Postmark sender when using Postmark). + * - `TACHI_EMAIL_AUTH_POSTMARK` — if `true`, uses Postmark SMTP; set `TACHI_EMAIL_AUTH_USER` / + * `TACHI_EMAIL_AUTH_PASS` to your server token (both are the token for Postmark). + * - Otherwise: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE`, and optionally + * `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` (e.g. Mailpit: no auth). + * + * @internal Exported for unit tests. + */ +export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMAIL_CONFIG"] { + const from = envOptFrom(env, "TACHI_EMAIL_FROM"); + if (from === undefined) { + throw new Error(`TACHI_EMAIL_FROM is required.`); + } + + if (envBoolFrom(env, "TACHI_EMAIL_AUTH_POSTMARK", false)) { + const pass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS"); + const user = envOptFrom(env, "TACHI_EMAIL_AUTH_USER"); + const token = pass ?? user; + if (token === undefined) { + throw new Error( + `TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_AUTH_POSTMARK is true.`, + ); + } + const authUser = user ?? token; + const authPass = pass ?? token; + return { + FROM: from, + TRANSPORT_OPS: { + host: "smtp.postmarkapp.com", + port: 587, + secure: false, + auth: { + user: authUser, + pass: authPass, + }, + }, + }; + } + + const host = envOptFrom(env, "TACHI_EMAIL_HOST"); + if (host === undefined) { + throw new Error(`TACHI_EMAIL_HOST is required when TACHI_EMAIL_AUTH_POSTMARK is false.`); + } + + const portRaw = envOptFrom(env, "TACHI_EMAIL_PORT"); + if (portRaw === undefined) { + throw new Error(`TACHI_EMAIL_PORT is required when TACHI_EMAIL_AUTH_POSTMARK is false.`); + } + const port = Number.parseInt(portRaw, 10); + if (Number.isNaN(port)) { + throw new Error(`TACHI_EMAIL_PORT must be a number, got "${portRaw}".`); + } + + const secure = envBoolFrom(env, "TACHI_EMAIL_SECURE", false); + const authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER"); + const authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS"); + + const transportOps: Record = { + host, + port, + secure, + }; + if (authUser !== undefined || authPass !== undefined) { + transportOps.auth = { + user: authUser ?? "", + pass: authPass ?? "", + }; + } + return { FROM: from, - ...(dkim !== undefined ? { DKIM: dkim } : {}), - ...(transportOps !== undefined ? { TRANSPORT_OPS: transportOps } : {}), + TRANSPORT_OPS: transportOps, }; } +function emailConfig(): TachiServerConfig["EMAIL_CONFIG"] { + try { + return buildEmailConfig(process.env); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error({ err, bootInfo: true }, `Invalid email configuration: ${msg}`); + process.exit(1); + throw new Error("unreachable"); + } +} + function inviteCodeConfig(): TachiServerConfig["INVITE_CODE_CONFIG"] { const batch = opt("TACHI_INVITE_CODE_BATCH_SIZE"); const cap = opt("TACHI_INVITE_CODE_INVITE_CAP"); @@ -288,7 +381,7 @@ function clientDevServer(): string | null | undefined { return v; } -const games = parseCsv("TACHI_GAMES", allSupportedGameGroups as readonly GameGroup[]); +const gameGroups = parseCsv("TACHI_GAME_GROUPS", allSupportedGameGroups as readonly GameGroup[]); const importTypes = parseCsv("TACHI_IMPORT_TYPES", allImportTypes as readonly ImportTypes[]); const cgDev = cgOptional("DEV"); @@ -333,7 +426,7 @@ const configFromEnv: unknown = { ), ALLOW_RUNNING_OFFLINE: parseBool("TACHI_ALLOW_RUNNING_OFFLINE"), ENABLE_METRICS: parseBool("TACHI_ENABLE_METRICS", true) ?? true, - ...(emailCfg !== undefined ? { EMAIL_CONFIG: emailCfg } : {}), + EMAIL_CONFIG: emailCfg, USC_QUEUE_SIZE: parseIntEnv("TACHI_USC_QUEUE_SIZE", 3), BEATORAJA_QUEUE_SIZE: parseIntEnv("TACHI_BEATORAJA_QUEUE_SIZE", 3), MAX_GOAL_SUBSCRIPTIONS: parseIntEnv("TACHI_MAX_GOAL_SUBSCRIPTIONS", 1_000), @@ -345,7 +438,7 @@ const configFromEnv: unknown = { TACHI_CONFIG: { NAME: req("TACHI_NAME"), TYPE: req("TACHI_TYPE"), - GAMES: games, + GAME_GROUPS: gameGroups, IMPORT_TYPES: importTypes, SIGNUPS_ENABLED: parseBool("TACHI_SIGNUPS_ENABLED", true) ?? true, }, @@ -366,6 +459,10 @@ if (!result.success) { export const TachiConfig = result.data.TACHI_CONFIG; export const ServerConfig: TachiServerConfig = result.data; +export function AllEnabledGames(): Array { + return ALL_GAMES.filter((g) => TachiConfig.GAME_GROUPS.includes(GameToGameGroup(g))); +} + // Environment Variable Validation let PORT = Number(process.env.PORT); @@ -403,7 +500,7 @@ if (!["dev", "production", "staging", "test"].includes(NODE_ENV)) { process.exit(1); } -if (TachiConfig.GAMES.includes("bms") !== TachiConfig.GAMES.includes("pms")) { +if (TachiConfig.GAME_GROUPS.includes("bms") !== TachiConfig.GAME_GROUPS.includes("pms")) { log.error(`BMS and PMS MUST be enabled at the same time, due to how the beatoraja IR works.`); process.exit(1); @@ -431,18 +528,18 @@ if (!MIGRATIONS_DIR) { process.exit(1); } -const VERSION = process.env.VERSION; +let version = process.env.VERSION; -if (!VERSION) { - log.error(`No VERSION specified in environment. Terminating.`); - process.exit(1); +if (!version) { + log.error(`No VERSION specified in environment. defaulting to 0.0.0.`); + version = "0.0.0"; } -const COMMIT_HASH = process.env.COMMIT_HASH; +let commitHash = process.env.COMMIT_HASH; -if (!COMMIT_HASH) { - log.error(`No COMMIT_HASH specified in environment. Terminating.`); - process.exit(1); +if (!commitHash) { + log.error(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`); + commitHash = "unknown"; } export const Env = { @@ -451,8 +548,8 @@ export const Env = { MONGO_URL, POSTGRES_URL, MIGRATIONS_DIR, - VERSION, - COMMIT_HASH, + VERSION: version, + COMMIT_HASH: commitHash, NODE_ENV: NODE_ENV as "dev" | "production" | "staging" | "test", LOG_LEVEL: logLevel as "crit" | "debug" | "error" | "info" | "severe" | "verbose" | "warn", }; diff --git a/typescript/server/src/lib/showcase/evaluator.oldtest.ts b/typescript/server/src/lib/showcase/evaluator.oldtest.ts deleted file mode 100644 index ffcddf00e..000000000 --- a/typescript/server/src/lib/showcase/evaluator.oldtest.ts +++ /dev/null @@ -1,81 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA, TestingIIDXFolderSP10, TestingIIDXSPScorePB } from "#test-utils/test-data"; -import { CreateFolderChartLookup } from "#utils/folder"; -import deepmerge from "deepmerge"; -import { IIDX_LAMPS } from "tachi-common"; -import t from "tap"; - -import { EvaluateShowcaseStat } from "./evaluator"; - -/* eslint-disable no-return-await */ -// causes a race condition otherwise due to weird tap interaction - -t.test("#EvaluateShowcaseStat", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await CreateFolderChartLookup(TestingIIDXFolderSP10, true); - }); - t.beforeEach( - async () => - await MONGODB_KILL["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})), - ); - - t.test("Should evaluate a folder stat.", async (t) => { - const data = await EvaluateShowcaseStat( - "iidx:SP", - { - folderID: TestingIIDXFolderSP10.folderID, - mode: "folder", - metric: "lamp", - gte: IIDX_LAMPS.HARD_CLEAR, - }, - 1, - ); - - t.strictSame(data, { - value: 1, - outOf: 1, - }); - - t.end(); - }); - - t.test("Should evaluate a chart stat.", async (t) => { - const data = await EvaluateShowcaseStat( - "iidx:SP", - { - chartID: Testing511SPA.chartID, - mode: "chart", - metric: "score", - }, - 1, - ); - - t.strictSame(data, { - value: 1479, - }); - - t.end(); - }); - - t.test("Should return null if the user has no score on this chart.", async (t) => { - const data = await EvaluateShowcaseStat( - "iidx:SP", - { - chartID: "nonsense", - mode: "chart", - metric: "score", - }, - 1, - ); - - t.strictSame(data, { - value: null, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/showcase/evaluator.test.ts b/typescript/server/src/lib/showcase/evaluator.test.ts new file mode 100644 index 000000000..85f256277 --- /dev/null +++ b/typescript/server/src/lib/showcase/evaluator.test.ts @@ -0,0 +1,151 @@ +import { seedUser } from "#actions/test-utils/api-tokens"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { + Testing511Song, + Testing511SPA, + TestingIIDXFolderSP10, + TestingIIDXSPScorePB, +} from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { IIDX_LAMPS } from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { EvaluateShowcaseStat } from "./evaluator"; + +async function seed511AndFolder() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); + + await DB.insertInto("folder") + .values({ + id: TestingIIDXFolderSP10.folderID, + legacy_id: TestingIIDXFolderSP10.folderID, + game: "iidx-sp", + inactive: false, + title: TestingIIDXFolderSP10.title, + slug: TestingIIDXFolderSP10.slug, + where: "chart.level_num = 10", + version_filter: null, + search_terms: [], + }) + .execute(); + + await DB.insertInto("folder_chart_lookup") + .values({ folder_id: TestingIIDXFolderSP10.folderID, chart_id: Testing511SPA.chartID }) + .execute(); +} + +async function insertPb(userId: number, doc: typeof TestingIIDXSPScorePB) { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { + ...doc.scoreData, + judgements: doc.scoreData.judgements, + }); + + await DB.insertInto("pb") + .values({ + user_id: userId, + chart_id: doc.chartID, + lens: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(doc.calculatedData), + ranking_value: doc.scoreData.score, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: doc.highlight, + time_achieved: + doc.timeAchieved !== null && doc.timeAchieved !== undefined + ? UnixMillisecondsToISO8601(doc.timeAchieved) + : null, + }) + .execute(); +} + +describe("EvaluateShowcaseStat (ported from evaluator.oldtest.ts)", () => { + beforeEach(async () => { + await seedUser({ username: "eval_u", email: "eval@example.com" }); + await seed511AndFolder(); + await insertPb(1, TestingIIDXSPScorePB); + }); + + it("evaluates a folder stat", async () => { + const data = await EvaluateShowcaseStat( + "iidx-sp", + { + mode: "folder", + slug: TestingIIDXFolderSP10.slug, + metric: "lamp", + gte: IIDX_LAMPS.HARD_CLEAR, + }, + 1, + ); + + expect(data).toEqual({ + value: 1, + outOf: 1, + }); + }); + + it("evaluates a chart stat (PB + playcount)", async () => { + const data = await EvaluateShowcaseStat( + "iidx-sp", + { + chartID: Testing511SPA.chartID, + mode: "chart", + }, + 1, + ); + + expect(data).toMatchObject({ + playcount: 0, + }); + expect((data as { pb: unknown }).pb).not.toBeNull(); + }); + + it("returns null PB when no score exists on chart", async () => { + const data = await EvaluateShowcaseStat( + "iidx-sp", + { + chartID: "nonsense", + mode: "chart", + }, + 1, + ); + + expect(data).toMatchObject({ + pb: null, + playcount: 0, + }); + }); +}); diff --git a/typescript/server/src/lib/showcase/evaluator.ts b/typescript/server/src/lib/showcase/evaluator.ts index 439fab4e7..e2786e0d5 100644 --- a/typescript/server/src/lib/showcase/evaluator.ts +++ b/typescript/server/src/lib/showcase/evaluator.ts @@ -1,29 +1,38 @@ -import { LoadPbByUserAndChartID } from "#lib/db-formats/pb"; +import { LoadFolderDocumentByGameAndSlug } from "#lib/db-formats/folders"; +import { GetPBOnChart } from "#lib/db-formats/pb"; import DB from "#services/pg/db"; import { GetFolderChartIDs } from "#utils/folder"; import { sql } from "kysely"; import { - GetGPTConfig, - type GPTString, + GetGameConfig, type integer, + type PBScoreDocument, type ShowcaseStatChart, type ShowcaseStatDetails, type ShowcaseStatFolder, + type V3Game, } from "tachi-common"; +export type ShowcaseEvalChartResult = { + pb: PBScoreDocument | null; + playcount: number; +}; + +export type ShowcaseEvalFolderResult = { + outOf: number; + value: number; +}; + export function EvaluateShowcaseStat( - gpt: GPTString, + game: V3Game, details: ShowcaseStatDetails, userID: integer, -): Promise<{ - outOf?: number; - value: number | null; -}> { +): Promise { switch (details.mode) { case "chart": - return EvaluateShowcaseChartStat(gpt, details, userID); + return EvaluateShowcaseChartStat(details, userID); case "folder": - return EvaluateShowcaseFolderStat(gpt, details, userID); + return EvaluateShowcaseFolderStat(game, details, userID); default: // @ts-expect-error This should never happen anyway -- this ignore ignores a 'never' result. @@ -32,64 +41,44 @@ export function EvaluateShowcaseStat( } async function EvaluateShowcaseChartStat( - gpt: GPTString, details: ShowcaseStatChart, userID: integer, -) { - // requires special handling - if (details.metric === "playcount") { - const row = await DB.selectFrom("score") +): Promise { + const [pb, playcountRow] = await Promise.all([ + GetPBOnChart(userID, details.chartID), + DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") .select((eb) => eb.fn.countAll().as("cnt")) .where("score.user_id", "=", userID) .where("chart.id", "=", details.chartID) - .executeTakeFirst(); + .executeTakeFirst(), + ]); - return { value: Number(row?.cnt ?? 0) }; - } - - const pb = await LoadPbByUserAndChartID(userID, details.chartID); - - if (!pb) { - return { value: null }; - } - - const metric = details.metric; - - const gptConfig = GetGPTConfig(gpt); - - const scoreMetricConfig = gptConfig.providedMetrics[metric] ?? gptConfig.derivedMetrics[metric]; - - if (!scoreMetricConfig) { - throw new Error(`Invalid metric of ${metric} passed for game ${gpt}.`); - } - - if (scoreMetricConfig.type === "ENUM") { - // @ts-expect-error guaranteed to be correct - return { value: pb.scoreData.enumIndexes[metric] }; - } - - // @ts-expect-error guaranteed to be correct - return { value: pb.scoreData[metric] }; + return { + pb, + playcount: Number(playcountRow?.cnt ?? 0), + }; } async function EvaluateShowcaseFolderStat( - gpt: GPTString, + game: V3Game, details: ShowcaseStatFolder, userID: integer, -) { - let chartIDs; +): Promise { + const folder = await LoadFolderDocumentByGameAndSlug(game, details.slug); - if (Array.isArray(details.folderID)) { - chartIDs = (await Promise.all(details.folderID.map((id) => GetFolderChartIDs(id)))).flat(1); - } else { - chartIDs = await GetFolderChartIDs(details.folderID); + if (!folder) { + throw new Error( + `Showcase folder slug ${JSON.stringify(details.slug)} does not exist for ${game}.`, + ); } + const chartIDs = await GetFolderChartIDs(folder.folderID); + const value = await CountPbsForFolderMetricGte( userID, chartIDs, - gpt, + game, details.metric, details.gte, ); @@ -100,7 +89,7 @@ async function EvaluateShowcaseFolderStat( async function CountPbsForFolderMetricGte( userID: integer, chartIDs: string[], - gpt: GPTString, + game: V3Game, metric: string, gte: number, ): Promise { @@ -108,15 +97,16 @@ async function CountPbsForFolderMetricGte( return 0; } - const gptConfig = GetGPTConfig(gpt); - const scoreMetricConfig = gptConfig.providedMetrics[metric] ?? gptConfig.derivedMetrics[metric]; + const gameConfig = GetGameConfig(game); + const scoreMetricConfig = + gameConfig.providedMetrics[metric] ?? gameConfig.derivedMetrics[metric]; if (!scoreMetricConfig) { - throw new Error(`Invalid metric of ${metric} passed for game ${gpt}.`); + throw new Error(`Invalid metric of ${metric} passed for game ${game}.`); } const jsonBlob = - gptConfig.derivedMetrics[metric] !== undefined ? sql`pb.derived_data` : sql`pb.data`; + gameConfig.derivedMetrics[metric] !== undefined ? sql`pb.derived_data` : sql`pb.data`; const row = await DB.selectFrom("pb") .select(sql`count(*)::int`.as("count")) diff --git a/typescript/server/src/lib/showcase/get-related.ts b/typescript/server/src/lib/showcase/get-related.ts index addcb6487..d1003af07 100644 --- a/typescript/server/src/lib/showcase/get-related.ts +++ b/typescript/server/src/lib/showcase/get-related.ts @@ -1,29 +1,21 @@ -import type { GameGroup, ShowcaseStatDetails } from "tachi-common"; +import type { ShowcaseStatDetails, V3Game } from "tachi-common"; -import { LoadFolderDocumentById } from "#lib/db-formats/folders"; +import { LoadFolderDocumentByGameAndSlug } from "#lib/db-formats/folders"; import { log } from "#lib/log/log"; import { GetChartForIDGuaranteed, GetSongForIDGuaranteed } from "#utils/db"; -export async function GetRelatedStatDocuments(stat: ShowcaseStatDetails, game: GameGroup) { +export async function GetRelatedStatDocuments(stat: ShowcaseStatDetails, game: V3Game) { switch (stat.mode) { case "chart": { - const chart = await GetChartForIDGuaranteed(game, stat.chartID); + const chart = await GetChartForIDGuaranteed(stat.chartID); - const song = await GetSongForIDGuaranteed(game, chart.songID); + const song = await GetSongForIDGuaranteed(chart.song.id); return { song, chart }; } case "folder": { - if (Array.isArray(stat.folderID)) { - log.warn( - { stat }, - `This stat is corrupt and attempted to use multiple folderIDs. This is no longer supported. Check that migrations have ran.`, - ); - throw new Error(`Legacy FolderIDs used in showcase stat.`); - } - - const folder = await LoadFolderDocumentById(stat.folderID); + const folder = await LoadFolderDocumentByGameAndSlug(game, stat.slug); if (!folder) { log.error({ stat }, `This stat refers to a folder that does not exist?`); diff --git a/typescript/server/src/lib/showcase/get-stats.oldtest.ts b/typescript/server/src/lib/showcase/get-stats.oldtest.ts deleted file mode 100644 index 69d822c35..000000000 --- a/typescript/server/src/lib/showcase/get-stats.oldtest.ts +++ /dev/null @@ -1,132 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA, TestingIIDXFolderSP10, TestingIIDXSPScorePB } from "#test-utils/test-data"; -import { CreateFolderChartLookup } from "#utils/folder"; -import deepmerge from "deepmerge"; -import { IIDX_LAMPS } from "tachi-common"; -import t from "tap"; - -import { EvaluateUsersStatsShowcase } from "./get-stats"; - -t.test("#EvalulateUsersStatsShowcase", (t) => { - t.beforeEach(ResetDBState); - - t.beforeEach(async () => { - await MONGODB_KILL.folders.insert(TestingIIDXFolderSP10); - await CreateFolderChartLookup(TestingIIDXFolderSP10, true); - - await MONGODB_KILL["game-settings"].remove({}); - - await MONGODB_KILL["game-settings"].insert({ - userID: 1, - game: "iidx", - playtype: "SP", - preferences: { - preferredProfileAlg: null, - preferredScoreAlg: null, - preferredSessionAlg: null, - preferredDefaultEnum: null, - defaultTable: null, - preferredRanking: null, - stats: [ - { - folderID: TestingIIDXFolderSP10.folderID, - mode: "folder", - metric: "lamp", - gte: IIDX_LAMPS.HARD_CLEAR, - }, - { - chartID: Testing511SPA.chartID, - mode: "chart", - metric: "score", - }, - ], - gameSpecific: { - display2DXTra: false, - bpiTarget: 0, - }, - }, - rivals: [], - }); - - await MONGODB_KILL["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})); - }); - - t.test("Should evaluate a user's preferred stats.", async (t) => { - const res = await EvaluateUsersStatsShowcase(1, "iidx", "SP"); - - t.hasStrict(res, [ - { - stat: { folderID: TestingIIDXFolderSP10.folderID }, - result: { - value: 1, - outOf: 1, - }, - related: { - folder: { - folderID: TestingIIDXFolderSP10.folderID, - }, - }, - }, - { - stat: { chartID: Testing511SPA.chartID }, - result: { - value: 1479, - }, - related: { - song: { - title: "5.1.1.", - }, - chart: { - difficulty: "ANOTHER", - playtype: "SP", - }, - }, - }, - ]); - - t.end(); - }); - - t.test("Should throw an error if the user does not have game-settings.", async (t) => { - await MONGODB_KILL["game-settings"].remove({}); - - t.rejects(() => EvaluateUsersStatsShowcase(1, "iidx", "SP")); - - t.end(); - }); - - t.test("Should project another users stats onto the given user if set.", async (t) => { - await MONGODB_KILL["personal-bests"].insert( - deepmerge(TestingIIDXSPScorePB, { - userID: 2, - scoreData: { - score: 300, - lampIndex: IIDX_LAMPS.CLEAR, - }, - }), - ); - - const res = await EvaluateUsersStatsShowcase(2, "iidx", "SP", 1); - - t.hasStrict(res, [ - { - stat: { folderID: TestingIIDXFolderSP10.folderID }, - result: { - value: 1, - outOf: 1, - }, - }, - { - stat: { chartID: Testing511SPA.chartID }, - result: { - value: 300, - }, - }, - ]); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/showcase/get-stats.test.ts b/typescript/server/src/lib/showcase/get-stats.test.ts new file mode 100644 index 000000000..de5901e49 --- /dev/null +++ b/typescript/server/src/lib/showcase/get-stats.test.ts @@ -0,0 +1,151 @@ +import { seedUser } from "#actions/test-utils/api-tokens"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { + Testing511Song, + Testing511SPA, + TestingIIDXFolderSP10, + TestingIIDXSPScorePB, +} from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { IIDX_LAMPS } from "tachi-common"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { EvaluateUsersStatsShowcase } from "./get-stats"; + +async function seedFixtures() { + await seedUser({ username: "showcase_u", email: "showcase@example.com" }); + + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); + + await DB.insertInto("folder") + .values({ + id: TestingIIDXFolderSP10.folderID, + legacy_id: TestingIIDXFolderSP10.folderID, + game: "iidx-sp", + inactive: false, + title: TestingIIDXFolderSP10.title, + slug: TestingIIDXFolderSP10.slug, + where: "chart.level_num = 10", + version_filter: null, + search_terms: [], + }) + .execute(); + + await DB.insertInto("folder_chart_lookup") + .values({ folder_id: TestingIIDXFolderSP10.folderID, chart_id: Testing511SPA.chartID }) + .execute(); + + await DB.insertInto("game_settings") + .values({ + user_id: 1, + game: "iidx-sp", + pf_preferred_score_alg: null, + pf_preferred_session_alg: null, + pf_preferred_profile_alg: null, + pf_preferred_default_enum: null, + pf_default_table: null, + pf_preferred_ranking: null, + data: JSON.stringify({ display2DXTra: false, bpiTarget: 0 }), + }) + .execute(); + + const stats = [ + { + mode: "folder" as const, + slug: TestingIIDXFolderSP10.slug, + metric: "lamp", + gte: IIDX_LAMPS.HARD_CLEAR, + }, + { + mode: "chart" as const, + chartID: Testing511SPA.chartID, + }, + ]; + + await DB.insertInto("game_settings_showcase") + .values({ + user_id: 1, + game: "iidx-sp", + data: JSON.stringify(stats), + }) + .execute(); + + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { + ...TestingIIDXSPScorePB.scoreData, + judgements: TestingIIDXSPScorePB.scoreData.judgements, + }); + + await DB.insertInto("pb") + .values({ + user_id: 1, + chart_id: TestingIIDXSPScorePB.chartID, + lens: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(TestingIIDXSPScorePB.calculatedData), + ranking_value: TestingIIDXSPScorePB.scoreData.score, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: TestingIIDXSPScorePB.highlight, + time_achieved: + TestingIIDXSPScorePB.timeAchieved !== null && + TestingIIDXSPScorePB.timeAchieved !== undefined + ? UnixMillisecondsToISO8601(TestingIIDXSPScorePB.timeAchieved) + : null, + }) + .execute(); +} + +describe("EvaluateUsersStatsShowcase (ported from get-stats.oldtest.ts)", () => { + beforeEach(seedFixtures); + + it("evaluates configured stats for the user", async () => { + const res = await EvaluateUsersStatsShowcase(1, "iidx-sp"); + + expect(res).toHaveLength(2); + expect(res[0]).toMatchObject({ + stat: { mode: "folder", slug: TestingIIDXFolderSP10.slug }, + result: { value: 1, outOf: 1 }, + }); + expect(res[1]?.stat).toMatchObject({ mode: "chart", chartID: Testing511SPA.chartID }); + }); + + it("throws when the user has no game_settings row", async () => { + await DB.deleteFrom("game_settings").where("user_id", "=", 1).execute(); + await DB.deleteFrom("game_settings_showcase").where("user_id", "=", 1).execute(); + + await expect(EvaluateUsersStatsShowcase(1, "iidx-sp")).rejects.toThrow(); + }); +}); diff --git a/typescript/server/src/lib/showcase/get-stats.ts b/typescript/server/src/lib/showcase/get-stats.ts index 69def8fb5..886f537d2 100644 --- a/typescript/server/src/lib/showcase/get-stats.ts +++ b/typescript/server/src/lib/showcase/get-stats.ts @@ -1,13 +1,6 @@ import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings"; import { log } from "#lib/log/log"; -import { - type GameGroup, - GetGPTString, - type GPTString, - type integer, - type Playtype, - type ShowcaseStatDetails, -} from "tachi-common"; +import { type integer, type ShowcaseStatDetails, type V3Game } from "tachi-common"; import { EvaluateShowcaseStat } from "./evaluator"; import { GetRelatedStatDocuments } from "./get-related"; @@ -19,12 +12,11 @@ import { GetRelatedStatDocuments } from "./get-related"; */ export async function EvaluateUsersStatsShowcase( userID: integer, - game: GameGroup, - playtype: Playtype, + game: V3Game, projectUserStats?: integer, ) { const getSettingsID = projectUserStats ?? userID; - const settings = await GetUGPTSettingsDocument(getSettingsID, game, playtype); + const settings = await GetUGPTSettingsDocument(getSettingsID, game); if (!settings) { log.error( @@ -36,23 +28,16 @@ export async function EvaluateUsersStatsShowcase( ); } - const gpt = GetGPTString(game, playtype); - const results = await Promise.all( - settings.preferences.stats.map((details) => EvaluateStats(gpt, details, userID, game)), + settings.preferences.stats.map((details) => EvaluateStats(game, details, userID)), ); return results; } -async function EvaluateStats( - gpt: GPTString, - details: ShowcaseStatDetails, - userID: integer, - game: GameGroup, -) { +async function EvaluateStats(game: V3Game, details: ShowcaseStatDetails, userID: integer) { const [result, related] = await Promise.all([ - EvaluateShowcaseStat(gpt, details, userID), + EvaluateShowcaseStat(game, details, userID), GetRelatedStatDocuments(details, game), ]); diff --git a/typescript/server/src/lib/targets/goal-pb-queries.ts b/typescript/server/src/lib/targets/goal-pb-queries.ts index bb63360db..54eef3895 100644 --- a/typescript/server/src/lib/targets/goal-pb-queries.ts +++ b/typescript/server/src/lib/targets/goal-pb-queries.ts @@ -2,13 +2,9 @@ import { type PbDocumentJoinRow, SELECT_PB_DOCUMENT_WITH_LEADERBOARD, ToPbScoreDocument, -} from "#lib/db-formats/pb.js"; -import DB from "#services/pg/db.js"; -import { - type GetScoreMetricConf, - type MONGO_GoalDocument, - type MONGO_PBScoreDocument, -} from "tachi-common"; +} from "#lib/db-formats/pb"; +import DB from "#services/pg/db"; +import { type GetScoreMetricConf, type GoalDocument, type PBScoreDocument } from "tachi-common"; type GoalMetricConf = NonNullable>; @@ -18,7 +14,7 @@ type GoalMetricConf = NonNullable>; export async function LoadPbsForUserOnChartsForGoal( userId: number, chartIDs: string[], -): Promise { +): Promise { if (chartIDs.length === 0) { return []; } @@ -36,8 +32,8 @@ export async function LoadPbsForUserOnChartsForGoal( } export function getGoalMetricValueFromPb( - pb: MONGO_PBScoreDocument, - criteriaKey: MONGO_GoalDocument["criteria"]["key"], + pb: PBScoreDocument, + criteriaKey: GoalDocument["criteria"]["key"], scoreConf: GoalMetricConf, ): number | null { if (scoreConf.type === "ENUM") { @@ -52,8 +48,8 @@ export function getGoalMetricValueFromPb( } export function pbMeetsGoalThreshold( - pb: MONGO_PBScoreDocument, - criteriaKey: MONGO_GoalDocument["criteria"]["key"], + pb: PBScoreDocument, + criteriaKey: GoalDocument["criteria"]["key"], threshold: number, scoreConf: GoalMetricConf, ): boolean { diff --git a/typescript/server/src/lib/targets/goal-utils.ts b/typescript/server/src/lib/targets/goal-utils.ts index 7c34e0cc7..acb1a246b 100644 --- a/typescript/server/src/lib/targets/goal-utils.ts +++ b/typescript/server/src/lib/targets/goal-utils.ts @@ -1,35 +1,27 @@ import type { GoalCriteriaFormatter } from "#game-implementations/types"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; -import { GetChartById } from "#lib/db-formats/chart"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { GetChartByIdForGame } from "#lib/db-formats/chart"; import { LoadFolderDocumentById } from "#lib/db-formats/folders"; -import { GetFolderChartIDs } from "#lib/folders/folders.js"; +import { GetFolderChartIDs } from "#lib/folders/folders"; import { HumaniseChartID } from "#utils/db"; import { HumanisedJoinArray, OnlyFloatToDP } from "#utils/misc"; import { - FormatGameGroup, - type GameGroup, - GamePTToV3, - GetGPTConfig, - GetGPTString, + FormatGame, + GetGameConfig, GetScoreMetricConf, - GetSpecificGPTConfig, - type GPTString, - type MONGO_GoalDocument, - type Playtype, + type GoalDocument, + type V3Game, } from "tachi-common"; export async function CreateGoalTitle( - charts: MONGO_GoalDocument["charts"], - criteria: MONGO_GoalDocument["criteria"], - game: GameGroup, - playtype: Playtype, + charts: GoalDocument["charts"], + criteria: GoalDocument["criteria"], + game: V3Game, ) { - const gptString = GetGPTString(game, playtype); + const formattedCriteria = FormatCriteria(criteria, game); - const formattedCriteria = FormatCriteria(criteria, gptString); - - const datasetName = await FormatCharts(charts, criteria, game); + const datasetName = await FormatCharts(charts, criteria); // Formatting this stuff into english is hard and excruciatingly manual. switch (criteria.mode) { @@ -93,19 +85,15 @@ export async function CreateGoalTitle( } } -async function FormatCharts( - charts: MONGO_GoalDocument["charts"], - criteria: MONGO_GoalDocument["criteria"], - game: GameGroup, -) { +async function FormatCharts(charts: GoalDocument["charts"], criteria: GoalDocument["criteria"]) { switch (charts.type) { case "single": - return HumaniseChartID(game, charts.data); + return HumaniseChartID(charts.data); case "multi": { // @inefficient // This could be done with significantly less db queries. const formattedTitles = await Promise.all( - charts.data.map((chartID) => HumaniseChartID(game, chartID)), + charts.data.map((chartID) => HumaniseChartID(chartID)), ); // In the case where this is an absolute query for *all* of these charts @@ -132,19 +120,19 @@ async function FormatCharts( default: throw new Error( `Invalid goal charts.type -- got ${ - (charts as MONGO_GoalDocument["charts"]).type + (charts as GoalDocument["charts"]).type }, which we don't support?`, ); } } -function FormatCriteria( - criteria: MONGO_GoalDocument["criteria"], - gptString: GPT, +function FormatCriteria( + criteria: GoalDocument["criteria"], + game: TGame, ) { - const gptConfig = GetSpecificGPTConfig(gptString); + const gameConfig = GetGameConfig(game); - const conf = GetScoreMetricConf(gptConfig, criteria.key); + const conf = GetScoreMetricConf(gameConfig, criteria.key); if (!conf) { throw new Error(`Invalid goal criteria with key ${criteria.key}. No config exists?`); @@ -161,7 +149,7 @@ function FormatCriteria( } else if (conf.type === "DECIMAL" || conf.type === "INTEGER") { const fmt: GoalCriteriaFormatter | undefined = // @ts-expect-error it still thinks criteria.key might be a symbol. - GPT_SERVER_IMPLEMENTATIONS[gptString].goalCriteriaFormatters[criteria.key]; + GAME_IMPLEMENTATIONS[game].goalCriteriaFormatters[criteria.key]; if (!fmt) { throw new Error(`No formatter defined for ${criteria.key}, yet one must exist?`); @@ -181,25 +169,20 @@ function FormatCriteria( * @warn This function is disgusting. This should have never happened. */ export async function ValidateGoalChartsAndCriteria( - charts: MONGO_GoalDocument["charts"], - criteria: MONGO_GoalDocument["criteria"], - game: GameGroup, - playtype: Playtype, + charts: GoalDocument["charts"], + criteria: GoalDocument["criteria"], + game: V3Game, ) { let chartCount = 0; - const v3Game = GamePTToV3(game, playtype); - // Validating the charts supplied switch (charts.type) { case "single": { - const chart = await GetChartById(v3Game, charts.data); + const chart = await GetChartByIdForGame(game, charts.data); if (!chart) { - throw new Error( - `A chart with id ${charts.data} does not exist for ${game}:${playtype}.`, - ); + throw new Error(`A chart with id ${charts.data} does not exist for ${game}.`); } chartCount = 1; @@ -209,10 +192,8 @@ export async function ValidateGoalChartsAndCriteria( case "folder": { const folder = await LoadFolderDocumentById(charts.data); - if (!folder || folder.game !== game || folder.playtype !== playtype) { - throw new Error( - `A folder with id ${charts.data} does not exist for ${game}:${playtype}.`, - ); + if (!folder || folder.game !== game) { + throw new Error(`A folder with id ${charts.data} does not exist for ${game}.`); } chartCount = (await GetFolderChartIDs(charts.data)).length; @@ -227,7 +208,7 @@ export async function ValidateGoalChartsAndCriteria( } const multiCharts = await Promise.all( - charts.data.map((chartID) => GetChartById(v3Game, chartID)), + charts.data.map((chartID) => GetChartByIdForGame(game, chartID)), ); if (multiCharts.some((c) => c === undefined)) { @@ -270,20 +251,16 @@ export async function ValidateGoalChartsAndCriteria( ); } - const gptString = GetGPTString(game, playtype); - // checking whether the key and value make sense - const gptConfig = GetGPTConfig(gptString); + const gameConfig = GetGameConfig(game); - const config = GetScoreMetricConf(gptConfig, criteria.key); + const config = GetScoreMetricConf(gameConfig, criteria.key); if (!config) { - throw new Error( - `Invalid criteria.key for ${FormatGameGroup(game, playtype)} (Got ${criteria.key}).`, - ); + throw new Error(`Invalid criteria.key for ${FormatGame(game)} (Got ${criteria.key}).`); } - const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gptString]; + const gptImpl = GAME_IMPLEMENTATIONS[game]; switch (config.type) { case "DECIMAL": @@ -297,7 +274,7 @@ export async function ValidateGoalChartsAndCriteria( let err; if (config.chartDependentMax) { - const chart = await GetChartById(v3Game, charts.data as string); + const chart = await GetChartByIdForGame(game, charts.data as string); if (!chart) { throw new Error( diff --git a/typescript/server/src/lib/targets/goals.oldtest.ts b/typescript/server/src/lib/targets/goals.oldtest.ts deleted file mode 100644 index a0af7ba37..000000000 --- a/typescript/server/src/lib/targets/goals.oldtest.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf, mkFakePBIIDXSP } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import { - GetKTDataJSON, - HC511Goal, - Testing511SPA, - TestingIIDXFolderSP10, - TestingIIDXSPScorePB, -} from "#test-utils/test-data"; -import { CreateFolderChartLookup } from "#utils/folder"; -import crypto from "crypto"; -import { - type ChartDocument, - type GoalDocument, - IIDX_GRADES, - type SongDocument, -} from "tachi-common"; -import t from "tap"; - -import { - EvaluateGoalForUser, - GetRelevantFolderGoals, - GetRelevantGoals, - HumaniseGoalProgress, -} from "./goals"; - -t.test("#EvaluateGoalForUser", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should correctly evaluate against single goals.", (t) => { - t.beforeEach(async () => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - // @ts-expect-error why does mongodb feel like mutating our documents! - delete TestingIIDXSPScorePB._id; - }); - - t.test("Should correctly evaluate goals if user succeeds.", async (t) => { - const res = await EvaluateGoalForUser(HC511Goal, 1, log); - - t.strictSame( - res, - { - achieved: true, - outOf: 5, - progress: 6, - outOfHuman: "HARD CLEAR", - progressHuman: "EX HARD CLEAR (BP: 2)", - }, - "Should correctly evaluate the goal if the user succeeds.", - ); - - t.end(); - }); - - t.test("Should correctly evaluate goals if user fails.", async (t) => { - await MONGODB_KILL["personal-bests"].update( - { userID: 1, chartID: Testing511SPA.chartID }, - { - $set: { - "scoreData.lamp": "CLEAR", - "scoreData.enumIndexes.lamp": 4, - }, - }, - ); - - const res = await EvaluateGoalForUser(HC511Goal, 1, log); - - t.strictSame( - res, - { - achieved: false, - outOf: 5, - progress: 4, - outOfHuman: "HARD CLEAR", - progressHuman: "CLEAR (BP: 2)", - }, - "Should correctly evaluate the goal if the user fails.", - ); - - t.end(); - }); - - t.test("Should correctly evaluate goals when user has no score.", async (t) => { - await MONGODB_KILL["personal-bests"].remove({}); - - const res = await EvaluateGoalForUser(HC511Goal, 1, log); - - t.strictSame( - res, - { - achieved: false, - outOf: 5, - progress: null, - outOfHuman: "HARD CLEAR", - progressHuman: "NO DATA", - }, - "Should correctly evaluate the goal if the user has no score.", - ); - - t.end(); - }); - - t.end(); - }); - - t.test("Should correctly evaluate against multi goals.", (t) => { - // this goal is effectively "HC any of the charts" - const multiGoal: GoalDocument = dmf(HC511Goal, { - charts: { - type: "multi", - data: [Testing511SPA.chartID, "fake_other_chart_id"], - }, - }); - - t.beforeEach(async () => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - await MONGODB_KILL["personal-bests"].insert( - dmf(TestingIIDXSPScorePB, { - scoreData: { lamp: "CLEAR", enumIndexes: { lamp: 4 } }, - chartID: "fake_other_chart_id", - }), - ); - }); - - t.test("Should work if 511 is >= HARD CLEAR", async (t) => { - const res = await EvaluateGoalForUser(multiGoal, 1, log); - - t.strictSame( - res, - { - achieved: true, - outOf: 5, - progress: 6, - outOfHuman: "HARD CLEAR", - progressHuman: "EX HARD CLEAR (BP: 2)", - }, - "Should correctly evaluate the goal if the user succeeds.", - ); - - t.end(); - }); - - t.test("Should work if other chart is >= HARD CLEAR", async (t) => { - await MONGODB_KILL["personal-bests"].update( - { userID: 1, chartID: Testing511SPA.chartID }, - { $set: { "scoreData.lamp": "CLEAR", "scoreData.enumIndexes.lamp": 4 } }, - ); - await MONGODB_KILL["personal-bests"].update( - { userID: 1, chartID: "fake_other_chart_id" }, - { $set: { "scoreData.lamp": "HARD CLEAR", "scoreData.enumIndexes.lamp": 5 } }, - ); - - const res = await EvaluateGoalForUser(multiGoal, 1, log); - - t.strictSame( - res, - { - achieved: true, - outOf: 5, - progress: 5, - outOfHuman: "HARD CLEAR", - progressHuman: "HARD CLEAR (BP: 2)", - }, - "Should correctly evaluate the goal if the user succeeds.", - ); - - t.end(); - }); - - t.test("Should work if user has no scores", async (t) => { - await MONGODB_KILL["personal-bests"].remove({}); - - const res = await EvaluateGoalForUser(multiGoal, 1, log); - - t.strictSame( - res, - { - achieved: false, - outOf: 5, - progress: null, - outOfHuman: "HARD CLEAR", - progressHuman: "NO DATA", - }, - "Should correctly evaluate the goal if the user has no data.", - ); - - t.end(); - }); - - t.end(); - }); - - t.test("Should correctly evaluate against folder goals.", (t) => { - // this goal is effectively "HC any of the sp10s" - const folderGoal: GoalDocument = dmf(HC511Goal, { - charts: { - type: "folder", - data: "ed9d8c734447ce67d7135c0067441a98cc81aeaf", - }, - }); - - t.beforeEach(async () => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - await MONGODB_KILL["personal-bests"].insert( - dmf(TestingIIDXSPScorePB, { - scoreData: { lamp: "CLEAR", enumIndexes: { lamp: 4 } }, - chartID: "other_sp10", - }), - ); - - await MONGODB_KILL.charts.iidx.insert([ - dmf(Testing511SPA, { - songID: 123, - level: "9", - chartID: "not_sp10", - data: {}, - }), - dmf(Testing511SPA, { - songID: 124, - level: "10", - chartID: "other_sp10", - data: {}, - }), - ]); - - await CreateFolderChartLookup(TestingIIDXFolderSP10, true); - }); - - t.test("Should work if 511 is >= HARD CLEAR", async (t) => { - const res = await EvaluateGoalForUser(folderGoal, 1, log); - - t.strictSame( - res, - { - achieved: true, - outOf: 5, - progress: 6, - outOfHuman: "HARD CLEAR", - progressHuman: "EX HARD CLEAR (BP: 2)", - }, - "Should correctly evaluate the goal if the user succeeds.", - ); - - t.end(); - }); - - t.test("Should work if other chart is >= HARD CLEAR", async (t) => { - await MONGODB_KILL["personal-bests"].update( - { userID: 1, chartID: Testing511SPA.chartID }, - { $set: { "scoreData.lamp": "CLEAR", "scoreData.enumIndexes.lamp": 4 } }, - ); - - await MONGODB_KILL["personal-bests"].update( - { userID: 1, chartID: "other_sp10" }, - { $set: { "scoreData.lamp": "HARD CLEAR", "scoreData.enumIndexes.lamp": 5 } }, - ); - - const res = await EvaluateGoalForUser(folderGoal, 1, log); - - t.strictSame( - res, - { - achieved: true, - outOf: 5, - progress: 5, - outOfHuman: "HARD CLEAR", - progressHuman: "HARD CLEAR (BP: 2)", - }, - "Should correctly evaluate the goal if the user succeeds.", - ); - - t.end(); - }); - - t.test("Should work if user has no scores", async (t) => { - await MONGODB_KILL["personal-bests"].remove({}); - - const res = await EvaluateGoalForUser(folderGoal, 1, log); - - t.strictSame( - res, - { - achieved: false, - outOf: 5, - progress: null, - outOfHuman: "HARD CLEAR", - progressHuman: "NO DATA", - }, - "Should correctly evaluate the goal if the user has no data.", - ); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); - -t.test("#HumaniseGoalProgress", (t) => { - t.test("Should prefer AAA- over AA+ for AAA goals", (t) => { - t.equal( - HumaniseGoalProgress( - "iidx:SP", - "grade", - IIDX_GRADES.AAA, - mkFakePBIIDXSP({ - scoreData: { - score: 1865, - grade: "AA", - percent: 79.97, - }, - }), - ), - "AAA-208", - ); - - t.end(); - }); - - t.end(); -}); - -t.test("#GetRelevantFolderGoals", (t) => { - t.beforeEach(ResetDBState); - - const fakeFolderGoalDocument: GoalDocument = { - charts: { - type: "folder", - data: "ed9d8c734447ce67d7135c0067441a98cc81aeaf", - }, - game: "iidx", - goalID: "fake_goal_id", - playtype: "SP", - name: "get > 1 ex score on any level 10.", - criteria: { - mode: "single", - value: 1, - key: "score", - }, - }; - - const notFolderGoalDocument: GoalDocument = { - charts: { - type: "folder", - data: "some_fake_folder_id", - }, - game: "iidx", - goalID: "fake_bad_goal_id", - playtype: "SP", - name: "get > 1 ex score on some other folder.", - criteria: { - mode: "single", - value: 1, - key: "score", - }, - }; - - t.beforeEach(async () => { - // @ts-expect-error garbage types - const sp11folder = deepmerge(TestingIIDXFolderSP10, { - data: { level: "11" }, - folderID: "foo", - }); - - await MONGODB_KILL.folders.insert(TestingIIDXFolderSP10); - await MONGODB_KILL.folders.insert(sp11folder); - await MONGODB_KILL.goals.insert(fakeFolderGoalDocument); - await MONGODB_KILL.goals.insert(notFolderGoalDocument); - await CreateFolderChartLookup(TestingIIDXFolderSP10, true); - await CreateFolderChartLookup(sp11folder, true); - }); - - t.test("Should correctly find the goals on this folder.", async (t) => { - const res = await GetRelevantFolderGoals( - ["fake_goal_id", "fake_bad_goal_id"], - [Testing511SPA.chartID], - ); - - t.strictSame( - res, - [fakeFolderGoalDocument], - "Should correctly return only the 511 goal document.", - ); - - t.end(); - }); - - t.end(); -}); - -t.test("#GetRelevantGoals", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - // use the real data so we have enough charts loaded for this to test properly. - await MONGODB_KILL.songs.iidx.remove({}); - await MONGODB_KILL.charts.iidx.remove({}); - await MONGODB_KILL.charts.iidx.insert( - GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array< - ChartDocument<"iidx:DP" | "iidx:SP"> - >, - ); - await MONGODB_KILL.songs.iidx.insert( - GetKTDataJSON("./tachi/tachi-songs-iidx.json") as Array>, - ); - - const lotsOfCharts = await MONGODB_KILL.charts.iidx.find({}, { limit: 20 }); - const goals: Array = lotsOfCharts.map((e) => ({ - charts: { - type: "single", - data: e.chartID, - }, - game: "iidx", - goalID: crypto.randomBytes(20).toString("hex"), - playtype: "SP", - name: "get > 1 ex score on some other folder.", - criteria: { - mode: "single", - value: 1, - key: "score", - }, - })); - - await MONGODB_KILL.goals.insert(goals); - - await MONGODB_KILL["goal-subs"].insert( - goals.map((e) => ({ - achieved: false, - wasInstantlyAchieved: false, - timeAchieved: null, - game: "iidx", - playtype: "SP", - goalID: e.goalID, - lastInteraction: null, - outOf: 5, - outOfHuman: "HARD CLEAR", - progress: null, - progressHuman: "NO DATA", - userID: 1, - wasAssignedStandalone: false, - })), - ); - }); - - t.test( - "Should successfully filter goals to only those that are relevant to the session.", - async (t) => { - // lets pretend our session had scores on charts 0->4 and 20->25. We also only have goals on - // charts 1->20, so only 5 of these should resolve. - const ourCharts = [ - ...(await MONGODB_KILL.charts.iidx.find({}, { limit: 5 })), - ...(await MONGODB_KILL.charts.iidx.find({}, { skip: 20, limit: 5 })), - ]; - - const chartIDs = new Set(ourCharts.map((e) => e.chartID)); - - const res = await GetRelevantGoals("iidx", 1, chartIDs, log); - - t.equal(res.goals.length, 5, "Should correctly resolve to 5 goals."); - t.equal(res.goalSubsMap.size, 5, "Should also return 5 goalSubs."); - - t.end(); - }, - ); - - t.end(); -}); diff --git a/typescript/server/src/lib/targets/goals.test.ts b/typescript/server/src/lib/targets/goals.test.ts new file mode 100644 index 000000000..37a77beab --- /dev/null +++ b/typescript/server/src/lib/targets/goals.test.ts @@ -0,0 +1,676 @@ +import { log } from "#lib/log/log"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf, mkFakePBIIDXSP } from "#test-utils/misc"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { + HC511Goal, + Testing511Song, + Testing511SPA, + TestingIIDXFolderSP10, + TestingIIDXSPScorePB, +} from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { randomBytes } from "node:crypto"; +import { type GoalDocument, IIDX_GRADES, type PBScoreDocument, type ScoreData } from "tachi-common"; +import { describe, expect, it } from "vitest"; + +import { + EvaluateGoalForUser, + GetRelevantFolderGoals, + GetRelevantGoals, + HumaniseGoalProgress, +} from "./goals"; + +async function seedIidx511Chart() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +async function seedExtraIidxCharts() { + await DB.insertInto("song") + .values({ + id: "s-not", + legacy_id: 123, + game_group: "iidx", + title: "Other", + artist: "X", + search_terms: [], + alt_titles: [], + data: { displayVersion: "1", genre: "TEST" }, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: "not_sp10", + legacy_id: "not_sp10", + game: "iidx-sp", + song_id: "s-not", + difficulty: "ANOTHER", + level: "9", + level_num: 9, + is_primary: true, + versions: [], + data: { inGameID: 1, notecount: 100 }, + }) + .execute(); + + await DB.insertInto("song") + .values({ + id: "s-oth", + legacy_id: 124, + game_group: "iidx", + title: "Other 10", + artist: "Y", + search_terms: [], + alt_titles: [], + data: { displayVersion: "1", genre: "TEST" }, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: "other_sp10", + legacy_id: "other_sp10", + game: "iidx-sp", + song_id: "s-oth", + difficulty: "ANOTHER", + level: "10", + level_num: 10, + is_primary: true, + versions: [], + data: { inGameID: 2, notecount: 100 }, + }) + .execute(); +} + +async function seedSecondChartForMulti(fakeOtherChartId: string) { + await DB.insertInto("song") + .values({ + id: "s-fake-other", + legacy_id: 999, + game_group: "iidx", + title: "Fake", + artist: "Z", + search_terms: [], + alt_titles: [], + data: { displayVersion: "1", genre: "TEST" }, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: fakeOtherChartId, + legacy_id: fakeOtherChartId, + game: "iidx-sp", + song_id: "s-fake-other", + difficulty: "ANOTHER", + level: "10", + level_num: 10, + is_primary: true, + versions: [], + data: { inGameID: 3, notecount: 100 }, + }) + .execute(); +} + +async function insertPbFromIidxDoc(userId: number, doc: PBScoreDocument<"iidx-sp">) { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { + ...doc.scoreData, + judgements: doc.scoreData.judgements, + }); + + await DB.insertInto("pb") + .values({ + user_id: userId, + chart_id: doc.chartID, + lens: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(doc.calculatedData), + ranking_value: doc.scoreData.score, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: doc.highlight, + time_achieved: + doc.timeAchieved !== null ? UnixMillisecondsToISO8601(doc.timeAchieved) : null, + }) + .execute(); +} + +async function rewritePbScoreData(userId: number, chartId: string, sd: ScoreData<"iidx-sp">) { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", sd); + + await DB.updateTable("pb") + .set({ + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + ranking_value: sd.score, + }) + .where("user_id", "=", userId) + .where("chart_id", "=", chartId) + .execute(); +} + +describe("EvaluateGoalForUser", () => { + describe("single-chart goals", () => { + it("evaluates success when the user meets the lamp threshold", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + await insertPbFromIidxDoc(userId, TestingIIDXSPScorePB); + + const res = await EvaluateGoalForUser(HC511Goal, userId, log); + + expect(res).toStrictEqual({ + achieved: true, + outOf: 5, + progress: 6, + outOfHuman: "HARD CLEAR", + progressHuman: "EX HARD CLEAR (BP: 2)", + }); + }); + + it("evaluates failure when the user is below the lamp threshold", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + await insertPbFromIidxDoc(userId, TestingIIDXSPScorePB); + await rewritePbScoreData(userId, Testing511SPA.chartID, { + ...TestingIIDXSPScorePB.scoreData, + lamp: "CLEAR", + enumIndexes: { + ...TestingIIDXSPScorePB.scoreData.enumIndexes, + lamp: 4, + }, + }); + + const res = await EvaluateGoalForUser(HC511Goal, userId, log); + + expect(res).toStrictEqual({ + achieved: false, + outOf: 5, + progress: 4, + outOfHuman: "HARD CLEAR", + progressHuman: "CLEAR (BP: 2)", + }); + }); + + it("returns NO DATA when the user has no PB on the chart", async () => { + await seedIidx511Chart(); + const { id: userId } = await seedUser(); + + const res = await EvaluateGoalForUser(HC511Goal, userId, log); + + expect(res).toStrictEqual({ + achieved: false, + outOf: 5, + progress: null, + outOfHuman: "HARD CLEAR", + progressHuman: "NO DATA", + }); + }); + }); + + describe("multi-chart goals", () => { + const fakeOther = "fake_other_chart_id"; + + it("succeeds when any listed chart meets the threshold", async () => { + await seedIidx511Chart(); + await seedSecondChartForMulti(fakeOther); + const { id: userId } = await seedUser(); + + const multiGoal: GoalDocument = dmf(HC511Goal, { + charts: { + type: "multi", + data: [Testing511SPA.chartID, fakeOther], + }, + }); + + await insertPbFromIidxDoc(userId, TestingIIDXSPScorePB); + await insertPbFromIidxDoc( + userId, + dmf(TestingIIDXSPScorePB, { + chartID: fakeOther, + scoreData: { + ...TestingIIDXSPScorePB.scoreData, + lamp: "CLEAR", + enumIndexes: { + ...TestingIIDXSPScorePB.scoreData.enumIndexes, + lamp: 4, + }, + }, + }), + ); + + const res = await EvaluateGoalForUser(multiGoal, userId, log); + + expect(res).toStrictEqual({ + achieved: true, + outOf: 5, + progress: 6, + outOfHuman: "HARD CLEAR", + progressHuman: "EX HARD CLEAR (BP: 2)", + }); + }); + + it("succeeds when a non-primary chart in the set meets the threshold", async () => { + await seedIidx511Chart(); + await seedSecondChartForMulti(fakeOther); + const { id: userId } = await seedUser(); + + const multiGoal: GoalDocument = dmf(HC511Goal, { + charts: { + type: "multi", + data: [Testing511SPA.chartID, fakeOther], + }, + }); + + await insertPbFromIidxDoc(userId, TestingIIDXSPScorePB); + await insertPbFromIidxDoc( + userId, + dmf(TestingIIDXSPScorePB, { + chartID: fakeOther, + scoreData: { + ...TestingIIDXSPScorePB.scoreData, + lamp: "CLEAR", + enumIndexes: { + ...TestingIIDXSPScorePB.scoreData.enumIndexes, + lamp: 4, + }, + }, + }), + ); + + await rewritePbScoreData(userId, Testing511SPA.chartID, { + ...TestingIIDXSPScorePB.scoreData, + lamp: "CLEAR", + enumIndexes: { + ...TestingIIDXSPScorePB.scoreData.enumIndexes, + lamp: 4, + }, + }); + await rewritePbScoreData(userId, fakeOther, { + ...TestingIIDXSPScorePB.scoreData, + lamp: "HARD CLEAR", + enumIndexes: { + ...TestingIIDXSPScorePB.scoreData.enumIndexes, + lamp: 5, + }, + }); + + const res = await EvaluateGoalForUser(multiGoal, userId, log); + + expect(res).toStrictEqual({ + achieved: true, + outOf: 5, + progress: 5, + outOfHuman: "HARD CLEAR", + progressHuman: "HARD CLEAR (BP: 2)", + }); + }); + + it("returns NO DATA when none of the charts have scores", async () => { + await seedIidx511Chart(); + await seedSecondChartForMulti(fakeOther); + const { id: userId } = await seedUser(); + + const multiGoal: GoalDocument = dmf(HC511Goal, { + charts: { + type: "multi", + data: [Testing511SPA.chartID, fakeOther], + }, + }); + + const res = await EvaluateGoalForUser(multiGoal, userId, log); + + expect(res).toStrictEqual({ + achieved: false, + outOf: 5, + progress: null, + outOfHuman: "HARD CLEAR", + progressHuman: "NO DATA", + }); + }); + }); + + describe("folder goals", () => { + async function seedFolderGoalFixtures(userId: number) { + await seedIidx511Chart(); + await seedExtraIidxCharts(); + + await DB.insertInto("folder") + .values({ + id: TestingIIDXFolderSP10.folderID, + legacy_id: TestingIIDXFolderSP10.folderID, + game: "iidx-sp", + inactive: false, + title: TestingIIDXFolderSP10.title, + slug: TestingIIDXFolderSP10.slug, + where: "chart.level_num = 10", + version_filter: null, + search_terms: [], + }) + .execute(); + + await DB.insertInto("folder_chart_lookup") + .values([ + { folder_id: TestingIIDXFolderSP10.folderID, chart_id: Testing511SPA.chartID }, + { folder_id: TestingIIDXFolderSP10.folderID, chart_id: "other_sp10" }, + ]) + .execute(); + + const folderGoal: GoalDocument = dmf(HC511Goal, { + charts: { + type: "folder", + data: TestingIIDXFolderSP10.folderID, + }, + }); + + await insertPbFromIidxDoc(userId, TestingIIDXSPScorePB); + await insertPbFromIidxDoc( + userId, + dmf(TestingIIDXSPScorePB, { + chartID: "other_sp10", + scoreData: { + ...TestingIIDXSPScorePB.scoreData, + lamp: "CLEAR", + enumIndexes: { + ...TestingIIDXSPScorePB.scoreData.enumIndexes, + lamp: 4, + }, + }, + }), + ); + + return folderGoal; + } + + it("succeeds when a folder chart meets the lamp threshold", async () => { + const { id: userId } = await seedUser(); + const folderGoal = await seedFolderGoalFixtures(userId); + + const res = await EvaluateGoalForUser(folderGoal, userId, log); + + expect(res).toStrictEqual({ + achieved: true, + outOf: 5, + progress: 6, + outOfHuman: "HARD CLEAR", + progressHuman: "EX HARD CLEAR (BP: 2)", + }); + }); + + it("succeeds when another level-10 folder chart meets the threshold", async () => { + const { id: userId } = await seedUser(); + const folderGoal = await seedFolderGoalFixtures(userId); + + await rewritePbScoreData(userId, Testing511SPA.chartID, { + ...TestingIIDXSPScorePB.scoreData, + lamp: "CLEAR", + enumIndexes: { + ...TestingIIDXSPScorePB.scoreData.enumIndexes, + lamp: 4, + }, + }); + await rewritePbScoreData(userId, "other_sp10", { + ...TestingIIDXSPScorePB.scoreData, + lamp: "HARD CLEAR", + enumIndexes: { + ...TestingIIDXSPScorePB.scoreData.enumIndexes, + lamp: 5, + }, + }); + + const res = await EvaluateGoalForUser(folderGoal, userId, log); + + expect(res).toStrictEqual({ + achieved: true, + outOf: 5, + progress: 5, + outOfHuman: "HARD CLEAR", + progressHuman: "HARD CLEAR (BP: 2)", + }); + }); + + it("returns NO DATA when the user has no PBs on folder charts", async () => { + const { id: userId } = await seedUser(); + const folderGoal = await seedFolderGoalFixtures(userId); + + await DB.deleteFrom("pb").where("user_id", "=", userId).execute(); + + const res = await EvaluateGoalForUser(folderGoal, userId, log); + + expect(res).toStrictEqual({ + achieved: false, + outOf: 5, + progress: null, + outOfHuman: "HARD CLEAR", + progressHuman: "NO DATA", + }); + }); + }); +}); + +describe("HumaniseGoalProgress", () => { + it("prefers AAA- over AA+ for AAA goals", () => { + expect( + HumaniseGoalProgress( + "iidx-sp", + "grade", + IIDX_GRADES.AAA, + mkFakePBIIDXSP({ + scoreData: { + score: 1865, + grade: "AA", + percent: 79.97, + }, + }), + ), + ).toBe("AAA-208"); + }); +}); + +describe("GetRelevantFolderGoals", () => { + const fakeFolderGoalDocument: GoalDocument = { + charts: { + type: "folder", + data: TestingIIDXFolderSP10.folderID, + }, + game: "iidx-sp", + goalID: "fake_goal_id", + name: "get > 1 ex score on any level 10.", + criteria: { + mode: "single", + value: 1, + key: "score", + }, + }; + + const notFolderGoalDocument: GoalDocument = { + charts: { + type: "folder", + data: "some_fake_folder_id", + }, + game: "iidx-sp", + goalID: "fake_bad_goal_id", + name: "get > 1 ex score on some other folder.", + criteria: { + mode: "single", + value: 1, + key: "score", + }, + }; + + it("returns goals whose folder contains the given chart", async () => { + await seedIidx511Chart(); + + await DB.insertInto("folder") + .values({ + id: TestingIIDXFolderSP10.folderID, + legacy_id: TestingIIDXFolderSP10.folderID, + game: "iidx-sp", + inactive: false, + title: TestingIIDXFolderSP10.title, + slug: TestingIIDXFolderSP10.slug, + where: "chart.level_num = 10", + version_filter: null, + search_terms: [], + }) + .execute(); + + await DB.insertInto("folder_chart_lookup") + .values({ folder_id: TestingIIDXFolderSP10.folderID, chart_id: Testing511SPA.chartID }) + .execute(); + + await DB.insertInto("goal") + .values([ + { + id: fakeFolderGoalDocument.goalID, + game: fakeFolderGoalDocument.game, + name: fakeFolderGoalDocument.name, + charts: fakeFolderGoalDocument.charts, + criteria: fakeFolderGoalDocument.criteria, + }, + { + id: notFolderGoalDocument.goalID, + game: notFolderGoalDocument.game, + name: notFolderGoalDocument.name, + charts: notFolderGoalDocument.charts, + criteria: notFolderGoalDocument.criteria, + }, + ]) + .execute(); + + const res = await GetRelevantFolderGoals( + ["fake_goal_id", "fake_bad_goal_id"], + [Testing511SPA.chartID], + ); + + expect(res).toEqual([fakeFolderGoalDocument]); + }); +}); + +describe("GetRelevantGoals", () => { + it("filters subscriptions to goals tied to charts in the session", async () => { + const { id: userId } = await seedUser(); + + const chartIds: Array = []; + for (let i = 0; i < 25; i++) { + const cid = `rel-goal-${i}`; + chartIds.push(cid); + const songId = `rel-song-${i}`; + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: 10_000 + i, + game_group: "iidx", + title: `T${i}`, + artist: "A", + search_terms: [], + alt_titles: [], + data: { displayVersion: "1", genre: "TEST" }, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: cid, + legacy_id: cid, + game: "iidx-sp", + song_id: songId, + difficulty: "ANOTHER", + level: "10", + level_num: 10, + is_primary: true, + versions: [], + data: { inGameID: i, notecount: 100 }, + }) + .execute(); + } + + const goalRows: Array<{ charts: GoalDocument["charts"]; id: string }> = []; + + for (let i = 0; i < 20; i++) { + const gid = randomBytes(10).toString("hex"); + const cid = chartIds[i]!; + goalRows.push({ + id: gid, + charts: { type: "single", data: cid }, + }); + + await DB.insertInto("goal") + .values({ + id: gid, + game: "iidx-sp", + name: "get > 1 ex score on some other folder.", + charts: { type: "single", data: cid }, + criteria: { + mode: "single", + value: 1, + key: "score", + }, + }) + .execute(); + + await DB.insertInto("goal_sub") + .values({ + goal_id: gid, + user_id: userId, + last_interaction: null, + progress: null, + progress_human: "NO DATA", + out_of: 5, + out_of_human: "HARD CLEAR", + achieved: false, + time_achieved: null, + was_instantly_achieved: false, + was_assigned_standalone: false, + }) + .execute(); + } + + const ourCharts = [...chartIds.slice(0, 5), ...chartIds.slice(20, 25)]; + const chartIDSet = new Set(ourCharts); + + const res = await GetRelevantGoals("iidx-sp", userId, chartIDSet, log); + + expect(res.goals.length).toBe(5); + expect(res.goalSubsMap.size).toBe(5); + }); +}); diff --git a/typescript/server/src/lib/targets/goals.ts b/typescript/server/src/lib/targets/goals.ts index e30eade79..12ebc84ef 100644 --- a/typescript/server/src/lib/targets/goals.ts +++ b/typescript/server/src/lib/targets/goals.ts @@ -1,43 +1,39 @@ import type { GoalCriteriaFormatter } from "#game-implementations/types"; -import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { SubscribeFailReasons } from "#lib/constants/err-codes"; import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { + AttachFolderSlugsToGoals, ToGoalDocument, ToGoalSubscriptionDocument, ToQuestDocument, ToQuestSubscriptionDocument, } from "#lib/db-formats/target-documents"; -import { GetFolderChartIDs } from "#lib/folders/folders.js"; +import { GetFolderChartIDs } from "#lib/folders/folders"; import { type KtLogger, log } from "#lib/log/log"; import { getGoalMetricValueFromPb, LoadPbsForUserOnChartsForGoal, pbMeetsGoalThreshold, -} from "#lib/targets/goal-pb-queries.js"; +} from "#lib/targets/goal-pb-queries"; import DB from "#services/pg/db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import fjsh from "fast-json-stable-hash"; import { sql } from "kysely"; import { - FormatGameGroup, - type GameGroup, - GamePTToV3, - GetGPTConfig, - GetGPTString, + FormatGame, + GetGameConfig, GetScoreMetricConf, - type GPTString, + type GoalDocument, + type GoalSubscriptionDocument, type integer, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_PBScoreDocument, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, - type Playtype, - v3AllGames, - V3ToGameGroup, + LEGACY_GameToGPTString, + type PBScoreDocument, + type QuestDocument, + type QuestSubscriptionDocument, + type V3Game, } from "tachi-common"; import { CreateGoalTitle as CreateGoalName, ValidateGoalChartsAndCriteria } from "./goal-utils"; @@ -62,23 +58,23 @@ export interface EvaluatedGoalReturn { * to multiple games. */ export function CreateGoalID( - charts: MONGO_GoalDocument["charts"], - criteria: MONGO_GoalDocument["criteria"], - game: GameGroup, - playtype: Playtype, + charts: GoalDocument["charts"], + criteria: GoalDocument["criteria"], + game: V3Game, ) { - return `G${fjsh.hash({ charts, criteria, game, playtype }, "sha256")}`; + return `G${fjsh.hash({ charts, criteria, game }, "sha256")}`; } export async function EvaluateGoalForUser( - goal: MONGO_GoalDocument, + goal: GoalDocument, userID: integer, log: KtLogger, ): Promise { const chartIDs = await ResolveGoalCharts(goal); - const gptString = GetGPTString(goal.game, goal.playtype); - const gptConfig = GetGPTConfig(gptString); - const scoreConf = GetScoreMetricConf(gptConfig, goal.criteria.key); + const v3Game = goal.game; + const gptString = LEGACY_GameToGPTString(v3Game); + const gameConfig = GetGameConfig(v3Game); + const scoreConf = GetScoreMetricConf(gameConfig, goal.criteria.key); if (!scoreConf) { throw new Error( @@ -90,7 +86,7 @@ export async function EvaluateGoalForUser( switch (goal.criteria.mode) { case "single": { - const outOfHuman = HumaniseGoalOutOf(gptString, goal.criteria.key, goal.criteria.value); + const outOfHuman = HumaniseGoalOutOf(v3Game, goal.criteria.key, goal.criteria.value); const qualifying = pbs.filter((pb) => pbMeetsGoalThreshold(pb, goal.criteria.key, goal.criteria.value, scoreConf), @@ -110,7 +106,7 @@ export async function EvaluateGoalForUser( res.scoreData[goal.criteria.key], outOfHuman, progressHuman: HumaniseGoalProgress( - gptString, + v3Game, goal.criteria.key, goal.criteria.value, res, @@ -123,7 +119,7 @@ export async function EvaluateGoalForUser( pb, v: getGoalMetricValueFromPb(pb, goal.criteria.key, scoreConf), })) - .filter((e): e is { pb: MONGO_PBScoreDocument; v: number } => e.v !== null) + .filter((e): e is { pb: PBScoreDocument; v: number } => e.v !== null) .sort((a, b) => b.v - a.v); if (scored.length === 0) { @@ -149,7 +145,7 @@ export async function EvaluateGoalForUser( : // @ts-expect-error narrow nextBestScore.scoreData[goal.criteria.key], progressHuman: HumaniseGoalProgress( - gptString, + v3Game, goal.criteria.key, goal.criteria.value, nextBestScore, @@ -186,7 +182,7 @@ export async function EvaluateGoalForUser( log.warn( { goal }, `Invalid goal: ${goal.goalID}, unknown criteria.mode ${ - (goal.criteria as MONGO_GoalDocument["criteria"]).mode + (goal.criteria as GoalDocument["criteria"]).mode }, ignoring.`, ); @@ -200,7 +196,7 @@ export async function EvaluateGoalForUser( * * @returns An array of chartIDs. */ -function ResolveGoalCharts(goal: MONGO_GoalDocument): Array | Promise> { +function ResolveGoalCharts(goal: GoalDocument): Array | Promise> { switch (goal.charts.type) { case "single": return [goal.charts.data]; @@ -215,7 +211,7 @@ function ResolveGoalCharts(goal: MONGO_GoalDocument): Array | Promise { - await ValidateGoalChartsAndCriteria(charts, criteria, game, playtype); + charts: GoalDocument["charts"], + criteria: GoalDocument["criteria"], + game: V3Game, +): Promise { + await ValidateGoalChartsAndCriteria(charts, criteria, game); // @ts-expect-error It's complaining because the potential criteria types might mismatch. - const MONGO_GoalDocument: MONGO_GoalDocument = { + const GoalDocument: GoalDocument = { game, - playtype, criteria, charts, - goalID: CreateGoalID(charts, criteria, game, playtype), - name: await CreateGoalName(charts, criteria, game, playtype), + goalID: CreateGoalID(charts, criteria, game), + name: await CreateGoalName(charts, criteria, game), }; - return MONGO_GoalDocument; + return GoalDocument; } -async function ensureGoalRow(doc: MONGO_GoalDocument) { - const v3Game = GamePTToV3(doc.game, doc.playtype); +async function ensureGoalRow(doc: GoalDocument) { + const v3Game = doc.game; const existed = await DB.selectFrom("goal") .select("goal.id") @@ -349,15 +343,15 @@ async function ensureGoalRow(doc: MONGO_GoalDocument) { */ export async function SubscribeToGoal( userID: integer, - MONGO_GoalDocument: MONGO_GoalDocument, + GoalDocument: GoalDocument, isStandaloneAssignment: boolean, ) { - await ensureGoalRow(MONGO_GoalDocument); + await ensureGoalRow(GoalDocument); const existingSub = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) - .where("goal_sub.goal_id", "=", MONGO_GoalDocument.goalID) + .where("goal_sub.goal_id", "=", GoalDocument.goalID) .where("goal_sub.user_id", "=", userID) .executeTakeFirst(); @@ -374,21 +368,21 @@ export async function SubscribeToGoal( await DB.updateTable("goal_sub") .set({ was_assigned_standalone: true }) - .where("goal_id", "=", MONGO_GoalDocument.goalID) + .where("goal_id", "=", GoalDocument.goalID) .where("user_id", "=", userID) .execute(); const updated = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) - .where("goal_sub.goal_id", "=", MONGO_GoalDocument.goalID) + .where("goal_sub.goal_id", "=", GoalDocument.goalID) .where("goal_sub.user_id", "=", userID) .executeTakeFirstOrThrow(); return ToGoalSubscriptionDocument(updated); } - const result = await EvaluateGoalForUser(MONGO_GoalDocument, userID, log); + const result = await EvaluateGoalForUser(GoalDocument, userID, log); if (!result) { throw new Error(`Couldn't evaluate goal? See previous logs.`); @@ -408,14 +402,13 @@ export async function SubscribeToGoal( progressHuman: result.progressHuman, userID, lastInteraction: null, - game: MONGO_GoalDocument.game, - playtype: MONGO_GoalDocument.playtype, - goalID: MONGO_GoalDocument.goalID, + game: GoalDocument.game, + goalID: GoalDocument.goalID, wasInstantlyAchieved: result.achieved, wasAssignedStandalone: isStandaloneAssignment, }; - const goalSub: MONGO_GoalSubscriptionDocument = result.achieved + const goalSub: GoalSubscriptionDocument = result.achieved ? { ...baseSub, achieved: true, @@ -429,7 +422,7 @@ export async function SubscribeToGoal( await DB.insertInto("goal_sub") .values({ - goal_id: MONGO_GoalDocument.goalID, + goal_id: GoalDocument.goalID, user_id: userID, last_interaction: null, progress: goalSub.progress, @@ -446,9 +439,7 @@ export async function SubscribeToGoal( return goalSub; } -export async function GetQuestsThatContainGoal( - goalID: string, -): Promise> { +export async function GetQuestsThatContainGoal(goalID: string): Promise> { const rows = await DB.selectFrom("quest") .select(SELECT_QUEST) .where( @@ -472,9 +463,9 @@ export async function GetQuestsThatContainGoal( * If this query matches none, an empty array is returned. */ export async function GetQuestSubsWhichDependOnThisGoalSub( - goalSub: MONGO_GoalSubscriptionDocument, -): Promise> { - const v3Game = GamePTToV3(goalSub.game, goalSub.playtype); + goalSub: GoalSubscriptionDocument, +): Promise> { + const v3Game = goalSub.game; const subRows = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") @@ -529,7 +520,7 @@ export async function GetQuestSubsWhichDependOnThisGoalSub( * only be explicitly un-assigned. */ export async function UnsubscribeFromGoal( - goalSub: MONGO_GoalSubscriptionDocument, + goalSub: GoalSubscriptionDocument, preventStandaloneRemoval: boolean, ) { const dependencies = await GetGoalDependencies(goalSub); @@ -566,7 +557,7 @@ export async function UnsubscribeFromGoal( * Failing that, the goal will return "WAS_ORPHAN", there's no reason this goal * should be subscribed to the user -- it's safe to remove for any reason. */ -export async function GetGoalDependencies(goalSub: MONGO_GoalSubscriptionDocument) { +export async function GetGoalDependencies(goalSub: GoalSubscriptionDocument) { const parentQuests = await GetQuestSubsWhichDependOnThisGoalSub(goalSub); if (parentQuests.length) { @@ -590,18 +581,12 @@ export async function GetGoalDependencies(goalSub: MONGO_GoalSubscriptionDocumen * for example, a quest was removed, now they are left with some stranded goals that we * don't want to keep around. */ -export async function UnsubscribeFromOrphanedGoalSubs( - userID: integer, - game: GameGroup, - playtype: Playtype, -) { - const v3Game = GamePTToV3(game, playtype); - +export async function UnsubscribeFromOrphanedGoalSubs(userID: integer, game: V3Game) { const subRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) - .where("goal.game", "=", v3Game) + .where("goal.game", "=", game) .execute(); const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); @@ -622,10 +607,7 @@ export async function UnsubscribeFromOrphanedGoalSubs( if (toRemove.length > 0) { log.info( - `Removing ${toRemove.length} goals from user ${userID} on ${FormatGameGroup( - game, - playtype, - )} as they were orphanned.`, + `Removing ${toRemove.length} goals from user ${userID} on ${FormatGame(game)} as they were orphanned.`, ); await DB.deleteFrom("goal_sub") @@ -636,7 +618,7 @@ export async function UnsubscribeFromOrphanedGoalSubs( } /** - * Gets the goals the user has set for this game and playtype. + * Gets the goals the user has set for this game. * Then, filters it based on the chartIDs involved in this import. * * This optimisation allows users to have *lots* of goals, but only ever @@ -647,22 +629,20 @@ export async function UnsubscribeFromOrphanedGoalSubs( * @returns An array of Goals, and an array of goalSubs. */ export async function GetRelevantGoals( - game: GameGroup, + game: V3Game, userID: integer, chartIDs: Set, log: KtLogger, onlyUnachieved = false, ): Promise<{ - goals: Array; - goalSubsMap: Map; + goals: Array; + goalSubsMap: Map; }> { - const v3GamesForGroup = v3AllGames.filter((v) => V3ToGameGroup(v) === game); - let q = DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) - .where("goal.game", "in", v3GamesForGroup); + .where("goal.game", "=", game); if (onlyUnachieved) { q = q.where("goal_sub.achieved", "=", false); @@ -691,7 +671,7 @@ export async function GetRelevantGoals( .where("goal.id", "in", goalIDs) .execute(); - const directMulti: Array = []; + const directMulti: Array = []; for (const g of goalRows) { const doc = ToGoalDocument(g); @@ -708,9 +688,10 @@ export async function GetRelevantGoals( const folderGoals = await GetRelevantFolderGoals(goalIDs, chartIDsArr); const goals = [...directMulti, ...folderGoals]; + await AttachFolderSlugsToGoals(goals); const goalSet = new Set(goals.map((e) => e.goalID)); - const goalSubsMap: Map = new Map(); + const goalSubsMap: Map = new Map(); for (const goalSub of goalSubs) { if (!goalSet.has(goalSub.goalID)) { @@ -753,13 +734,8 @@ export async function GetRelevantFolderGoals(goalIDs: Array, chartIDsArr * * This happens if the goal schema changes, but that really is quite rare. */ -export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalDocument) { - const newGoalID = CreateGoalID( - newGoal.charts, - newGoal.criteria, - newGoal.game, - newGoal.playtype, - ); +export async function EditGoal(oldGoal: GoalDocument, newGoal: GoalDocument) { + const newGoalID = CreateGoalID(newGoal.charts, newGoal.criteria, newGoal.game); newGoal.goalID = newGoalID; @@ -771,7 +747,7 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD const quests = await GetQuestsThatContainGoal(oldGoal.goalID); for (const quest of quests) { - const newQuestData: MONGO_QuestDocument["questData"] = []; + const newQuestData: QuestDocument["questData"] = []; for (const qd of quest.questData) { const goals = []; @@ -798,20 +774,13 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD await DB.deleteFrom("goal").where("goal.id", "=", oldGoal.goalID).execute(); - newGoal.name = await CreateGoalName( - newGoal.charts, - newGoal.criteria, - newGoal.game, - newGoal.playtype, - ); - - const v3Game = GamePTToV3(newGoal.game, newGoal.playtype); + newGoal.name = await CreateGoalName(newGoal.charts, newGoal.criteria, newGoal.game); try { await DB.insertInto("goal") .values({ id: newGoal.goalID, - game: v3Game, + game: newGoal.game, name: newGoal.name, charts: newGoal.charts, criteria: newGoal.criteria, diff --git a/typescript/server/src/lib/targets/quests.ts b/typescript/server/src/lib/targets/quests.ts index a1ddcfb02..22d3bbe9d 100644 --- a/typescript/server/src/lib/targets/quests.ts +++ b/typescript/server/src/lib/targets/quests.ts @@ -1,5 +1,3 @@ -import type { Game } from "tachi-db"; - import { SubscribeFailReasons } from "#lib/constants/err-codes"; import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { @@ -20,14 +18,14 @@ import { UnixMillisecondsToISO8601 } from "#utils/time"; import { sql } from "kysely"; import { type GameGroup, - GamePTToV3, + type GoalDocument, + type GoalSubscriptionDocument, type integer, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, - type Playtype, - V3ToGamePT, + LEGACY_GameGroupPTToGame, + type LEGACY_Playtype, + type QuestDocument, + type QuestSubscriptionDocument, + type V3Game, } from "tachi-common"; import { @@ -42,14 +40,14 @@ import { * Retrieves the goalID documents in a single array from the * nested structure of quests. */ -export function GetGoalIDsFromQuest(quest: MONGO_QuestDocument) { +export function GetGoalIDsFromQuest(quest: QuestDocument) { return quest.questData.map((e) => e.goals.map((e) => e.goalID)).flat(1); } /** * Return all the goals inside this quest. */ -export async function GetGoalsInQuest(quest: MONGO_QuestDocument) { +export async function GetGoalsInQuest(quest: QuestDocument) { const goalIDs = GetGoalIDsFromQuest(quest); const goals = await DB.selectFrom("goal") @@ -80,7 +78,7 @@ export async function GetGoalsInQuest(quest: MONGO_QuestDocument) { /** * Return all the goals inside these quests */ -export async function GetGoalsInQuests(quests: Array) { +export async function GetGoalsInQuests(quests: Array) { const goalIDs = quests.flatMap((quest) => GetGoalIDsFromQuest(quest)); if (goalIDs.length === 0) { @@ -99,7 +97,7 @@ export async function GetGoalsInQuests(quests: Array) { * Work out how many goals need to be achieved for this * quest to be considered completed. */ -export function CalculateQuestOutOf(quest: MONGO_QuestDocument) { +export function CalculateQuestOutOf(quest: QuestDocument) { const goalIDs = GetGoalIDsFromQuest(quest); return goalIDs.length; @@ -115,7 +113,7 @@ type EvaluatedGoalResult = { goalID: string } & EvaluatedGoalReturn; * their overall progress, what the quest was outOf, and whether they achieved it or * not. */ -export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestDocument) { +export async function EvaluateQuestProgress(userID: integer, quest: QuestDocument) { const goals = await GetGoalsInQuest(quest); const isSubscribedToQuest = await DB.selectFrom("quest_sub") @@ -124,7 +122,7 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD .where("quest_sub.user_id", "=", userID) .executeTakeFirst(); - const goalSubMap = new Map(); + const goalSubMap = new Map(); if (isSubscribedToQuest) { const goalSubRows = await DB.selectFrom("goal_sub") @@ -226,8 +224,8 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD } interface QuestSubscriptionReturns { - questSub: MONGO_QuestSubscriptionDocument; - goals: Array; + questSub: QuestSubscriptionDocument; + goals: Array; goalResults: Array; } @@ -240,12 +238,12 @@ interface QuestSubscriptionReturns { */ export async function SubscribeToQuest( userID: integer, - quest: MONGO_QuestDocument, + quest: QuestDocument, denyInstantAchievement: false, ): Promise; export async function SubscribeToQuest( userID: integer, - quest: MONGO_QuestDocument, + quest: QuestDocument, denyInstantAchievement = true, ): Promise< | QuestSubscriptionReturns @@ -277,11 +275,10 @@ export async function SubscribeToQuest( questID: quest.questID, wasInstantlyAchieved: result.achieved, game: quest.game, - playtype: quest.playtype, lastInteraction: null, }; - const questSub: MONGO_QuestSubscriptionDocument = result.achieved + const questSub: QuestSubscriptionDocument = result.achieved ? { ...questSubBase, achieved: true, @@ -339,11 +336,7 @@ export async function UpdateQuestSubscriptions(questID: string) { await DB.deleteFrom("quest_sub").where("quest_sub.quest_id", "=", questID).execute(); await Promise.all( - subscriptions.map((e) => { - const { game, playtype } = V3ToGamePT(e.quest_game as Game); - - return UnsubscribeFromOrphanedGoalSubs(e.user_id, game, playtype); - }), + subscriptions.map((e) => UnsubscribeFromOrphanedGoalSubs(e.user_id, e.quest_game)), ); log.info(`Quest ${questID} has been deleted. Unsubscribed ${subscriptions.length} users.`); @@ -367,7 +360,6 @@ export async function UpdateQuestSubscriptions(questID: string) { content: { questID, game: quest.game, - playtype: quest.playtype, }, }, ); @@ -380,8 +372,8 @@ export async function UpdateQuestSubscriptions(questID: string) { * Returns nothing. */ export async function UnsubscribeFromQuest( - questSub: MONGO_QuestSubscriptionDocument, - quest: MONGO_QuestDocument, + questSub: QuestSubscriptionDocument, + quest: QuestDocument, ) { const goalIDs = GetGoalIDsFromQuest(quest); @@ -408,11 +400,9 @@ export async function UnsubscribeFromQuest( */ export async function GetParentQuests( userID: integer, - game: GameGroup, - playtype: Playtype, - goalSubs: Array, + game: V3Game, + goalSubs: Array, ) { - const v3Game = GamePTToV3(game, playtype); const goalIds = goalSubs.map((e) => e.goalID); if (goalIds.length === 0) { @@ -423,7 +413,7 @@ export async function GetParentQuests( .innerJoin("quest", "quest.id", "quest_sub.quest_id") .select("quest_sub.quest_id") .where("quest_sub.user_id", "=", userID) - .where("quest.game", "=", v3Game) + .where("quest.game", "=", game) .execute(); const questSubIDs = questSubRows.map((e) => e.quest_id); @@ -451,8 +441,8 @@ export async function GetParentQuests( /** * Find all quests not in any questlines. */ -export async function FindStandaloneQuests(game: GameGroup, playtype: Playtype) { - const v3Game = GamePTToV3(game, playtype); +export async function FindStandaloneQuests(game: GameGroup, playtype: LEGACY_Playtype) { + const v3Game = LEGACY_GameGroupPTToGame(game, playtype); const rows = await DB.selectFrom("quest") .select(SELECT_QUEST) diff --git a/typescript/server/src/lib/v3/migration-tools.test.ts b/typescript/server/src/lib/v3/migration-tools.test.ts index 310ae7cd1..d356481e3 100644 --- a/typescript/server/src/lib/v3/migration-tools.test.ts +++ b/typescript/server/src/lib/v3/migration-tools.test.ts @@ -1,9 +1,9 @@ -import { type MONGO_ScoreData, type PgScoreData, SDVX_GRADES, SDVX_LAMPS } from "tachi-common"; +import { type PgScoreData, type ScoreData, SDVX_GRADES, SDVX_LAMPS } from "tachi-common"; import { describe, expect, it } from "vitest"; -import { mongoScoreDataToPg, pgScoreDataToMongo } from "./migration-tools.js"; +import { mongoScoreDataToPg, pgScoreDataToMongo } from "./migration-tools"; -const sdvxScoreData: MONGO_ScoreData<"sdvx:Single"> = { +const sdvxScoreData: ScoreData<"sdvx"> = { score: 9_876_543, lamp: "EXCESSIVE CLEAR", grade: "S", @@ -74,10 +74,8 @@ describe("pgScoreDataToMongo", () => { }); }); - it("roundtrips as expected (sdvx:Single)", () => { - const gpt = "sdvx:Single"; - - const pgScoreData = mongoScoreDataToPg(gpt, sdvxScoreData); + it("roundtrips as expected (sdvx)", () => { + const pgScoreData = mongoScoreDataToPg("sdvx", sdvxScoreData); const merged = pgScoreDataToMongo("sdvx", pgScoreData); expect(merged).toStrictEqual(sdvxScoreData); @@ -86,9 +84,7 @@ describe("pgScoreDataToMongo", () => { describe("mongoScoreDataToPg", () => { it("converts nicely", () => { - const gpt = "sdvx:Single"; - - const { data, derived, judgements } = mongoScoreDataToPg(gpt, sdvxScoreData); + const { data, derived, judgements } = mongoScoreDataToPg("sdvx", sdvxScoreData); expect(data).toStrictEqual({ lamp: SDVX_LAMPS.EXCESSIVE_CLEAR, diff --git a/typescript/server/src/lib/v3/migration-tools.ts b/typescript/server/src/lib/v3/migration-tools.ts index aa501918c..f261ab210 100644 --- a/typescript/server/src/lib/v3/migration-tools.ts +++ b/typescript/server/src/lib/v3/migration-tools.ts @@ -1,22 +1,18 @@ -import { DeleteUndefinedProps } from "#utils/misc.js"; +import { DeleteUndefinedProps } from "#utils/misc"; import { - GetGPTConfig, - type GPTString, - type GPTStringToV3Game, + GetGameConfig, type integer, - type MONGO_ScoreData, type MongoDerivedMetrics, type MongoOptionalMetrics, type MongoProvidedMetrics, type PgScoreData, + type ScoreData, type V3Game, - type V3GameToGPTString, - V3GetGameConfig, } from "tachi-common"; -interface RetVal { - derived: MongoDerivedMetrics[GPT]; - data: MongoOptionalMetrics[GPT] & MongoProvidedMetrics[GPT]; +interface RetVal { + derived: MongoDerivedMetrics[TGame]; + data: MongoOptionalMetrics[TGame] & MongoProvidedMetrics[TGame]; } /** @@ -43,11 +39,11 @@ function applyOrdinals( return result; } -function splitScoreData( - gpt: GPT, - sd: MONGO_ScoreData, -): RetVal { - const config = GetGPTConfig(gpt); +function splitScoreData( + game: TGame, + sd: ScoreData, +): RetVal { + const config = GetGameConfig(game); const scoreData = sd as any; @@ -84,13 +80,13 @@ function splitScoreData( return eventualOut; } -export function mongoScoreDataToPg( - gpt: GPT, - scoreData: MONGO_ScoreData, -): PgScoreData { - const { data, derived } = splitScoreData(gpt, scoreData); +export function mongoScoreDataToPg( + game: TGame, + scoreData: ScoreData, +): PgScoreData { + const { data, derived } = splitScoreData(game, scoreData); - const config = GetGPTConfig(gpt); + const config = GetGameConfig(game); return { data: applyOrdinals(data, config.providedMetrics as any) as any, @@ -100,17 +96,17 @@ export function mongoScoreDataToPg( } /** - * Reconstruct API {@link MONGO_ScoreData} from Postgres `data` / `derived_data` JSON blobs + * Reconstruct API {@link ScoreData} from Postgres `data` / `derived_data` JSON blobs * and the `judgements` column (the inverse of {@link mongoScoreDataToPg}). */ -export function pgScoreDataToMongo( - game: G, - scoreData: PgScoreData, -): MONGO_ScoreData { +export function pgScoreDataToMongo( + game: TGame, + scoreData: PgScoreData, +): ScoreData { const data = scoreData.data as any; const derived = scoreData.derived as any; - const config = V3GetGameConfig(game); + const config = GetGameConfig(game); const eventualOut: any = { enumIndexes: {}, diff --git a/typescript/server/src/scripts/anonymise-db.ts b/typescript/server/src/scripts/anonymise-db.ts index 33bf0628b..59f38c037 100644 --- a/typescript/server/src/scripts/anonymise-db.ts +++ b/typescript/server/src/scripts/anonymise-db.ts @@ -1,5 +1,4 @@ import { log } from "#lib/log/log"; -import { execSync } from "child_process"; import { Command } from "commander"; import monk from "monk"; diff --git a/typescript/server/src/scripts/load-seeds-pg.ts b/typescript/server/src/scripts/load-seeds-pg.ts index 4be6a312e..6e5a5a513 100644 --- a/typescript/server/src/scripts/load-seeds-pg.ts +++ b/typescript/server/src/scripts/load-seeds-pg.ts @@ -20,9 +20,9 @@ import { Kysely, PostgresDialect } from "kysely"; import path from "path"; import { Pool } from "pg"; -import { buildChartIdMap, importSeeds, importSeedsSubset, toPgGame } from "../services/pg/seeds"; +import { buildChartIdMap, importSeeds, importSeedsSubset } from "../services/pg/seeds"; -export { buildChartIdMap, importSeeds, importSeedsSubset, toPgGame }; +export { buildChartIdMap, importSeeds, importSeedsSubset }; // ── Standalone entrypoint ────────────────────────────────────────────────── diff --git a/typescript/server/src/scripts/migrate-to-postgres.ts b/typescript/server/src/scripts/migrate-to-postgres.ts index a3a448c05..1e2b72aa9 100644 --- a/typescript/server/src/scripts/migrate-to-postgres.ts +++ b/typescript/server/src/scripts/migrate-to-postgres.ts @@ -66,31 +66,29 @@ import monk from "monk"; import path from "path"; import { Pool } from "pg"; import { - GetGPTString, - GPTStringToV3, - type MONGO_CGCardInfo, - type MONGO_ClassAchievementDocument, - type MONGO_FervidexSettingsDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_ImportDocument, - type MONGO_ImportTimingsDocument, - type MONGO_KaiAuthDocument, - type MONGO_KsHookSettingsDocument, - type MONGO_MytCardInfo, - type MONGO_NotificationDocument, - type MONGO_OrphanChartDocument, - type MONGO_QuestSubscriptionDocument, - type MONGO_RecentlyViewedFolderDocument, - type MONGO_ScoreDocument, - type MONGO_SessionDocument, - type MONGO_TachiAPIClientDocument, - type MONGO_UGPTSettingsDocument, - type MONGO_UserDocument, - type MONGO_UserGameStats, - type MONGO_UserGameStatsSnapshotDocument, - type MONGO_UserNameChangeDocument, - type MONGO_UserSettingsDocument, + ALL_GAMES, + type CGCardInfo, + type ClassAchievementDocument, + type FervidexSettingsDocument, + type GoalSubscriptionDocument, + type ImportDocument, + type ImportTimingsDocument, + type KaiAuthDocument, + type KsHookSettingsDocument, + type MytCardInfo, + type NotificationDocument, + type OrphanChartDocument, + type QuestSubscriptionDocument, + type ScoreDocument, + type SessionDocument, + type TachiAPIClientDocument, + type UGPTSettingsDocument, UserAuthLevels, + type UserDocument, + type UserGameStats, + type UserGameStatsSnapshotDocument, + type UserNameChangeDocument, + type UserSettingsDocument, } from "tachi-common"; import { buildChartIdMap, importSeeds } from "./load-seeds-pg"; @@ -99,7 +97,7 @@ import { buildChartIdMap, importSeeds } from "./load-seeds-pg"; // Extension types for MongoDB documents that have extra fields not in the TS interface // ────────────────────────────────────────────────────────────────────────────── -/** MONGO_InviteCodeDocument extended – always has all fields in practice. */ +/** InviteCodeDocument extended – always has all fields in practice. */ interface InviteCodeDocumentFull { code: string; createdBy: number; @@ -173,6 +171,19 @@ function toGame(game: string, playtype: string): PgGame { return `${game}-${playtype.toLowerCase()}` as PgGame; } +/** + * Handles both legacy Mongo `{ game: GameGroup, playtype }` rows and v3 `{ game: V3Game }` rows. + */ +function mongoGameToPg(game: string, playtype?: string): PgGame { + if ((ALL_GAMES as readonly string[]).includes(game)) { + return game as PgGame; + } + if (playtype === undefined) { + throw new Error(`[migrate] Missing playtype for legacy game group: ${game}`); + } + return toGame(game, playtype); +} + // ────────────────────────────────────────────────────────────────────────────── // Auth level helper // ────────────────────────────────────────────────────────────────────────────── @@ -228,10 +239,6 @@ const INSERT_CHUNK = 500; /** JSON text form of U+0000 that JSON.stringify uses; Postgres jsonb rejects this in strings. */ const JSON_TEXT_ESCAPE_U0000 = ["\\", "u0000"].join(""); -function jsonStringHasPostgresRejectedNul(s: string): boolean { - return s.includes("\u0000") || s.includes(JSON_TEXT_ESCAPE_U0000); -} - /** * Some chart_id sha256s in fervidex imports have nulbytes at the end; which are illegal in postgres serializations. */ @@ -245,38 +252,6 @@ function sanitizeOrphanScoreJsonForPostgres(value: unknown): unknown { return JSON.parse(stripped) as unknown; } -/** - * Logs when a value destined for Postgres (jsonb or text) contains NUL / JSON \\u0000, - * which Postgres rejects (22P05). - */ -function logPostgresNulColumnIssue( - scopeLabel: string, - columnName: string, - value: string, - meta: Record, -): void { - const idxLiteral = value.indexOf("\u0000"); - const idxEscaped = value.indexOf(JSON_TEXT_ESCAPE_U0000); - const idx = idxLiteral !== -1 ? idxLiteral : idxEscaped; - const kind = - idxLiteral !== -1 - ? "literal U+0000 in string" - : "JSON \\u0000 escape (disallowed by Postgres jsonb)"; - const snippet = - idx === -1 - ? undefined - : value.slice(Math.max(0, idx - 48), Math.min(value.length, idx + 64)); - - console.warn( - `[${scopeLabel}] column "${columnName}" ${kind} — Postgres will reject this row. Source document:`, - { - ...meta, - firstOccurrenceAtIndex: idx, - ...(snippet !== undefined ? { snippetAroundFirstOccurrence: snippet } : {}), - }, - ); -} - async function batchInsert( table: T, rows: ReadonlyArray>, @@ -406,7 +381,7 @@ async function main(): Promise { // ── account + account_badge ─────────────────────────────────────────────── { console.log("\n[account / account_badge]"); - const users = await mongoDB.get("users").find({}); + const users = await mongoDB.get("users").find({}); const accountRows: Array = users.map((u) => ({ id: u.id, @@ -442,7 +417,7 @@ async function main(): Promise { // ── orphan_chart + orphan_chart_user ────────────────────────────────────── { console.log("\n[orphan_chart / orphan_chart_user]"); - const orphans = await mongoDB.get("orphan-chart-queue").find({}); + const orphans = await mongoDB.get("orphan-chart-queue").find({}); const orphanRows: Array = []; const orphanUserRows: Array = []; @@ -452,7 +427,7 @@ async function main(): Promise { orphanRows.push({ id: chartId, - game: o.gptString as PgGame, + game: mongoGameToPg(o.game), chart_doc: JSON.stringify(o.chartDoc), song_doc: JSON.stringify(o.songDoc), }); @@ -524,7 +499,7 @@ async function main(): Promise { // ── account_settings + account_following ────────────────────────────────── { console.log("\n[account_settings / account_following]"); - const settings = await mongoDB.get("user-settings").find({}); + const settings = await mongoDB.get("user-settings").find({}); const settingRows: Array = settings.map((s) => ({ user_id: s.userID, @@ -552,9 +527,7 @@ async function main(): Promise { // ── account_username_change ─────────────────────────────────────────────── { console.log("\n[account_username_change]"); - const changes = await mongoDB - .get("user-name-changes") - .find({}); + const changes = await mongoDB.get("user-name-changes").find({}); const changeRows: Array = changes.map((c) => ({ user_id: c.userID, @@ -587,7 +560,7 @@ async function main(): Promise { // ── priv_api_client ─────────────────────────────────────────────────────── { console.log("\n[priv_api_client]"); - const clients = await mongoDB.get("api-clients").find({}); + const clients = await mongoDB.get("api-clients").find({}); const clientRows: Array = clients.map((c) => { // Old MongoDB data uses dash-separated permission names ("customise-profile"), @@ -619,7 +592,7 @@ async function main(): Promise { // ── priv_invite ─────────────────────────────────────────────────────────── { console.log("\n[priv_invite]"); - // MONGO_InviteCodeDocument is a discriminated union; use the full flattened type. + // InviteCodeDocument is a discriminated union; use the full flattened type. const invites = await mongoDB.get("invites").find({}); const inviteRows: Array = invites.map((inv) => ({ @@ -638,7 +611,7 @@ async function main(): Promise { // ── priv_svc_kai_auth_token ─────────────────────────────────────────────── { console.log("\n[priv_svc_kai_auth_token]"); - const kaiTokens = await mongoDB.get("kai-auth-tokens").find({}); + const kaiTokens = await mongoDB.get("kai-auth-tokens").find({}); const tokenRows: Array = kaiTokens.map((k) => ({ user_id: k.userID, @@ -654,7 +627,7 @@ async function main(): Promise { // ── priv_svc_cg_card_info ───────────────────────────────────────────────── { console.log("\n[priv_svc_cg_card_info]"); - const cgCards = await mongoDB.get("cg-card-info").find({}); + const cgCards = await mongoDB.get("cg-card-info").find({}); const cgRows: Array = []; @@ -679,7 +652,7 @@ async function main(): Promise { // ── priv_svc_myt_card_info ──────────────────────────────────────────────── { console.log("\n[priv_svc_myt_card_info]"); - const mytCards = await mongoDB.get("myt-card-info").find({}); + const mytCards = await mongoDB.get("myt-card-info").find({}); const mytRows: Array = []; @@ -704,9 +677,7 @@ async function main(): Promise { // ── svc_fer_settings + priv_svc_fer_card ───────────────────────────────── { console.log("\n[svc_fer_settings / priv_svc_fer_card]"); - const ferSettings = await mongoDB - .get("fer-settings") - .find({}); + const ferSettings = await mongoDB.get("fer-settings").find({}); const ferRows: Array = ferSettings.map((f) => ({ user_id: f.userID, @@ -731,7 +702,7 @@ async function main(): Promise { { console.log("\n[svc_kshook_sv6c_settings]"); const ksSettings = await mongoDB - .get("kshook-sv6c-settings") + .get("kshook-sv6c-settings") .find({}); const ksRows: Array = ksSettings.map((k) => ({ @@ -785,9 +756,7 @@ async function main(): Promise { // ── notification ────────────────────────────────────────────────────────── { console.log("\n[notification]"); - const notifications = await mongoDB - .get("notifications") - .find({}); + const notifications = await mongoDB.get("notifications").find({}); const notifRows: Array = notifications.map((n) => ({ title: n.title, @@ -805,16 +774,14 @@ async function main(): Promise { // ── game_settings + game_settings_showcase + game_rival ────────────────── { console.log("\n[game_settings / game_settings_showcase / game_rival]"); - const ugptSettings = await mongoDB - .get("game-settings") - .find({}); + const ugptSettings = await mongoDB.get("game-settings").find({}); const settingsRows: Array = []; const showcaseRows: Array = []; const rivalRows: Array = []; for (const s of ugptSettings) { - const game = toGame(s.game, s.playtype); + const game = mongoGameToPg(s.game, (s as { playtype?: string }).playtype); const prefs = s.preferences; settingsRows.push({ @@ -855,11 +822,11 @@ async function main(): Promise { // ── game_profile ─────────────────────────────────────────────────────────── { console.log("\n[game_profile]"); - const gameStats = await mongoDB.get("game-stats").find({}); + const gameStats = await mongoDB.get("game-stats").find({}); const statsRows: Array = gameStats.map((gs) => ({ user_id: gs.userID, - game: toGame(gs.game, gs.playtype), + game: mongoGameToPg(gs.game, (gs as { playtype?: string }).playtype), ratings: JSON.stringify(gs.ratings), classes: JSON.stringify(gs.classes), })); @@ -870,12 +837,12 @@ async function main(): Promise { // ── game_stats_snapshot ─────────────────────────────────────────────────── console.log("\n[game_stats_snapshot]"); - await streamMigrate( + await streamMigrate( "game-stats-snapshots", "game_stats_snapshot", (snap) => ({ user_id: snap.userID, - game: toGame(snap.game, snap.playtype), + game: mongoGameToPg(snap.game, (snap as { playtype?: string }).playtype), timestamp: tsReq(snap.timestamp), playcount: snap.playcount, ratings: JSON.stringify(snap.ratings), @@ -888,11 +855,11 @@ async function main(): Promise { { console.log("\n[class_achievement]"); const achievements = await mongoDB - .get("class-achievements") + .get("class-achievements") .find({}); const achievementRows: Array = achievements.map((a) => ({ - game: toGame(a.game, a.playtype), + game: mongoGameToPg(a.game, (a as { playtype?: string }).playtype), user_id: a.userID, class_set: a.classSet as string, // classOldValue can be null in Mongo; Postgres requires a string — use empty string. @@ -907,7 +874,7 @@ async function main(): Promise { // ── session ─────────────────────────────────────────────────────────────── console.log("\n[session]"); - await streamCollection<{ _id?: unknown } & MONGO_SessionDocument>( + await streamCollection<{ _id?: unknown } & SessionDocument>( "sessions", async (docs) => { const rows: Array = []; @@ -916,7 +883,7 @@ async function main(): Promise { rows.push({ id: s.sessionID, user_id: s.userID, - game: toGame(s.game, s.playtype), + game: mongoGameToPg(s.game, (s as { playtype?: string }).playtype), name: s.name, description: s.desc, time_inserted: tsReq(s.timeInserted), @@ -945,7 +912,7 @@ async function main(): Promise { ); console.log("\n[import / import_game / import_error / import_class / import_session]"); - await streamCollection( + await streamCollection( "imports", async (docs) => { const importRows: Array = []; @@ -963,7 +930,7 @@ async function main(): Promise { const scoreServiceMap = new Map( ( await mongoDB - .get("scores") + .get("scores") .find( { scoreID: { $in: firstScoreIds } }, { projection: { scoreID: 1, service: 1 } }, @@ -981,18 +948,21 @@ async function main(): Promise { user_id: imp.userID, time_started: tsReq(imp.timeStarted), time_finished: tsReq(imp.timeFinished), - game_group: imp.game as GameGroup, + game_group: imp.gameGroup as GameGroup, import_type: imp.importType as ImportType, user_intent: imp.userIntent, service, }); - // some imports have no gpt strings + const games = + imp.games.length > 0 + ? imp.games + : ((imp as { playtypes?: string[] }).playtypes ?? []).map((pt) => + toGame(imp.gameGroup, pt), + ); - const playtypes = imp.playtypes ?? []; - - for (const playtype of playtypes) { - importGameRows.push({ id: importId, game: toGame(imp.game, playtype) }); + for (const g of games) { + importGameRows.push({ id: importId, game: mongoGameToPg(g) }); } for (const err of imp.errors) { @@ -1006,7 +976,7 @@ async function main(): Promise { for (const delta of imp.classDeltas) { importClassRows.push({ import_id: importId, - game: toGame(delta.game, delta.playtype), + game: delta.game, set: delta.set as string, prev: delta.old, new: delta.new, @@ -1067,7 +1037,7 @@ async function main(): Promise { // ── goal_sub ────────────────────────────────────────────────────────────── { console.log("\n[goal_sub]"); - const goalSubs = await mongoDB.get("goal-subs").find({}); + const goalSubs = await mongoDB.get("goal-subs").find({}); const uniqueGoalIds = [...new Set(goalSubs.map((gs) => gs.goalID))]; const existingGoalIds = new Set( @@ -1116,7 +1086,7 @@ async function main(): Promise { // ── quest_sub ───────────────────────────────────────────────────────────── { console.log("\n[quest_sub]"); - const questSubs = await mongoDB.get("quest-subs").find({}); + const questSubs = await mongoDB.get("quest-subs").find({}); const questSubRows: Array = questSubs.map((qs) => ({ quest_id: qs.questID, @@ -1135,8 +1105,15 @@ async function main(): Promise { // ── folder_view ─────────────────────────────────────────────────────────── { console.log("\n[folder_view]"); + /** Legacy Mongo `recent-folder-views` stored internal folder ids as `folderID`. */ + type LegacyRecentFolderViewDoc = { + folderID: string; + lastViewed: number; + userID: number; + }; + const folderViews = await mongoDB - .get("recent-folder-views") + .get("recent-folder-views") .find({}); const uniqueFolderIds = [...new Set(folderViews.map((fv) => fv.folderID))]; @@ -1271,7 +1248,7 @@ async function main(): Promise { // ── import_timing ───────────────────────────────────────────────────────── console.log("\n[import_timing]"); - await streamCollection( + await streamCollection( "import-timings", async (docs) => { const importIds = docs.map((t) => t.importID); @@ -1340,7 +1317,7 @@ async function main(): Promise { // ── score ───────────────────────────────────────────────────────────────── console.log("\n[score]"); - await streamCollection( + await streamCollection( "scores", async (docs) => { const rows: Array = []; @@ -1408,14 +1385,14 @@ async function main(): Promise { s.scoreData.optional = { enumIndexes: {} }; } - const gpt = GetGPTString(s.game, s.playtype); - const { data, derived, judgements } = mongoScoreDataToPg(gpt, s.scoreData); + const v3Game = mongoGameToPg(s.game, (s as { playtype?: string }).playtype); + const { data, derived, judgements } = mongoScoreDataToPg(v3Game, s.scoreData); rows.push({ id: s.scoreID, user_id: s.userID, chart_id: chartSid, - game: GPTStringToV3(GetGPTString(s.game, s.playtype)), + game: v3Game, session_id: sessionId, import_id: importId, data: JSON.stringify(data), diff --git a/typescript/server/src/security-audit/poc-folder-where-sql-raw.test.ts b/typescript/server/src/security-audit/poc-folder-where-sql-raw.test.ts index b19098ae4..26b0c202a 100644 --- a/typescript/server/src/security-audit/poc-folder-where-sql-raw.test.ts +++ b/typescript/server/src/security-audit/poc-folder-where-sql-raw.test.ts @@ -1,4 +1,4 @@ -import { BuildFolderQuery } from "#lib/folders/folders.js"; +import { BuildFolderQuery } from "#lib/folders/folders"; import DB from "#services/pg/db"; import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; @@ -77,7 +77,7 @@ describe("POC: tautological folder.where via sql.raw", () => { game: "iidx-sp", inactive: false, title: "POC tautology", - slug: null, + slug: folderId, where: "true", version_filter: null, search_terms: [], diff --git a/typescript/server/src/server/middleware/auth.oldtest.ts b/typescript/server/src/server/middleware/auth.oldtest.ts deleted file mode 100644 index c98a31d4c..000000000 --- a/typescript/server/src/server/middleware/auth.oldtest.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import { expressRequestMock } from "#test-utils/mock-request"; -import ResetDBState from "#test-utils/resets"; -import { ALL_PERMISSIONS } from "tachi-common"; -import t from "tap"; - -import { SetRequestPermissions } from "./auth"; - -t.test("#SetRequestPermissions", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should assign valid APIKey information to req[SYMBOL_TachiAPIData]", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "Mock API Token", - permissions: { - customise_profile: true, - }, - token: "mock_token", - fromAPIClient: null, - }); - - const { req } = await expressRequestMock(SetRequestPermissions, { - headers: { - authorization: "Bearer mock_token", - }, - }); - - t.strictSame(req[SYMBOL_TACHI_API_AUTH], { - userID: 1, - identifier: "Mock API Token", - permissions: { - customise_profile: true, - }, - token: "mock_token", - fromAPIClient: null, - }); - - t.end(); - }); - - t.test("Should assign guest APIKey information if no auth present", async (t) => { - const { req } = await expressRequestMock(SetRequestPermissions); - - t.strictSame(req[SYMBOL_TACHI_API_AUTH], { - userID: null, - identifier: "Guest Token", - permissions: {}, - token: null, - fromAPIClient: null, - }); - - t.end(); - }); - - t.test("Should return 400 if auth type is not Bearer", async (t) => { - const { res } = await expressRequestMock(SetRequestPermissions, { - headers: { - authorization: "Basic Foo", - }, - }); - - t.equal(res.statusCode, 400); - - const json = res._getJSONData(); - - t.match(json.description, /Invalid Authorization Type - Expected Bearer/u); - t.end(); - }); - - t.test("Should return 401 if no token is present", async (t) => { - const { res } = await expressRequestMock(SetRequestPermissions, { - headers: { - authorization: "Bearer ", - }, - }); - - t.equal(res.statusCode, 401); - - const json = res._getJSONData(); - - t.match(json.description, /Invalid token/u); - t.end(); - }); - - t.test("Should return 401 if token is unknown", async (t) => { - const { res } = await expressRequestMock(SetRequestPermissions, { - headers: { - authorization: "Bearer unknown_token", - }, - }); - - t.equal(res.statusCode, 401); - - const json = res._getJSONData(); - - t.match( - json.description, - /The provided API token does not correspond with any key in the database/u, - ); - t.end(); - }); - - t.test("Should assign a Session-Key if user authenticates with cookie.", async (t) => { - const { req } = await expressRequestMock(SetRequestPermissions, { - session: { - tachi: { - user: await MONGODB_KILL.users.findOne({ id: 1 }), - settings: await MONGODB_KILL["user-settings"].findOne({ userID: 1 }), - }, - }, - }); - - t.strictSame(req[SYMBOL_TACHI_API_AUTH], { - userID: 1, - identifier: `Session-Key 1`, - token: null, - permissions: ALL_PERMISSIONS, - fromAPIClient: null, - }); - t.end(); - }); - - t.end(); -}); - -t.test("#RejectIfBanned", (t) => { - t.test("Should stop banned users from doing anything.", async (t) => { - await MONGODB_KILL.users.update({ id: 1 }, { $set: { authLevel: 0 } }); - - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "Mock API Token", - permissions: { - customise_profile: true, - }, - token: "mock_token", - fromAPIClient: null, - }); - - const res = await mockApi.get("/api/v1/status").set("Authorization", "Bearer mock_token"); - - t.equal(res.statusCode, 403, "Should return 403 on benign endpoints."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/middleware/auth.test.ts b/typescript/server/src/server/middleware/auth.test.ts new file mode 100644 index 000000000..45cb113d7 --- /dev/null +++ b/typescript/server/src/server/middleware/auth.test.ts @@ -0,0 +1,157 @@ +import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; +import DB from "#services/pg/db"; +import mockApi from "#test-utils/mock-api"; +import { expressRequestMock } from "#test-utils/mock-request"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { ALL_PERMISSIONS } from "tachi-common"; +import { describe, expect, it } from "vitest"; + +import { SetRequestPermissions } from "./auth"; + +describe("SetRequestPermissions", () => { + it("assigns API token data to req when Authorization Bearer matches priv_api_token", async () => { + const { id: userId } = await seedUser({ username: "api_token_user" }); + + await DB.insertInto("priv_api_token") + .values({ + token: "mock_token", + user_id: userId, + identifier: "Mock API Token", + from_oauth2_client: null, + pm_customise_profile: true, + pm_customise_score: null, + pm_customise_session: null, + pm_delete_score: null, + pm_manage_rivals: null, + pm_manage_targets: null, + pm_submit_score: null, + pm_manage_challenges: null, + }) + .execute(); + + const { req } = await expressRequestMock(SetRequestPermissions, { + headers: { + authorization: "Bearer mock_token", + }, + }); + + expect(req[SYMBOL_TACHI_API_AUTH]).toEqual({ + userID: userId, + identifier: "Mock API Token", + permissions: { + customise_profile: true, + }, + token: "mock_token", + fromAPIClient: null, + }); + }); + + it("assigns guest token when no Authorization header is sent", async () => { + const { req } = await expressRequestMock(SetRequestPermissions); + + expect(req[SYMBOL_TACHI_API_AUTH]).toEqual({ + userID: null, + identifier: "Guest Token", + permissions: {}, + token: null, + fromAPIClient: null, + }); + }); + + it("returns 400 when Authorization is not Bearer", async () => { + const { res } = await expressRequestMock(SetRequestPermissions, { + headers: { + authorization: "Basic Foo", + }, + }); + + expect(res.statusCode).toBe(400); + + const json = res._getJSONData() as { description?: string }; + + expect(json.description).toMatch(/Invalid Authorization Type - Expected Bearer/u); + }); + + it("returns 401 when Bearer token is empty", async () => { + const { res } = await expressRequestMock(SetRequestPermissions, { + headers: { + authorization: "Bearer ", + }, + }); + + expect(res.statusCode).toBe(401); + + const json = res._getJSONData() as { description?: string }; + + expect(json.description).toMatch(/Invalid token/u); + }); + + it("returns 401 when Bearer token is unknown", async () => { + const { res } = await expressRequestMock(SetRequestPermissions, { + headers: { + authorization: "Bearer unknown_token", + }, + }); + + expect(res.statusCode).toBe(401); + + const json = res._getJSONData() as { description?: string }; + + expect(json.description).toMatch( + /The provided API token does not correspond with any key in the database/u, + ); + }); + + it("assigns session-based auth when req.session.tachi.user.id is set", async () => { + const { id: userId } = await seedUser({ username: "session_auth_user" }); + + const { req } = await expressRequestMock(SetRequestPermissions, { + session: { + tachi: { + user: { id: userId }, + settings: {}, + }, + }, + } as never); + + expect(req[SYMBOL_TACHI_API_AUTH]).toEqual({ + userID: userId, + identifier: `Session-Key ${userId}`, + token: null, + permissions: ALL_PERMISSIONS, + fromAPIClient: null, + }); + }); +}); + +describe("RejectIfBanned (integration)", () => { + it("returns 403 on /api/v1/status when the API user is banned", async () => { + const { id: userId } = await seedUser({ username: "banned_api_user" }); + + await DB.insertInto("priv_api_token") + .values({ + token: "mock_token", + user_id: userId, + identifier: "Mock API Token", + from_oauth2_client: null, + pm_customise_profile: true, + pm_customise_score: null, + pm_customise_session: null, + pm_delete_score: null, + pm_manage_rivals: null, + pm_manage_targets: null, + pm_submit_score: null, + pm_manage_challenges: null, + }) + .execute(); + + await DB.updateTable("account") + .set({ auth_level: "banned" }) + .where("id", "=", userId) + .execute(); + + const res = await mockApi.get("/api/v1/status").set("Authorization", "Bearer mock_token"); + + expect(res.statusCode).toBe(403); + }); +}); diff --git a/typescript/server/src/server/middleware/auth.ts b/typescript/server/src/server/middleware/auth.ts index 46f7c45be..b3cfbc859 100644 --- a/typescript/server/src/server/middleware/auth.ts +++ b/typescript/server/src/server/middleware/auth.ts @@ -2,15 +2,15 @@ import type { RequestHandler } from "express"; import type { Session, SessionData } from "express-session"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { SELECT_API_TOKEN, ToAPITokenDocument } from "#lib/db-formats/api-token.js"; +import { SELECT_API_TOKEN, ToAPITokenDocument } from "#lib/db-formats/api-token"; import { log } from "#lib/log/log"; import { TachiConfig } from "#lib/setup/config"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import { IsNullishOrEmptyStr, SplitAuthorizationHeader } from "#utils/misc"; -import { IsUserBanned } from "#utils/user.js"; -import { ALL_PERMISSIONS, type APIPermissions, type MONGO_APITokenDocument } from "tachi-common"; +import { IsUserBanned } from "#utils/user"; +import { ALL_PERMISSIONS, type APIPermissions, type APITokenDocument } from "tachi-common"; -const GuestToken: MONGO_APITokenDocument = { +const GuestToken: APITokenDocument = { token: null, userID: null, identifier: "Guest Token", @@ -20,7 +20,7 @@ const GuestToken: MONGO_APITokenDocument = { export const RejectIfBanned: RequestHandler = async (req, res, next) => { // auth might not be defined. - const auth = req[SYMBOL_TACHI_API_AUTH] as MONGO_APITokenDocument | undefined; + const auth = req[SYMBOL_TACHI_API_AUTH] as APITokenDocument | undefined; // this is deliberately not auth?.userID !== null, as that isn't correct. // we need to ignore this if auth doesn't exist and if auth is null. diff --git a/typescript/server/src/server/middleware/prudence-validate.oldtest.ts b/typescript/server/src/server/middleware/prudence-validate.oldtest.ts deleted file mode 100644 index 09eea8698..000000000 --- a/typescript/server/src/server/middleware/prudence-validate.oldtest.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { expressRequestMock } from "#test-utils/mock-request"; -import { p } from "prudence"; -import t from "tap"; - -import prValidate from "./prudence-validate"; - -t.test("#PrudenceMiddleware", (t) => { - const mw = prValidate({ foo: p.regex(/^baz$/u) }, { foo: "example error message" }); - - t.test("Should return 400 on invalid prudence validation", async (t) => { - const { res } = await expressRequestMock(mw, { - query: { - foo: "bar", - }, - }); - - t.equal(res.statusCode, 400, "Status code should be 400"); - - const json = res._getJSONData(); - - t.equal( - json.description, - "[foo] example error message (Received bar)", - "Should return error message", - ); - - t.end(); - }); - - t.test("Should return 'nothing' instead of undefined for missing fields", async (t) => { - const { res } = await expressRequestMock(mw, { - query: {}, - }); - - t.equal(res.statusCode, 400, "Status code should be 400"); - - const json = res._getJSONData(); - - t.equal( - json.description, - "[foo] example error message (Received nothing (undefined))", - "Should return error message with received nothing", - ); - - t.end(); - }); - - t.test("Should allow valid prudence data.", async (t) => { - const { res } = await expressRequestMock(mw, { - query: { - foo: "baz", - }, - }); - - t.equal(res.statusCode, 200, "Should stay as 200"); - - // no body -- not returned. - t.equal(res._isJSON(), false, "Should not have any data set"); - - t.end(); - }); - - t.test("Should allow valid bodies on non-GET requests", async (t) => { - const { res } = await expressRequestMock(mw, { - method: "POST", - body: { - foo: "baz", - }, - }); - - t.equal(res.statusCode, 200, "Should stay as 200"); - - // no body -- not returned. - t.equal(res._isJSON(), false, "Should not have any data set"); - - t.end(); - }); - - t.test("Should return 400 on invalid prudence validation for non-GET requests", async (t) => { - const { res } = await expressRequestMock(mw, { - method: "POST", - body: { - foo: "bar", - }, - }); - - t.equal(res.statusCode, 400, "Status code should be 400"); - - const json = res._getJSONData(); - - t.equal( - json.description, - "[foo] example error message (Received bar)", - "Should return error message", - ); - - t.end(); - }); - - const mwWithPassword = prValidate( - { "!password": "string" }, - { "!password": "invalid password" }, - ); - - t.test( - "Should not return the contents of the error message if the field starts with !", - async (t) => { - const { res } = await expressRequestMock(mwWithPassword, { - query: { - "!password": 123, - }, - }); - - t.equal(res.statusCode, 400, "Status code should be 400"); - - const json = res._getJSONData(); - - t.equal( - json.description, - "[!password] invalid password (Received ****)", - "Should return obscured error message", - ); - - t.end(); - }, - ); - - t.test( - "Should not return the contents of the error message if the field starts with ! and is undefined.", - async (t) => { - const { res } = await expressRequestMock(mwWithPassword, { - query: { - "!password": undefined, - }, - }); - - t.equal(res.statusCode, 400, "Status code should be 400"); - - const json = res._getJSONData(); - - t.equal( - json.description, - "[!password] invalid password (Received nothing (undefined))", - "Should indicate if no data was sent in obscured error message", - ); - - t.end(); - }, - ); - - t.end(); -}); diff --git a/typescript/server/src/server/middleware/prudence-validate.test.ts b/typescript/server/src/server/middleware/prudence-validate.test.ts new file mode 100644 index 000000000..cb96bd293 --- /dev/null +++ b/typescript/server/src/server/middleware/prudence-validate.test.ts @@ -0,0 +1,108 @@ +import { expressRequestMock } from "#test-utils/mock-request"; +import { p } from "prudence"; +import { describe, expect, it } from "vitest"; + +import prValidate from "./prudence-validate"; + +describe("PrudenceMiddleware", () => { + const mw = prValidate({ foo: p.regex(/^baz$/u) }, { foo: "example error message" }); + + it("returns 400 on invalid prudence validation", async () => { + const { res } = await expressRequestMock(mw, { + query: { + foo: "bar", + }, + }); + + expect(res.statusCode).toBe(400); + + const json = res._getJSONData(); + + expect(json.description).toBe("[foo] example error message (Received bar)"); + }); + + it("returns 'nothing' instead of undefined for missing fields", async () => { + const { res } = await expressRequestMock(mw, { + query: {}, + }); + + expect(res.statusCode).toBe(400); + + const json = res._getJSONData(); + + expect(json.description).toBe("[foo] example error message (Received nothing (undefined))"); + }); + + it("allows valid prudence data", async () => { + const { res } = await expressRequestMock(mw, { + query: { + foo: "baz", + }, + }); + + expect(res.statusCode).toBe(200); + expect(res._isJSON()).toBe(false); + }); + + it("allows valid bodies on non-GET requests", async () => { + const { res } = await expressRequestMock(mw, { + method: "POST", + body: { + foo: "baz", + }, + }); + + expect(res.statusCode).toBe(200); + expect(res._isJSON()).toBe(false); + }); + + it("returns 400 on invalid prudence validation for non-GET requests", async () => { + const { res } = await expressRequestMock(mw, { + method: "POST", + body: { + foo: "bar", + }, + }); + + expect(res.statusCode).toBe(400); + + const json = res._getJSONData(); + + expect(json.description).toBe("[foo] example error message (Received bar)"); + }); + + const mwWithPassword = prValidate( + { "!password": "string" }, + { "!password": "invalid password" }, + ); + + it("does not return field contents when the key starts with !", async () => { + const { res } = await expressRequestMock(mwWithPassword, { + query: { + "!password": 123, + }, + }); + + expect(res.statusCode).toBe(400); + + const json = res._getJSONData(); + + expect(json.description).toBe("[!password] invalid password (Received ****)"); + }); + + it("obscures missing !-prefixed fields", async () => { + const { res } = await expressRequestMock(mwWithPassword, { + query: { + "!password": undefined, + }, + }); + + expect(res.statusCode).toBe(400); + + const json = res._getJSONData(); + + expect(json.description).toBe( + "[!password] invalid password (Received nothing (undefined))", + ); + }); +}); diff --git a/typescript/server/src/server/middleware/request-logger.ts b/typescript/server/src/server/middleware/request-logger.ts index 8459b337e..7e3766cbc 100644 --- a/typescript/server/src/server/middleware/request-logger.ts +++ b/typescript/server/src/server/middleware/request-logger.ts @@ -1,5 +1,5 @@ import type { RequestHandler, Response } from "express-serve-static-core"; -import type { MONGO_APITokenDocument } from "tachi-common"; +import type { APITokenDocument } from "tachi-common"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; @@ -59,8 +59,7 @@ export const RequestLoggerMiddleware: RequestHandler = (req, res, next) => { requestBody: safeBody, // This might actually be undefined, as it could be called in some weird scenarios? - from: - (req[SYMBOL_TACHI_API_AUTH] as MONGO_APITokenDocument | undefined)?.userID ?? null, + from: (req[SYMBOL_TACHI_API_AUTH] as APITokenDocument | undefined)?.userID ?? null, fromIp: req.ip, }; diff --git a/typescript/server/src/server/middleware/sanitise-qstring.oldtest.ts b/typescript/server/src/server/middleware/sanitise-qstring.test.ts similarity index 52% rename from typescript/server/src/server/middleware/sanitise-qstring.oldtest.ts rename to typescript/server/src/server/middleware/sanitise-qstring.test.ts index 4dcaff4ba..bf407f5e7 100644 --- a/typescript/server/src/server/middleware/sanitise-qstring.oldtest.ts +++ b/typescript/server/src/server/middleware/sanitise-qstring.test.ts @@ -1,10 +1,10 @@ import { expressRequestMock } from "#test-utils/mock-request"; -import t from "tap"; +import { describe, expect, it } from "vitest"; import SanitiseQString from "./sanitise-qstring"; -t.test("#SanitiseQString", (t) => { - t.test("Should allow GET requests with valid data.", async (t) => { +describe("SanitiseQString", () => { + it("allows GET requests with valid data", async () => { const { res } = await expressRequestMock(SanitiseQString, { method: "GET", query: { @@ -12,12 +12,10 @@ t.test("#SanitiseQString", (t) => { }, }); - t.not(res.statusCode, 400, "Status code should NOT be 400"); - - t.end(); + expect(res.statusCode).not.toBe(400); }); - t.test("Should disallow GET requests with nested data.", async (t) => { + it("disallows GET requests with nested data", async () => { const { res } = await expressRequestMock(SanitiseQString, { method: "GET", query: { @@ -27,10 +25,6 @@ t.test("#SanitiseQString", (t) => { }, }); - t.equal(res.statusCode, 400, "Status code should be 400"); - - t.end(); + expect(res.statusCode).toBe(400); }); - - t.end(); }); diff --git a/typescript/server/src/server/middleware/update-last-seen.ts b/typescript/server/src/server/middleware/update-last-seen.ts index 4e60fbb21..ffcb29817 100644 --- a/typescript/server/src/server/middleware/update-last-seen.ts +++ b/typescript/server/src/server/middleware/update-last-seen.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from "express"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import { sql } from "kysely"; export const UpdateLastSeen: RequestHandler = (req, _res, next) => { diff --git a/typescript/server/src/server/router/api/v1/activity/router.ts b/typescript/server/src/server/router/api/v1/activity/router.ts index 1634bb041..fc0b70a94 100644 --- a/typescript/server/src/server/router/api/v1/activity/router.ts +++ b/typescript/server/src/server/router/api/v1/activity/router.ts @@ -1,47 +1,24 @@ import { GetRecentActivityForMultipleGames } from "#lib/activity/activity"; -import { TachiConfig } from "#lib/setup/config"; -import { Router } from "express"; -import { GetGameGroupConfig } from "tachi-common"; +import { ALL_GAMES } from "tachi-common"; -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; /** * Retrieve *all* activity across every game on the site. * * @param session - See CreateActivityRouteHandler * @param startTime - See CreateActivityRouteHandler - * - * @name GET /api/v1/activity */ -router.get("/", async (req, res) => { - const qStartTime = req.query.startTime as string | undefined; +API_V1_ROUTER.add("GET /activity", async ({ input }) => { + const data = await GetRecentActivityForMultipleGames( + ALL_GAMES, + undefined, + input.startTime ?? null, + ); - const startTime = qStartTime ? Number(qStartTime) : null; - - if (Number.isNaN(startTime)) { - return res.status(400).json({ - success: false, - description: `Invalid startTime, got a non number.`, - }); - } - - const gpts = []; - - for (const game of TachiConfig.GAMES) { - const playtypes = GetGameGroupConfig(game).playtypes; - - for (const playtype of playtypes) { - gpts.push({ game, playtype }); - } - } - - const data = await GetRecentActivityForMultipleGames(gpts, undefined, startTime); - - return res.status(200).json({ + return { success: true, description: `Returned global activity.`, body: data, - }); + }; }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/admin/router.oldtest.ts b/typescript/server/src/server/router/api/v1/admin/router.oldtest.ts deleted file mode 100644 index 17482a545..000000000 --- a/typescript/server/src/server/router/api/v1/admin/router.oldtest.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { ONE_MINUTE } from "#lib/constants/time"; -import { ChangeRootLogLevel, GetLogLevel } from "#lib/log/log.js"; -import { Env, ServerConfig } from "#lib/setup/config"; -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { TestingIIDXSPScore } from "#test-utils/test-data"; -import { Sleep } from "#utils/misc"; -import deepmerge from "deepmerge"; -import { type ScoreDocument, UserAuthLevels } from "tachi-common"; -import t from "tap"; - -const LOG_LEVEL = Env.LOG_LEVEL; - -t.test("POST /api/v1/admin/change-log-level", async (t) => { - t.beforeEach(async () => { - ChangeRootLogLevel(LOG_LEVEL); - await MONGODB_KILL.users.update({ id: 1 }, { $set: { authLevel: UserAuthLevels.ADMIN } }); - }); - - const auth = await CreateFakeAuthCookie(mockApi); - - t.test("Should require an admin authlevel", async (t) => { - await MONGODB_KILL.users.update({ id: 1 }, { $set: { authLevel: UserAuthLevels.USER } }); - - const res = await mockApi.post("/api/v1/admin/change-log-level").set("Cookie", auth).send({ - noReset: true, - logLevel: "crit", - }); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should change the log level on the server.", async (t) => { - const res = await mockApi.post("/api/v1/admin/change-log-level").set("Cookie", auth).send({ - noReset: true, - logLevel: "crit", - }); - - t.equal(res.statusCode, 200); - t.equal(GetLogLevel(), "crit"); - - t.end(); - }); - - t.test("Should reject invalid log levels", async (t) => { - const res = await mockApi.post("/api/v1/admin/change-log-level").set("Cookie", auth).send({ - noReset: true, - logLevel: "invalid", - }); - - t.equal(res.statusCode, 400); - t.equal(GetLogLevel(), LOG_LEVEL); - - t.end(); - }); - - t.test("Should set a timer that lasts duration minutes.", async (t) => { - const res = await mockApi.post("/api/v1/admin/change-log-level").set("Cookie", auth).send({ - duration: 0.05, - logLevel: "warn", - }); - - t.equal(res.statusCode, 200); - t.equal(GetLogLevel(), "warn"); - - // wait a bit - await Sleep(ONE_MINUTE * 0.06); - - t.equal(GetLogLevel(), LOG_LEVEL); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/admin/delete-score", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL.users.update({ id: 1 }, { $set: { authLevel: UserAuthLevels.ADMIN } }); - }); - - const auth = await CreateFakeAuthCookie(mockApi); - - t.test("Should require an admin authlevel", async (t) => { - await MONGODB_KILL.users.update({ id: 1 }, { $set: { authLevel: UserAuthLevels.USER } }); - - const res = await mockApi - .post("/api/v1/admin/delete-score") - .set({ - Cookie: auth, - }) - .send({ - scoreID: "deleteme", - }); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should delete another user's score.", async (t) => { - await MONGODB_KILL.scores.insert( - deepmerge(TestingIIDXSPScore, { - scoreID: "deleteme", - }), - ); - - const res = await mockApi - .post("/api/v1/admin/delete-score") - .set({ - Cookie: auth, - }) - .send({ - scoreID: "deleteme", - }); - - t.equal(res.statusCode, 200); - - const dbScore = await MONGODB_KILL.scores.findOne({ scoreID: "deleteme" }); - - t.equal(dbScore, null, "Should remove the score from the database."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/admin/router.test.ts b/typescript/server/src/server/router/api/v1/admin/router.test.ts new file mode 100644 index 000000000..69541ef80 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/admin/router.test.ts @@ -0,0 +1,129 @@ +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; +import { type ScoreData } from "tachi-common"; +import { afterAll, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +describe("POST /api/v1/admin/delete-score", () => { + it("returns 403 when the caller is not an admin", async () => { + await seedUser({ + username: "pleb", + email: "pleb@test.com", + withCredential: true, + withSettings: true, + }); + + const plebCookie = await loginAs("pleb"); + + const res = await mockApi + .post("/api/v1/admin/delete-score") + .set("Cookie", plebCookie) + .send({ scoreID: "anything" }); + + expect(res.status).toBe(403); + }); + + it("deletes another user's score when the caller is an admin", async () => { + await seedUser({ + username: "admin_del", + email: "admin_del@test.com", + authLevel: "admin", + withCredential: true, + withSettings: true, + }); + await seedUser({ + username: "victim", + email: "victim_adm@test.com", + withCredential: true, + withSettings: true, + }); + + const adminCookie = await loginAs("admin_del"); + + const chartId = Testing511SPA.chartID; + const sd = TestingIIDXSPScore.scoreData as ScoreData<"iidx-sp">; + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", sd); + const now = new Date().toISOString(); + + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: [], + alt_titles: [], + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: true, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); + + await DB.insertInto("score") + .values({ + id: "deleteme", + user_id: 2, + chart_id: chartId, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: now, + time_added: now, + highlight: false, + comment: null, + }) + .execute(); + + const res = await mockApi + .post("/api/v1/admin/delete-score") + .set("Cookie", adminCookie) + .send({ scoreID: "deleteme" }); + + expect(res.status).toBe(200); + + const row = await DB.selectFrom("score") + .select("id") + .where("id", "=", "deleteme") + .executeTakeFirst(); + + expect(row).toBeUndefined(); + }); +}); + +describe("POST /api/v1/admin/change-log-level", () => { + it.todo("no route in router.ts (see router.oldtest.ts)"); +}); diff --git a/typescript/server/src/server/router/api/v1/admin/router.ts b/typescript/server/src/server/router/api/v1/admin/router.ts index 150ae2f80..b12ad01f7 100644 --- a/typescript/server/src/server/router/api/v1/admin/router.ts +++ b/typescript/server/src/server/router/api/v1/admin/router.ts @@ -1,448 +1,194 @@ -import { ACTION_DeleteScore } from "#actions/delete-score.js"; -import { ACTION_DeleteSession } from "#actions/delete-session.js"; -import { ACTION_RebuildFolderChartLookup } from "#actions/rebuild-folder-chart-lookup.js"; -import { ACTION_SetUserSupporterStatus } from "#actions/set-user-supporter-status.js"; +import { ACTION_DeleteScore } from "#actions/delete-score"; +import { ACTION_DeleteSession } from "#actions/delete-session"; +import { ACTION_RebuildFolderChartLookup } from "#actions/rebuild-folder-chart-lookup"; +import { ACTION_SetUserSupporterStatus } from "#actions/set-user-supporter-status"; import { GetActions, GetActiveJobs, GetCronTaskExecutions, GetCronTasks, GetJobQueue, -} from "#lib/admin/admin-queries.js"; +} from "#lib/admin/admin-queries"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log"; import { SendSiteAnnouncementNotification } from "#lib/notifications/notification-wrappers"; +import { withAdmin } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { TachiConfig } from "#lib/setup/config"; -import prValidate from "#server/middleware/prudence-validate"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import { IsValidPlaytype } from "#utils/misc"; -import DestroyUserGameProfile from "#utils/reset-state/destroy-user-game-profile.js"; -import { GetUserWithID, GetUserWithIDGuaranteed, ResolveUser } from "#utils/user"; -import { type RequestHandler, Router } from "express"; -import { p } from "prudence"; +import DestroyUserGameProfile from "#utils/reset-state/destroy-user-game-profile"; +import { GetUserWithIDGuaranteed, ResolveUser } from "#utils/user"; +import { ExpectedErr } from "bliss"; import { type GameGroup, - GamePTToV3, - type integer, - type Playtype, - UserAuthLevels, + GameToGameGroup, + LEGACY_GameGroupPTToGame, + type LEGACY_Playtype, + type V3Game, } from "tachi-common"; -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; -const RequireAdminLevel: RequestHandler = async (req, res, next) => { - if (req[SYMBOL_TACHI_API_AUTH].userID === null) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); - } - - const userDoc = await GetUserWithID(req[SYMBOL_TACHI_API_AUTH].userID); - - if (!userDoc) { - log.error( - `Api Token ${req[SYMBOL_TACHI_API_AUTH].token} is assigned to ${req[SYMBOL_TACHI_API_AUTH].userID}, who does not exist?`, - ); - - return res.status(500).json({ - success: false, - description: `An internal error has occured.`, - }); - } - - if (userDoc.authLevel !== UserAuthLevels.ADMIN) { - return res.status(403).json({ - success: false, - description: `You are not authorised to perform this.`, - }); - } - - next(); -}; - -router.use(RequireAdminLevel); - -/** - * @name GET /api/v1/admin/job-queue - */ -router.get("/job-queue", async (req, res) => { - const page = Math.max(0, Number(req.query.page ?? 0)); - const statusRaw = req.query.status; +API_V1_ROUTER.add("GET /admin/job-queue", withAdmin, async ({ input }) => { + const page = Math.max(0, input.page ?? 0); + const statusRaw = input.status; let status: number | undefined; - if (typeof statusRaw === "string" && statusRaw !== "") { + + if (statusRaw !== undefined && statusRaw !== "") { const n = Number.parseInt(statusRaw, 10); + if (!Number.isNaN(n)) { status = n; } } - const job_kind = - typeof req.query.job_kind === "string" && req.query.job_kind !== "" - ? req.query.job_kind - : undefined; - const scope = - typeof req.query.scope === "string" && req.query.scope !== "" ? req.query.scope : undefined; + + const jobKind = + input.job_kind !== undefined && input.job_kind !== "" ? input.job_kind : undefined; + const scope = input.scope !== undefined && input.scope !== "" ? input.scope : undefined; const [activeJobs, jobQueue] = await Promise.all([ GetActiveJobs(), - GetJobQueue({ page, status, job_kind, scope }), + GetJobQueue({ job_kind: jobKind, page, scope, status }), ]); - return res.status(200).json({ - success: true, - description: "Done.", - body: { activeJobs, jobQueue, filters: { status, job_kind, scope } }, + return success("Done.", { + activeJobs, + filters: { job_kind: jobKind, scope, status }, + jobQueue, }); }); -/** - * @name GET /api/v1/admin/actions - */ -router.get("/actions", async (req, res) => { - const page = Math.max(0, Number(req.query.page ?? 0)); - const kind = - typeof req.query.kind === "string" && req.query.kind !== "" ? req.query.kind : undefined; +API_V1_ROUTER.add("GET /admin/actions", withAdmin, async ({ input }) => { + const page = Math.max(0, input.page ?? 0); + const kind = input.kind !== undefined && input.kind !== "" ? input.kind : undefined; const username = - typeof req.query.username === "string" && req.query.username !== "" - ? req.query.username - : undefined; + input.username !== undefined && input.username !== "" ? input.username : undefined; const actions = await GetActions({ page, kind, username }); - return res.status(200).json({ - success: true, - description: "Done.", - body: { actions, filters: { kind, username } }, - }); + return success("Done.", { actions, filters: { kind, username } }); }); -/** - * @name GET /api/v1/admin/cron-tasks - */ -router.get("/cron-tasks", async (_req, res) => { +API_V1_ROUTER.add("GET /admin/cron-tasks", withAdmin, async () => { const [tasks, executions] = await Promise.all([GetCronTasks(), GetCronTaskExecutions(100)]); - return res.status(200).json({ - success: true, - description: "Done.", - body: { tasks, executions }, - }); + return success("Done.", { executions, tasks }); }); -/** - * Resynchronises all PBs that match the given query or users. - * - * @param userIDs - Optionally, An array of integers of users to resync. - * @param filter - Optionally, the set of scores to resync. - * - * @name POST /api/v1/admin/resync-pbs - */ -router.post( - "/resync-pbs", - prValidate({ - userIDs: p.optional([p.isPositiveInteger]), - filter: "*object", - }), - (_req, res) => - res.status(501).json({ - success: false, - description: `Not implemented.`, - }), -); +API_V1_ROUTER.add("POST /admin/resync-pbs", withAdmin, () => { + throw new ExpectedErr(501, "Not implemented."); +}); -/** - * Force Delete anyones score. - * - * @param scoreID - The scoreID to delete. - * - * @name POST /api/v1/admin/delete-score - */ -router.post("/delete-score", prValidate({ scoreID: "string" }), async (req, res) => { - const body = req.safeBody as { scoreID: string }; +API_V1_ROUTER.add("POST /admin/delete-score", withAdmin, async ({ input, req }) => { + const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID!; + const adminUser = await GetUserWithIDGuaranteed(adminUserID); + const taker = { acct: { id: adminUser.id, username: adminUser.username }, ip: req.ip }; - const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID; + await ACTION_DeleteScore(taker, { id: input.scoreID }); - if (adminUserID === null) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); + return success("Removed score.", {}); +}); + +API_V1_ROUTER.add("POST /admin/delete-session", withAdmin, async ({ input, req }) => { + const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID!; + const adminUser = await GetUserWithIDGuaranteed(adminUserID); + const taker = { acct: { id: adminUser.id, username: adminUser.username }, ip: req.ip }; + + await ACTION_DeleteSession(taker, { id: input.sessionID }); + + return success("Removed session.", {}); +}); + +API_V1_ROUTER.add("POST /admin/destroy-ugpt", withAdmin, async ({ input }) => { + const gameGroup = input.game as GameGroup; + const playtype = input.playtype as LEGACY_Playtype; + + if (!IsValidPlaytype(gameGroup, playtype)) { + throw new ExpectedErr(400, `Invalid playtype ${playtype} for game ${gameGroup}.`); } - const adminUser = await GetUserWithIDGuaranteed(adminUserID); - const taker = { ip: req.ip, acct: { id: adminUser.id, username: adminUser.username } }; + const game = LEGACY_GameGroupPTToGame(gameGroup, playtype); - await ACTION_DeleteScore(taker, { id: body.scoreID }); + const ugpt = await DB.selectFrom("game_profile") + .where("user_id", "=", input.userID) + .where("game", "=", game) + .executeTakeFirst(); - return res.status(200).json({ - success: true, - description: `Removed score.`, - body: {}, - }); -}); - -/** - * Force Delete anyones session. - * - * @param sessionID - The sessionID to delete. - * - * @name POST /api/v1/admin/delete-session - */ -router.post("/delete-session", prValidate({ sessionID: "string" }), async (req, res) => { - const body = req.safeBody as { sessionID: string }; - - const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID; - - if (adminUserID === null) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); + if (!ugpt) { + throw new ExpectedErr( + 404, + `No stats for ${input.userID} (${gameGroup} ${playtype}) exist.`, + ); } - const adminUser = await GetUserWithIDGuaranteed(adminUserID); - const taker = { ip: req.ip, acct: { id: adminUser.id, username: adminUser.username } }; + await DestroyUserGameProfile(input.userID, gameGroup, playtype); - await ACTION_DeleteSession(taker, { id: body.sessionID }); - - return res.status(200).json({ - success: true, - description: `Removed session.`, - body: {}, - }); + return success(`Completely destroyed UGPT for ${input.userID} (${gameGroup} ${playtype}).`, {}); }); -/** - * Destroys a users UGPT profile and forces a leaderboard recalc. - * - * @param userID - The U... - * @param game - The G... - * @param playtype - And the PT to delete. - * - * @name POST /api/v1/admin/destroy-ugpt - */ -router.post( - "/destroy-ugpt", - prValidate({ - userID: p.isInteger, - game: p.isIn(TachiConfig.GAMES), - playtype: (self, parent) => { - if (typeof self !== "string") { - return "Expected a string for a playtype."; - } +API_V1_ROUTER.add("POST /admin/recalc", withAdmin, () => { + throw new ExpectedErr(501, "Not implemented."); +}); - if (!IsValidPlaytype(parent.game as GameGroup, self)) { - return `Invalid playtype of ${self} for game ${parent.game as GameGroup}.`; - } +API_V1_ROUTER.add("POST /admin/announcement", withAdmin, async ({ input }) => { + const game = input.game as V3Game | undefined; - return true; - }, - }), - async (req, res) => { - const { userID, game, playtype } = req.safeBody as { - game: GameGroup; - playtype: Playtype; - userID: integer; - }; - - const ugpt = await DB.selectFrom("game_profile") - .where("user_id", "=", userID) - .where("game", "=", GamePTToV3(game, playtype)) - .executeTakeFirst(); - - if (!ugpt) { - return res.status(404).json({ - success: false, - description: `No stats for ${userID} (${game} ${playtype}) exist.`, - }); + if (game) { + if (!TachiConfig.GAME_GROUPS.includes(GameToGameGroup(game))) { + throw new ExpectedErr(400, `This game is not enabled '${game}'.`); } + } - await DestroyUserGameProfile(userID, game, playtype); + await SendSiteAnnouncementNotification(input.title, game); - return res.status(200).json({ - success: true, - description: `Completely destroyed UGPT for ${userID} (${game} ${playtype}).`, - body: {}, - }); - }, -); + return success(`Sent notification '${input.title}'.`, {}); +}); -/** - * Perform a site recalc on this set of scores. - * - * @name POST /api/v1/admin/recalc - */ -router.post("/recalc", (_req, res) => - res.status(501).json({ - success: false, - description: `Not implemented.`, - }), -); - -/** - * Send an announcement to the site. - * - * @name POST /api/v1/admin/announcement - */ -router.post( - "/announcement", - prValidate({ - game: p.optional(p.isIn(TachiConfig.GAMES)), - playtype: "*string", - title: "string", - }), - async (req, res) => { - const { game, playtype, title } = req.safeBody as { - game?: GameGroup; - playtype?: string; - title: string; - }; - - let maybePlaytype: Playtype | undefined; - - if (game && playtype) { - if (!IsValidPlaytype(game, playtype)) { - return res.status(400).json({ - success: false, - description: `Invalid playtype '${playtype}' for game '${game}'.`, - }); - } - - maybePlaytype = playtype; - } - - await SendSiteAnnouncementNotification(title, game, maybePlaytype); - - return res.status(200).json({ - success: true, - description: `Sent notification '${title}'.`, - body: {}, - }); - }, -); - -/** - * Make this user a Tachi supporter. - * - * @name POST /api/v1/admin/supporter/:userID - */ -router.post("/supporter/:userID", async (req, res) => { - const target = await ResolveUser(req.params.userID); +API_V1_ROUTER.add("POST /admin/supporter/:userID", withAdmin, async ({ params, req }) => { + const target = await ResolveUser(params.userID); if (!target) { - return res.status(404).json({ - success: false, - description: `This user does not exist.`, - }); - } - - const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID; - - if (adminUserID === null) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); + throw new ExpectedErr(404, "This user does not exist."); } + const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID!; const adminUser = await GetUserWithIDGuaranteed(adminUserID); - const taker = { ip: req.ip, acct: { id: adminUser.id, username: adminUser.username } }; + const taker = { acct: { id: adminUser.id, username: adminUser.username }, ip: req.ip }; - await ACTION_SetUserSupporterStatus(taker, { userID: target.id, isSupporter: true }); + await ACTION_SetUserSupporterStatus(taker, { isSupporter: true, userID: target.id }); - return res.status(200).json({ - success: true, - description: `Done.`, - body: {}, - }); + return success("Done.", {}); }); -/** - * Un-Make this user a Tachi supporter. - * - * @name DELETE /api/v1/admin/supporter/:userID - */ -router.delete("/supporter/:userID", async (req, res) => { - const target = await ResolveUser(req.params.userID); +API_V1_ROUTER.add("DELETE /admin/supporter/:userID", withAdmin, async ({ params, req }) => { + const target = await ResolveUser(params.userID); if (!target) { - return res.status(404).json({ - success: false, - description: `This user does not exist.`, - }); - } - - const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID; - - if (adminUserID === null) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); + throw new ExpectedErr(404, "This user does not exist."); } + const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID!; const adminUser = await GetUserWithIDGuaranteed(adminUserID); - const taker = { ip: req.ip, acct: { id: adminUser.id, username: adminUser.username } }; + const taker = { acct: { id: adminUser.id, username: adminUser.username }, ip: req.ip }; - await ACTION_SetUserSupporterStatus(taker, { userID: target.id, isSupporter: false }); + await ACTION_SetUserSupporterStatus(taker, { isSupporter: false, userID: target.id }); - return res.status(200).json({ - success: true, - description: `Done.`, - body: {}, - }); + return success("Done.", {}); }); -/** - * Rebuilds the Postgres `folder_chart_lookup` table (chart → folders cache). - * - * @param folderId - If set, only rebuild that folder's rows. - * - * @name POST /api/v1/admin/rebuild-folder-chart-lookup - */ -router.post( - "/rebuild-folder-chart-lookup", - prValidate({ - folderId: p.optional("string"), - }), - async (req, res) => { - const userID = req[SYMBOL_TACHI_API_AUTH].userID; +API_V1_ROUTER.add("POST /admin/rebuild-folder-chart-lookup", withAdmin, async ({ input, req }) => { + const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID!; + const user = await GetUserWithIDGuaranteed(adminUserID); + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; - if (userID === null) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); - } + const result = await ACTION_RebuildFolderChartLookup(taker, { folderId: input.folderId }); - const user = await GetUserWithIDGuaranteed(userID); - const body = req.safeBody as { folderId?: string }; - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + return success( + `Rebuilt folder_chart_lookup (${result.folderCount} folders, ${result.rowCount} rows).`, + result, + ); +}); - const result = await ACTION_RebuildFolderChartLookup(taker, { - folderId: body.folderId, - }); - - return res.status(200).json({ - success: true, - description: `Rebuilt folder_chart_lookup (${result.folderCount} folders, ${result.rowCount} rows).`, - body: result, - }); - }, -); - -/** - * Reprocess all goals for every user. This should be used to un-screw the site - * if the server goes down or peoples goals fall out of sync. Obviously, this - * should never happen, but the error handling around this stuff is really wacky. - * - * @name POST /api/v1/admin/reprocess-all-goals - */ -router.post("/reprocess-all-goals", (_req, res) => - res.status(501).json({ - success: false, - description: `Not implemented.`, - }), -); - -export default router; +API_V1_ROUTER.add("POST /admin/reprocess-all-goals", withAdmin, () => { + throw new ExpectedErr(501, "Not implemented."); +}); diff --git a/typescript/server/src/server/router/api/v1/auth/router.test.ts b/typescript/server/src/server/router/api/v1/auth/router.test.ts index ab7e0c597..43827eb38 100644 --- a/typescript/server/src/server/router/api/v1/auth/router.test.ts +++ b/typescript/server/src/server/router/api/v1/auth/router.test.ts @@ -249,13 +249,14 @@ describe("POST /api/v1/auth/login", () => { expect(res.status).toBe(400); }); - it("returns 400 when captcha is missing", async () => { + it("allows login without captcha outside production (captcha skipped in test)", async () => { const res = await mockApi.post("/api/v1/auth/login").send({ username: "test_user", "!password": "password123", }); - expect(res.status).toBe(400); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); }); }); diff --git a/typescript/server/src/server/router/api/v1/auth/router.ts b/typescript/server/src/server/router/api/v1/auth/router.ts index c9541aeeb..15216de27 100644 --- a/typescript/server/src/server/router/api/v1/auth/router.ts +++ b/typescript/server/src/server/router/api/v1/auth/router.ts @@ -3,21 +3,14 @@ import { ANON_ACTION_ForgotPassword } from "#anon-actions/forgot-password"; import { ANON_ACTION_Register } from "#anon-actions/register"; import { ANON_ACTION_ResetPassword } from "#anon-actions/reset-password"; import { ANON_ACTION_VerifyEmail } from "#anon-actions/verify-email"; -import { - MountAuthCookie, - PasswordCompare, - ValidateCaptcha, - ValidateEmail, - ValidatePassword, -} from "#lib/auth/auth"; +import { MountAuthCookie, PasswordCompare, ValidateCaptcha } from "#lib/auth/auth"; import { log } from "#lib/log/log"; -import { Env, ServerConfig, TachiConfig } from "#lib/setup/config"; -import prValidate from "#server/middleware/prudence-validate"; +import { success, wrapExpressMiddleware } from "#lib/router/typed-router"; +import { Env } from "#lib/setup/config"; import { AggressiveRateLimitMiddleware, HyperAggressiveRateLimitMiddleware, } from "#server/middleware/rate-limiter"; -import { apiSuccess } from "#utils/response"; import { FormatUserDoc, GetSettingsForUser, @@ -25,207 +18,114 @@ import { GetUserPrivateInfo, GetUserWithID, } from "#utils/user"; -import { Router } from "express"; -import { p } from "prudence"; -import { type MONGO_UserDocument } from "tachi-common"; +import { ExpectedErr } from "bliss"; -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; + +const aggressiveRL = wrapExpressMiddleware(AggressiveRateLimitMiddleware); +const hyperAggressiveRL = wrapExpressMiddleware(HyperAggressiveRateLimitMiddleware); /** * Logs in a user. * @name POST /api/v1/auth/login */ -router.post( - "/login", - AggressiveRateLimitMiddleware, - prValidate( - { - username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), - "!password": ValidatePassword, - captcha: "string", - }, - { - username: - "Invalid username. Usernames cannot start with a number, and must be between 2 and 20 characters.", - captcha: "Please fill out the captcha.", - }, - undefined, - "debug", - ), - async (req, res) => { - if (req.session.tachi?.user.id !== undefined) { - // Dual logins should destroy the users session and recreate it. - req.session.tachi = undefined; +API_V1_ROUTER.add("POST /auth/login", aggressiveRL, async ({ input, req, res }) => { + if (req.session.tachi?.user.id !== undefined) { + // Dual logins should destroy the users session and recreate it. + req.session.tachi = undefined; + } + + log.debug(`Received login request with username ${input.username} (${req.ip})`); + + /* istanbul ignore next */ + if (Env.NODE_ENV === "production" || Env.NODE_ENV === "staging") { + log.debug("Validating captcha..."); + const validCaptcha = await ValidateCaptcha(input.captcha ?? "", req.socket.remoteAddress); + + if (!validCaptcha) { + log.debug("Captcha failed."); + res.status(400).json({ success: false, description: `Captcha failed.` }); + return success("Captcha failed.", {}); } - const body = req.safeBody as { - "!password": string; - captcha: string; - username: string; - }; + log.debug("Captcha validated!"); + } else { + log.debug("Skipped captcha check because not in production."); + } - log.debug(`Received login request with username ${body.username} (${req.ip})`); + const requestedUser = await GetUserCaseInsensitive(input.username); - /* istanbul ignore next */ - if (Env.NODE_ENV === "production" || Env.NODE_ENV === "staging") { - log.debug("Validating captcha..."); - const validCaptcha = await ValidateCaptcha(body.captcha, req.socket.remoteAddress); + if (!requestedUser) { + log.debug(`Invalid username for login ${input.username}.`); + throw new ExpectedErr(404, "This user does not exist."); + } - if (!validCaptcha) { - log.debug("Captcha failed."); - return res.status(400).json({ - success: false, - description: `Captcha failed.`, - }); - } + const privateInfo = await GetUserPrivateInfo(requestedUser.id); - log.debug("Captcha validated!"); - } else { - log.debug("Skipped captcha check because not in production."); - } + if (!privateInfo) { + log.error( + { requestedUser }, + `State desync for user ${FormatUserDoc(requestedUser)}. This user has no password/email information?`, + ); + throw new ExpectedErr(500, "An internal server error has occured."); + } - const requestedUser = await GetUserCaseInsensitive(body.username); + const passwordMatch = await PasswordCompare(input["!password"], privateInfo.password); - if (!requestedUser) { - log.debug(`Invalid username for login ${body.username}.`); - return res.status(404).json({ - success: false, - description: `This user does not exist.`, - }); - } + if (!passwordMatch) { + log.debug("Invalid password provided."); + throw new ExpectedErr(403, "Invalid password."); + } - const privateInfo = await GetUserPrivateInfo(requestedUser.id); + const user = await GetUserWithID(requestedUser.id); - if (!privateInfo) { - log.error( - { requestedUser }, - `State desync for user ${FormatUserDoc( - requestedUser, - )}. This user has no password/email information?`, - ); + if (!user) { + log.error({ requestedUser }, `User logged in as someone who does not exist?`); + throw new ExpectedErr(500, "An internal server error has occured."); + } - return res.status(500).json({ - success: false, - description: `An internal server error has occured.`, - }); - } + const settings = await GetSettingsForUser(requestedUser.id); - const passwordMatch = await PasswordCompare(body["!password"], privateInfo.password); + MountAuthCookie(req, user, settings); - if (!passwordMatch) { - log.debug("Invalid password provided."); - return res.status(403).json({ - success: false, - description: `Invalid password.`, - }); - } + log.debug(`${FormatUserDoc(requestedUser)} Logged in.`); - const user = await GetUserWithID(requestedUser.id); - - if (!user) { - log.error({ requestedUser }, `User logged in as someone who does not exist?`); - return res.status(500).json({ - success: false, - description: `An internal server error has occured.`, - }); - } - - const settings = await GetSettingsForUser(requestedUser.id); - - MountAuthCookie(req, user, settings); - - log.debug(`${FormatUserDoc(requestedUser)} Logged in.`); - - return res.status(200).json({ - success: true, - description: `Successfully logged in as ${FormatUserDoc(requestedUser)}`, - body: { - userID: requestedUser.id, - }, - }); - }, -); + return success(`Successfully logged in as ${FormatUserDoc(requestedUser)}`, { + userID: requestedUser.id, + }); +}); /** * Registers a new user. * @name POST /api/v1/auth/register */ -router.post( - "/register", - AggressiveRateLimitMiddleware, - prValidate( +API_V1_ROUTER.add("POST /auth/register", aggressiveRL, async ({ input, req }) => { + const newUser = await ANON_ACTION_Register( + { ip: req.ip }, { - username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), - "!password": ValidatePassword, - email: ValidateEmail, - inviteCode: "*string", - captcha: "string", + email: input.email, + "!password": input["!password"], + inviteCode: input.inviteCode ?? null, + captcha: input.captcha ?? "", + username: input.username, }, - { - username: - "Usernames must be between 3 and 20 characters long, can only contain alphanumeric characters and cannot start with a number.", - email: "Invalid email.", - inviteCode: "Invalid invite code.", - captcha: "Please fill out the captcha.", - }, - undefined, - "debug", - ), - async (req, res) => { - if (!TachiConfig.SIGNUPS_ENABLED) { - return res.status(501).json({ - success: false, - description: `Signups are not currently enabled.`, - }); - } + ); - const body = req.safeBody as { - "!password": string; - captcha: string; - email: string; - inviteCode?: string; - username: string; - }; + const user = await GetUserWithID(newUser.userID); - const newUser = await ANON_ACTION_Register( - { - ip: req.ip, - }, - { - email: body.email, - "!password": body["!password"], - inviteCode: body.inviteCode ?? null, - captcha: body.captcha, - username: body.username, - }, + if (!user) { + log.error( + `User ${newUser.userID} does not have a user document, but one was just created.`, ); + throw new ExpectedErr(500, "An internal server error has occured."); + } - const user = await GetUserWithID(newUser.userID); + const settings = await GetSettingsForUser(user.id); - if (!user) { - log.error( - `User ${newUser.userID} does not have a user document, but one was just created.`, - ); + MountAuthCookie(req, user, settings); - return res.status(500).json({ - success: false, - description: "An internal server error has occured.", - }); - } - - const settings = await GetSettingsForUser(user.id); - MountAuthCookie(req, user, settings); - - return res - .status(200) - .json( - apiSuccess( - `Successfully created account ${body.username}!`, - user, - ), - ); - }, -); + return success(`Successfully created account ${input.username}!`, user); +}); /** * Verifies the provided email according to the code provided. @@ -234,36 +134,18 @@ router.post( * * @name POST /api/v1/auth/verify-email */ -router.post( - "/verify-email", - AggressiveRateLimitMiddleware, - prValidate({ - code: "string", - }), - async (req, res) => { - const body = req.safeBody as { - code: string; - }; +API_V1_ROUTER.add("POST /auth/verify-email", aggressiveRL, async ({ input, req }) => { + await ANON_ACTION_VerifyEmail({ ip: req.ip }, { code: input.code }); - await ANON_ACTION_VerifyEmail({ ip: req.ip }, { code: body.code }); - - return res.status(200).json({ - success: true, - description: `Verified email!`, - body: {}, - }); - }, -); + return success("Verified email!", {}); +}); /** - * Resend a verification email, for when they fall through the - * cracks. - * - * @param email - The email to send a verification email to. + * Resend a verification email, for when they fall through the cracks. * * @name POST /api/v1/auth/resend-verify-email */ -router.post("/resend-verify-email", HyperAggressiveRateLimitMiddleware, async (req, res) => { +API_V1_ROUTER.add("POST /auth/resend-verify-email", hyperAggressiveRL, async ({ req, res }) => { // Immediately send a response so the existence of emails // cannot be timing attacked out. res.status(200).json({ @@ -275,121 +157,69 @@ router.post("/resend-verify-email", HyperAggressiveRateLimitMiddleware, async (r const user = req.session.tachi?.user; if (!user) { - return; + return success("Sent an email if the email address has not been verified.", {}); } await ACTION_ResendVerifyEmail( - { - ip: req.ip, - acct: { - id: user.id, - username: user.username, - }, - }, + { ip: req.ip, acct: { id: user.id, username: user.username } }, {}, ); + + return success("Sent an email if the email address has not been verified.", {}); }); /** * Logs out the requesting user. * @name POST /api/v1/auth/logout */ -router.post("/logout", (req, res) => { - // this is sorrrrt of an action, but it does - // mutation of req, it's not "pure" enough - // for me to want to make it an action. - +API_V1_ROUTER.add("POST /auth/logout", ({ req, res }) => { if (req.session.tachi?.user.id === undefined) { - return res.status(409).json({ - success: false, - description: `You are not logged in.`, - }); + res.status(409).json({ success: false, description: `You are not logged in.` }); + return success("You are not logged in.", {}); } req.session.destroy(() => 0); - return res.status(200).json({ - success: true, - description: `Logged Out.`, - body: {}, - }); + return success("Logged Out.", {}); }); /** - * Creates a password reset code for a user. The user will then - * be able to trigger POST /reset-password with that code. + * Creates a password reset code for a user. * * @param email - The email associated with the account you want to reset. * * @name POST /api/v1/auth/forgot-password */ -router.post( - "/forgot-password", - HyperAggressiveRateLimitMiddleware, - prValidate({ email: "string" }), - async (req, res) => { - if (!ServerConfig.EMAIL_CONFIG && Env.NODE_ENV !== "test") { - return res.status(501).json({ - success: false, - description: `This server does not support password resets.`, - }); - } +API_V1_ROUTER.add("POST /auth/forgot-password", hyperAggressiveRL, async ({ input, req, res }) => { + // For timing attack and infosec reasons, respond immediately. + res.status(202).json({ + success: true, + description: "A code has been sent to your email.", + body: {}, + }); - const body = req.safeBody as { - email: string; - }; + try { + await ANON_ACTION_ForgotPassword({ ip: req.ip }, { email: input.email }); + } catch (_err) { + // error is logged elsewhere. + } - // For timing attack and infosec reasons, we can't do anything but **immediately** return here. - res.status(202).json({ - success: true, - description: "A code has been sent to your email.", - body: {}, - }); - - try { - await ANON_ACTION_ForgotPassword({ ip: req.ip }, { email: body.email }); - } catch (_err) { - // error is logged elsewhere. - } - }, -); + return success("A code has been sent to your email.", {}); +}); /** - * Takes a code generated from /forgot-password, a new password, - * and performs the reset for the user. + * Takes a code generated from /forgot-password, a new password, and performs the reset. * * @param password - The users new password. * @param code - The code to use to reset this password. * * @name POST /api/v1/auth/reset-password */ -router.post( - "/reset-password", - AggressiveRateLimitMiddleware, - prValidate({ - code: "string", - "!password": ValidatePassword, - }), - async (req, res) => { - const body = req.safeBody as { - "!password": string; - code: string; - }; +API_V1_ROUTER.add("POST /auth/reset-password", aggressiveRL, async ({ input, req }) => { + await ANON_ACTION_ResetPassword( + { ip: req.ip }, + { code: input.code, "!password": input["!password"] }, + ); - await ANON_ACTION_ResetPassword( - { ip: req.ip }, - { - code: body.code, - "!password": body["!password"], - }, - ); - - return res.status(200).json({ - success: true, - description: `Reset your password.`, - body: {}, - }); - }, -); - -export default router; + return success("Reset your password.", {}); +}); diff --git a/typescript/server/src/server/router/api/v1/clients/middleware.oldtest.ts b/typescript/server/src/server/router/api/v1/clients/middleware.oldtest.ts deleted file mode 100644 index fa4f24029..000000000 --- a/typescript/server/src/server/router/api/v1/clients/middleware.oldtest.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { SYMBOL_TACHI_DATA } from "#lib/constants/tachi"; -import { expressRequestMock } from "#test-utils/mock-request"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -import { GetClientFromID, RequireOwnershipOfClient } from "./middleware"; - -t.test("#GetClientFromID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should assign the client to req[@@TachiData] if exists.", async (t) => { - const { req } = await expressRequestMock(GetClientFromID, { - params: { - clientID: "OAUTH2_CLIENT_ID", - }, - [SYMBOL_TACHI_DATA]: {}, - }); - - t.strictSame( - req[SYMBOL_TACHI_DATA]?.apiClientDoc, - { - clientID: "OAUTH2_CLIENT_ID", - - // clientSecret: "OAUTH2_CLIENT_SECRET", - name: "Test_Service", - author: 1, - requestedPermissions: ["customise_profile"], - redirectUri: "https://example.com/callback", - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }, - "Should assign clientDoc with secret omitted.", - ); - - t.end(); - }); - - t.test("Should return 404 if client does not exist.", async (t) => { - const { res } = await expressRequestMock(GetClientFromID, { - params: { - clientID: "NONSENSE", - }, - [SYMBOL_TACHI_DATA]: {}, - }); - - t.equal(res.statusCode, 404); - - t.hasStrict(res._getJSONData(), { - success: false, - description: "This client does not exist.", - }); - - t.end(); - }); - - t.end(); -}); - -t.test("#RequireOwnershipOfClient", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return 401 if the user has no authentication.", async (t) => { - const { res } = await expressRequestMock(RequireOwnershipOfClient, { - safeBody: { - __terribleHackOauth2ClientDoc: { - author: 1, - }, - }, - [SYMBOL_TACHI_DATA]: {}, - session: {}, - }); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("Should return 403 if the user does not own this client.", async (t) => { - const { res } = await expressRequestMock(RequireOwnershipOfClient, { - safeBody: { - __terribleHackOauth2ClientDoc: { - author: 1, - }, - }, - session: { - tachi: { - user: { - id: 2, - }, - }, - }, - [SYMBOL_TACHI_DATA]: {}, - }); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should continue if this is their session.", async (t) => { - const { res } = await expressRequestMock(RequireOwnershipOfClient, { - safeBody: { - __terribleHackOauth2ClientDoc: { - author: 1, - }, - }, - session: { - tachi: { - user: { - id: 1, - }, - }, - }, - [SYMBOL_TACHI_DATA]: {}, - }); - - t.equal(res.statusCode, 200); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/clients/middleware.test.ts b/typescript/server/src/server/router/api/v1/clients/middleware.test.ts new file mode 100644 index 000000000..7ea8943e4 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/clients/middleware.test.ts @@ -0,0 +1,62 @@ +import { seedApiClient } from "#actions/test-utils/api-tokens"; +import { SYMBOL_TACHI_DATA } from "#lib/constants/tachi"; +import { expressRequestMock } from "#test-utils/mock-request"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { GetClientFromID } from "./middleware"; + +describe("GetClientFromID", () => { + beforeEach(async () => { + const { id: userId } = await seedUser({ + username: "api_client_owner", + withCredential: true, + withSettings: true, + }); + + await seedApiClient({ + clientId: "OAUTH2_CLIENT_ID", + authorId: userId, + name: "Test_Service", + clientSecret: "OAUTH2_CLIENT_SECRET", + customiseProfile: true, + redirectUri: "https://example.com/callback", + }); + }); + + it("assigns the client to req tachi data when it exists", async () => { + const { req } = await expressRequestMock(GetClientFromID, { + params: { + clientID: "OAUTH2_CLIENT_ID", + }, + [SYMBOL_TACHI_DATA]: {}, + }); + + expect(req[SYMBOL_TACHI_DATA]?.apiClientDoc).toEqual({ + clientID: "OAUTH2_CLIENT_ID", + name: "Test_Service", + author: 1, + requestedPermissions: ["customise_profile"], + redirectUri: "https://example.com/callback", + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + }); + }); + + it("returns 404 if the client does not exist", async () => { + const { res } = await expressRequestMock(GetClientFromID, { + params: { + clientID: "NONSENSE", + }, + [SYMBOL_TACHI_DATA]: {}, + }); + + expect(res.statusCode).toBe(404); + + expect(res._getJSONData()).toEqual({ + success: false, + description: "This client does not exist.", + }); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/clients/middleware.ts b/typescript/server/src/server/router/api/v1/clients/middleware.ts index b8cc0bcea..a3ce02327 100644 --- a/typescript/server/src/server/router/api/v1/clients/middleware.ts +++ b/typescript/server/src/server/router/api/v1/clients/middleware.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from "express"; import { GetClientByID } from "#utils/queries/api-clients"; -import { AssignToReqTachiData } from "#utils/req-tachi-data"; +import { REQ_AssignToReqTachiData } from "#utils/req-tachi-data"; export const GetClientFromID: RequestHandler = async (req, res, next) => { const client = await GetClientByID(req.params.clientID); @@ -16,7 +16,7 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => { // Strip the client secret — this middleware is used for public lookups. const { clientSecret: _secret, ...publicClient } = client; - AssignToReqTachiData(req, { apiClientDoc: publicClient }); + REQ_AssignToReqTachiData(req, { apiClientDoc: publicClient }); next(); }; diff --git a/typescript/server/src/server/router/api/v1/clients/router.oldtest.ts b/typescript/server/src/server/router/api/v1/clients/router.oldtest.ts deleted file mode 100644 index b4359aa4a..000000000 --- a/typescript/server/src/server/router/api/v1/clients/router.oldtest.ts +++ /dev/null @@ -1,470 +0,0 @@ -import type { APITokenDocument, TachiAPIClientDocument } from "tachi-common"; - -import { ServerConfig } from "#lib/setup/config"; -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -const clientDataset: Array = [ - { - author: 1, - clientID: "CLIENT_1", - clientSecret: "SECRET_1", - name: "foo", - redirectUri: "example.com", - requestedPermissions: ["customise_profile"], - webhookUri: null, - apiKeyFilename: null, - apiKeyTemplate: null, - }, - { - author: 1, - clientID: "CLIENT_2", - clientSecret: "SECRET_2", - name: "bar", - redirectUri: "example.com", - requestedPermissions: ["customise_profile"], - webhookUri: null, - apiKeyFilename: null, - apiKeyTemplate: null, - }, - { - author: 2, - clientID: "CLIENT_3", - clientSecret: "SECRET_3", - name: "baz", - redirectUri: "example.com", - requestedPermissions: ["customise_profile"], - webhookUri: null, - apiKeyFilename: null, - apiKeyTemplate: null, - }, -]; - -t.test("GET /api/v1/clients", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL["api-clients"].remove({}); - await MONGODB_KILL["api-clients"].insert(clientDataset); - }); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should retrieve your clients.", async (t) => { - const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - // note: force sort alphabetically so this isn't dependent on - // mongodb natural order. - t.strictSame( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - res.body.body.sort((a: any, b: any) => a.name - b.name), - [ - { - author: 1, - clientID: "CLIENT_1", - clientSecret: "SECRET_1", - name: "foo", - redirectUri: "example.com", - requestedPermissions: ["customise_profile"], - webhookUri: null, - apiKeyFilename: null, - apiKeyTemplate: null, - }, - { - author: 1, - clientID: "CLIENT_2", - clientSecret: "SECRET_2", - name: "bar", - redirectUri: "example.com", - requestedPermissions: ["customise_profile"], - webhookUri: null, - apiKeyFilename: null, - apiKeyTemplate: null, - }, - ], - ); - - t.end(); - }); - - t.test("Requires self-key level authentication.", async (t) => { - const res = await mockApi.get("/api/v1/clients"); - - t.equal(res.statusCode, 401); - - const res2 = await mockApi - .get("/api/v1/clients") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res2.statusCode, 401); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/clients/create", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should create a new client.", async (t) => { - const res = await mockApi - .post("/api/v1/clients/create") - .send({ - name: "Hello World", - redirectUri: "https://example.com/callback", - permissions: ["customise_profile"], - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - const dbRes = await MONGODB_KILL["api-clients"].findOne({ - clientID: res.body.body.clientID, - }); - - t.not(dbRes, null, "Should be saved in the database."); - - t.strictSame(dbRes?.requestedPermissions, ["customise_profile"]); - - t.end(); - }); - - t.test("Should validate names to be between 3 and 80 characters.", async (t) => { - const res = await mockApi - .post("/api/v1/clients/create") - .send({ - name: "2", - redirectUri: "https://example.com/callback", - permissions: ["customise_profile"], - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 400); - - const res2 = await mockApi - .post("/api/v1/clients/create") - .send({ - name: "2".repeat(100), - redirectUri: "https://example.com/callback", - permissions: ["customise_profile"], - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }) - .set("Cookie", cookie); - - t.equal(res2.statusCode, 400); - - t.end(); - }); - - t.test("Should validate urls to be between 3 and 80 characters.", async (t) => { - const res = await mockApi - .post("/api/v1/clients/create") - .send({ - name: "Hello World", - redirectUri: "ftp://example.com/callback", - permissions: ["customise_profile"], - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should validate permissions.", async (t) => { - const res = await mockApi - .post("/api/v1/clients/create") - .send({ - name: "Hello World", - redirectUri: "http://example.com/callback", - permissions: ["permission_that_doesnt_exist"], - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - // Currently skipped as its difficult to mock user auth level. - t.skip("Should cap a user at OAUTH_CLIENT_CAP.", async (t) => { - for (let i = 0; i < ServerConfig.OAUTH_CLIENT_CAP; i++) { - // eslint-disable-next-line no-await-in-loop - await mockApi - .post("/api/v1/clients/create") - .send({ - name: "Hello World", - redirectUri: "https://example.com/callback", - permissions: ["customise_profile"], - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }) - .set("Cookie", cookie); - } - - const res = await mockApi - .post("/api/v1/clients/create") - .send({ - name: "Hello World", - redirectUri: "https://example.com/callback", - permissions: ["customise_profile"], - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 400); - - const dbCount = await MONGODB_KILL["api-clients"].count({ author: 1 }); - - t.equal(dbCount, ServerConfig.OAUTH_CLIENT_CAP); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/clients/:clientID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return information about the client at that ID.", async (t) => { - const res = await mockApi.get("/api/v1/clients/OAUTH2_CLIENT_ID"); - - t.strictSame(res.body.body, { - clientID: "OAUTH2_CLIENT_ID", - - // clientSecret: "OAUTH2_CLIENT_SECRET", MUST NOT have secret! - name: "Test_Service", - author: 1, - requestedPermissions: ["customise_profile"], - redirectUri: "https://example.com/callback", - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - }); - - t.end(); - }); - - t.test("Should return 404 if client doesn't exist.", async (t) => { - const res = await mockApi.get("/api/v1/clients/BAD_CLIENT"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("PATCH /api/v1/clients/:clientID", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL["api-clients"].remove({}); - await MONGODB_KILL["api-clients"].insert(clientDataset); - }); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should be able to modify a clients name.", async (t) => { - const res = await mockApi - .patch("/api/v1/clients/CLIENT_1") - .send({ name: "NEW NAME" }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - t.equal(res.body.body.name, "NEW NAME"); - - const dbRes = await MONGODB_KILL["api-clients"].findOne({ - clientID: "CLIENT_1", - }); - - t.equal(dbRes?.name, "NEW NAME"); - - t.end(); - }); - - t.test("Should be able to modify a clients webhookUri.", async (t) => { - const res = await mockApi - .patch("/api/v1/clients/CLIENT_1") - .send({ webhookUri: "https://example.com" }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - t.equal(res.body.body.webhookUri, "https://example.com"); - - const dbRes = await MONGODB_KILL["api-clients"].findOne({ - clientID: "CLIENT_1", - }); - - t.equal(dbRes?.webhookUri, "https://example.com"); - - t.end(); - }); - - t.test("Must validate name to be between 3 and 80 characters.", async (t) => { - const res = await mockApi - .patch("/api/v1/clients/CLIENT_1") - .send({ name: "2" }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 400); - - const res2 = await mockApi - .patch("/api/v1/clients/CLIENT_1") - .send({ name: "2".repeat(100) }) - .set("Cookie", cookie); - - t.equal(res2.statusCode, 400); - - t.end(); - }); - - t.test("Must provide name to modify.", async (t) => { - const res = await mockApi.patch("/api/v1/clients/CLIENT_1").send({}).set("Cookie", cookie); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Must be owner of client.", async (t) => { - const res = await mockApi - .patch("/api/v1/clients/CLIENT_3") - .send({ name: "foo" }) - .set("Cookie", cookie); - - t.equal(res.statusCode, 403); - - const res2 = await mockApi.patch("/api/v1/clients/CLIENT_3").send({ name: "foo" }); - - t.equal(res2.statusCode, 401); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/clients/:clientID/reset-secret", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL["api-clients"].remove({}); - await MONGODB_KILL["api-clients"].insert(clientDataset); - }); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should reset the client's secret.", async (t) => { - const res = await mockApi - .post("/api/v1/clients/CLIENT_1/reset-secret") - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - t.not(res.body.body.clientSecret, "SECRET_1", "Should return the new secret."); - - const dbRes = await MONGODB_KILL["api-clients"].findOne({ - clientID: "CLIENT_1", - }); - - t.not(dbRes?.clientSecret, "SECRET_1", "Should change secret to anything else."); - - t.end(); - }); - - t.test("Must be owner of client.", async (t) => { - const res = await mockApi - .post("/api/v1/clients/CLIENT_3/reset-secret") - .set("Cookie", cookie); - - t.equal(res.statusCode, 403); - - const res2 = await mockApi.post("/api/v1/clients/CLIENT_3/reset-secret"); - - t.equal(res2.statusCode, 401); - - t.end(); - }); - - t.end(); -}); - -t.test("DELETE /api/v1/clients/:clientID", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL["api-clients"].remove({}); - await MONGODB_KILL["api-clients"].insert(clientDataset); - }); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should destroy the client and all associated api keys.", async (t) => { - await MONGODB_KILL["api-tokens"].insert([ - { - fromAPIClient: "CLIENT_1", - token: "foo", - userID: 1, - }, - { - fromAPIClient: "CLIENT_1", - token: "bar", - userID: 1, - }, - ] as Array); - - const res = await mockApi.delete("/api/v1/clients/CLIENT_1").set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - const dbRes = await MONGODB_KILL["api-clients"].findOne({ clientID: "CLIENT_1" }); - - t.equal(dbRes, null, "Should no longer exist."); - - const dbCount = await MONGODB_KILL["api-tokens"].count({ fromOAuth2Client: "CLIENT_1" }); - - t.equal(dbCount, 0, "Should have destroyed all related api tokens."); - - t.end(); - }); - - t.test("Must be owner of client.", async (t) => { - const res = await mockApi.delete("/api/v1/clients/CLIENT_3").set("Cookie", cookie); - - t.equal(res.statusCode, 403); - - const res2 = await mockApi.delete("/api/v1/clients/CLIENT_3"); - - t.equal(res2.statusCode, 401); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/clients/router.ts b/typescript/server/src/server/router/api/v1/clients/router.ts index 8bc4fbb23..7586127ca 100644 --- a/typescript/server/src/server/router/api/v1/clients/router.ts +++ b/typescript/server/src/server/router/api/v1/clients/router.ts @@ -3,46 +3,37 @@ import { ACTION_DeleteApiClient } from "#actions/delete-api-client"; import { ACTION_ResetApiClientSecret } from "#actions/reset-api-client-secret"; import { ACTION_UpdateApiClient } from "#actions/update-api-client"; import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client"; -import prValidate from "#server/middleware/prudence-validate"; +import { withClient } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import DB from "#services/pg/db"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { p } from "prudence"; -import { ALL_PERMISSIONS, type APIPermissions } from "tachi-common"; +import { ExpectedErr } from "bliss"; +import { type APIPermissions } from "tachi-common"; -import { GetClientFromID } from "./middleware"; - -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; /** * Retrieve the clients you created. Must be performed with a session-level request. * * @warn This also returns the client_secrets! Those *have* to be kept secret. - * - * @name GET /api/v1/clients */ -router.get("/", async (req, res) => { +API_V1_ROUTER.add("GET /clients", async ({ req }) => { const user = req.session.tachi?.user; if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authenticated (for a session-level request, atleast).`, - }); + throw new ExpectedErr( + 401, + "You are not authenticated (for a session-level request, at least).", + ); } const rows = await DB.selectFrom("priv_api_client") .select(SELECT_API_CLIENT) - .where("author", "=", user.id) + .where("priv_api_client.author", "=", user.id) .execute(); const clients = rows.map(ToAPIClientDocument); - return res.status(200).json({ - success: true, - description: `Returned ${clients.length} clients.`, - body: clients, - }); + return success(`Returned ${clients.length} clients.`, clients); }); /** @@ -55,71 +46,34 @@ router.get("/", async (req, res) => { * @param apiKeyFilename - Optionally, a filename to automatically download the template to, when doing * static flow. * @param permissions - An array of APIPermissions this client is expected to use. - * - * @name POST /api/v1/clients/create */ -router.post( - "/create", - prValidate({ - name: p.isBoundedString(3, 80), - redirectUri: "?string", - webhookUri: "?string", - apiKeyTemplate: "?string", - apiKeyFilename: "?string", - permissions: [p.isIn(Object.keys(ALL_PERMISSIONS))], - }), - async (req, res) => { - const user = req.session.tachi?.user; +API_V1_ROUTER.add("POST /clients/create", async ({ input, req }) => { + const user = req.session.tachi?.user; - if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); - } + if (!user) { + throw new ExpectedErr(401, "You are not authenticated."); + } - const body = req.safeBody as { - apiKeyFilename: string | null; - apiKeyTemplate: string | null; - name: string; - permissions: Array; - redirectUri: string | null; - webhookUri: string | null; - }; + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + const clientDoc = await ACTION_CreateApiClient(taker, { + apiKeyFilename: input.apiKeyFilename ?? null, + apiKeyTemplate: input.apiKeyTemplate ?? null, + name: input.name, + permissions: (input.permissions ?? []) as Array, + redirectUri: input.redirectUri ?? null, + webhookUri: input.webhookUri ?? null, + }); - const clientDoc = await ACTION_CreateApiClient(taker, { - name: body.name, - redirectUri: body.redirectUri, - webhookUri: body.webhookUri, - apiKeyTemplate: body.apiKeyTemplate, - apiKeyFilename: body.apiKeyFilename, - permissions: body.permissions, - }); - - return res.status(200).json({ - success: true, - description: `Created a new API client.`, - body: clientDoc, - }); - }, -); + return success("Created a new API client.", clientDoc); +}); /** * Retrieves information about the client at this ID. - * - * @name GET /api/v1/clients/:clientID */ -router.get("/:clientID", GetClientFromID, (req, res) => { - const client = GetTachiData(req, "apiClientDoc"); - - return res.status(200).json({ - success: true, - description: `Retrieved client ${client.name}.`, - body: client, - }); -}); +API_V1_ROUTER.add("GET /clients/:clientID", withClient, ({ ctx }) => + success(`Retrieved client ${ctx.apiClientDoc.name}.`, ctx.apiClientDoc), +); /** * Update an existing client. The requester must be the owner of this @@ -130,104 +84,60 @@ router.get("/:clientID", GetClientFromID, (req, res) => { * @param redirectUri - Change a bound redirectUri for this client. * @param apiKeyTemplate - Change the APIKeyTemplate for this client. * @param apiKeyFilename - Change the APIKeyFilename for this client. - * - * @name PATCH /api/v1/clients/:clientID */ -router.patch( - "/:clientID", - prValidate({ - name: p.optional(p.isBoundedString(3, 80)), - apiKeyTemplate: "?string", - apiKeyFilename: p.optional(p.isBoundedString(3, 80)), - webhookUri: "?string", - redirectUri: "?string", - }), - async (req, res) => { - const user = req.session.tachi?.user; +API_V1_ROUTER.add("PATCH /clients/:clientID", withClient, async ({ params, input, req }) => { + const user = req.session.tachi?.user; - if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); - } + if (!user) { + throw new ExpectedErr(401, "You are not authenticated."); + } - const body = req.safeBody as { - apiKeyFilename?: string | null; - apiKeyTemplate?: string | null; - name?: string; - redirectUri?: string | null; - webhookUri?: string | null; - }; + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + const updatedClient = await ACTION_UpdateApiClient(taker, { + apiKeyFilename: input.apiKeyFilename, + apiKeyTemplate: input.apiKeyTemplate, + clientID: params.clientID, + name: input.name, + redirectUri: input.redirectUri, + webhookUri: input.webhookUri, + }); - const updatedClient = await ACTION_UpdateApiClient(taker, { - clientID: req.params.clientID, - ...body, - }); - - return res.status(200).json({ - success: true, - description: `Updated client.`, - body: updatedClient, - }); - }, -); + return success("Updated client.", updatedClient); +}); /** * Resets the clientSecret for this client. * This will NOT invalidate any existing tokens, as per oauth2 spec. - * - * @name POST /api/v1/clients/:clientID/reset-secret */ -router.post("/:clientID/reset-secret", async (req, res) => { +API_V1_ROUTER.add("POST /clients/:clientID/reset-secret", withClient, async ({ params, req }) => { const user = req.session.tachi?.user; if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); + throw new ExpectedErr(401, "You are not authenticated."); } - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; const updatedClient = await ACTION_ResetApiClientSecret(taker, { - clientID: req.params.clientID, + clientID: params.clientID, }); - return res.status(200).json({ - success: true, - description: `Reset secret.`, - body: updatedClient, - }); + return success("Reset secret.", updatedClient); }); /** * Delete this client. Must be authorized at a session-request level. - * - * @name DELETE /api/v1/clients/:clientID */ -router.delete("/:clientID", async (req, res) => { +API_V1_ROUTER.add("DELETE /clients/:clientID", withClient, async ({ params, req }) => { const user = req.session.tachi?.user; if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); + throw new ExpectedErr(401, "You are not authenticated."); } - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; - await ACTION_DeleteApiClient(taker, { clientID: req.params.clientID }); + await ACTION_DeleteApiClient(taker, { clientID: params.clientID }); - return res.status(200).json({ - success: true, - description: `Deleted client ${req.params.clientID}.`, - body: {}, - }); + return success(`Deleted client ${params.clientID}.`, {}); }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/config/router.oldtest.ts b/typescript/server/src/server/router/api/v1/config/router.oldtest.ts deleted file mode 100644 index 69978af64..000000000 --- a/typescript/server/src/server/router/api/v1/config/router.oldtest.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ServerConfig, TachiConfig } from "#lib/setup/config"; -import mockApi from "#test-utils/mock-api"; -import t from "tap"; - -t.test("GET /api/v1/config", async (t) => { - const res = await mockApi.get("/api/v1/config"); - - t.strictSame(res.body.body, TachiConfig, "Should return TachiConfig info"); - - t.end(); -}); - -t.test("GET /api/v1/config/beatoraja-queue-size", async (t) => { - const res = await mockApi.get("/api/v1/config/beatoraja-queue-size"); - - t.equal( - res.body.body, - ServerConfig.BEATORAJA_QUEUE_SIZE, - "Should return integer equal to BEATORAJA_QUEUE_SIZE.", - ); - - t.end(); -}); - -t.test("GET /api/v1/config/max-rivals", async (t) => { - const res = await mockApi.get("/api/v1/config/max-rivals"); - - t.equal(res.body.body, ServerConfig.MAX_RIVALS, "Should return integer equal to MAX_RIVALS."); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/config/router.test.ts b/typescript/server/src/server/router/api/v1/config/router.test.ts new file mode 100644 index 000000000..e3ea8315a --- /dev/null +++ b/typescript/server/src/server/router/api/v1/config/router.test.ts @@ -0,0 +1,31 @@ +import { ServerConfig, TachiConfig } from "#lib/setup/config"; +import mockApi from "#test-utils/mock-api"; +import { describe, expect, it } from "vitest"; + +describe("GET /api/v1/config", () => { + it("returns TachiConfig", async () => { + const res = await mockApi.get("/api/v1/config"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body).toEqual(TachiConfig); + }); +}); + +describe("GET /api/v1/config/beatoraja-queue-size", () => { + it("returns BEATORAJA_QUEUE_SIZE", async () => { + const res = await mockApi.get("/api/v1/config/beatoraja-queue-size"); + + expect(res.status).toBe(200); + expect(res.body.body).toBe(ServerConfig.BEATORAJA_QUEUE_SIZE); + }); +}); + +describe("GET /api/v1/config/max-rivals", () => { + it("returns MAX_RIVALS", async () => { + const res = await mockApi.get("/api/v1/config/max-rivals"); + + expect(res.status).toBe(200); + expect(res.body.body).toBe(ServerConfig.MAX_RIVALS); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/config/router.ts b/typescript/server/src/server/router/api/v1/config/router.ts index 5d4aa9d8e..cb524c7a2 100644 --- a/typescript/server/src/server/router/api/v1/config/router.ts +++ b/typescript/server/src/server/router/api/v1/config/router.ts @@ -1,47 +1,24 @@ +import { success } from "#lib/router/typed-router"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; -import { RequireBokutachi } from "#server/middleware/type-require"; -import { Router } from "express"; -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; /** * Returns Tachi Configuration info, such as server name, type, supported games * and more. - * - * @name GET /api/v1/config */ -router.get("/", (req, res) => - res.status(200).json({ - success: true, - description: `Returned configuration info.`, - body: TachiConfig, - }), -); +API_V1_ROUTER.add("GET /config", () => success("Returned configuration info.", TachiConfig)); /** * Returns the value of the BEATORAJA_QUEUE_SIZE. - * - * @name GET /api/v1/config/beatoraja-queue-size */ -router.get("/beatoraja-queue-size", RequireBokutachi, (req, res) => - res.status(200).json({ - success: true, - description: `Returned BEATORAJA_QUEUE_SIZE.`, - body: ServerConfig.BEATORAJA_QUEUE_SIZE, - }), +API_V1_ROUTER.add("GET /config/beatoraja-queue-size", () => + success("Returned BEATORAJA_QUEUE_SIZE.", ServerConfig.BEATORAJA_QUEUE_SIZE), ); /** * Returns the maximum amount of rivals a user can have on this instance. - * - * @name GET /api/v1/config/max-rivals */ -router.get("/max-rivals", (req, res) => - res.status(200).json({ - success: true, - description: `Returned MAX_RIVALS.`, - body: ServerConfig.MAX_RIVALS, - }), +API_V1_ROUTER.add("GET /config/max-rivals", () => + success("Returned MAX_RIVALS.", ServerConfig.MAX_RIVALS), ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.oldtest.ts deleted file mode 100644 index bf2dcbb90..000000000 --- a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.oldtest.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ServerConfig } from "#lib/setup/config"; -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import t from "tap"; - -t.test("GET /api/v1/games/bms/:playtype/custom-tables/:tableUrlName", async (t) => { - await MONGODB_KILL.tables.insert({ - default: false, - description: "EC difficulties according to Sieglinde.", - - // stubbed folders - folders: ["sieglinde_folder"], - game: "bms", - inactive: false, - playtype: "7K", - tableID: "bms-7K-sgl-EC", - title: "Sieglinde EC", - }); - - await MONGODB_KILL.folders.insert({ - folderID: "sieglinde_folder", - game: "bms", - playtype: "7K", - inactive: false, - searchTerms: [], - title: "Mock Sieglinde Folder", - - // this table contains all charts. silly mock. - data: {}, - type: "charts", - }); - - t.test("html compatibility return should point to the right file", async (t) => { - const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables/sieglindeEC"); - - const content = //u.exec(res.text); - - if (content) { - const maybeMatch = content[1]; - - t.equal( - maybeMatch, - `${ServerConfig.OUR_URL}/api/v1/games/bms/7K/custom-tables/sieglindeEC/header.json`, - ); - } else { - t.fail(`Didn't match regexp for having a bmstable header?`); - } - - t.end(); - }); - - t.test("Should point to the right file.", async (t) => { - const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables/sieglindeEC/header.json"); - - t.equal( - res.body.data_url, - `${ServerConfig.OUR_URL}/api/v1/games/bms/7K/custom-tables/sieglindeEC/body.json`, - ); - - t.end(); - }); - - t.test("`body.json` should return an array.", async (t) => { - const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables/sieglindeEC/body.json"); - - t.equal(Array.isArray(res.body), true); - - t.end(); - }); - - t.test("Invalid Table", async (t) => { - const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables/fake-table"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Invalid Playtype", async (t) => { - const res = await mockApi.get("/api/v1/games/bms/14K/custom-tables/sieglindeEC"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.test.ts b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.test.ts index 29a524376..46b88e12b 100644 --- a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.test.ts @@ -60,12 +60,12 @@ async function seedBmsSongAndChart(opts: { }) .execute(); - return { chartPgId, songLegacyId: 970_000 + n }; + return { chartPgId, songPgId: songNewID, songLegacyId: 970_000 + n }; } -describe("GET /api/v1/games/bms/:playtype/sieglinde-charts", () => { +describe("GET /api/v1/games/:game/sieglinde-charts", () => { it("returns empty songs and charts when nothing matches", async () => { - const res = await mockApi.get("/api/v1/games/bms/7K/sieglinde-charts"); + const res = await mockApi.get("/api/v1/games/bms-7k/sieglinde-charts"); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -73,31 +73,31 @@ describe("GET /api/v1/games/bms/:playtype/sieglinde-charts", () => { expect(res.body.body.songs).toEqual([]); }); - it("returns 400 for an invalid playtype", async () => { - const res = await mockApi.get("/api/v1/games/bms/99K/sieglinde-charts"); + it("returns 400 for an invalid game", async () => { + const res = await mockApi.get("/api/v1/games/bms-99k/sieglinde-charts"); expect(res.status).toBe(400); expect(res.body.success).toBe(false); - expect(String(res.body.description)).toContain("playtype"); + expect(String(res.body.description)).toContain("game"); }); it("includes charts with sglEC > 0 and their songs", async () => { - const { chartPgId, songLegacyId } = await seedBmsSongAndChart({ + const { chartPgId, songPgId } = await seedBmsSongAndChart({ game: "bms-7k", sglEC: 12, sglHC: null, }); - const res = await mockApi.get("/api/v1/games/bms/7K/sieglinde-charts"); + const res = await mockApi.get("/api/v1/games/bms-7k/sieglinde-charts"); expect(res.status).toBe(200); expect(res.body.body.charts).toHaveLength(1); expect(res.body.body.charts[0].chartID).toBe(chartPgId); - expect(res.body.body.charts[0].songID).toBe(songLegacyId); + expect(res.body.body.charts[0].song.id).toBe(songPgId); expect(res.body.body.charts[0].data.sglEC).toBe(12); expect(res.body.body.songs).toHaveLength(1); - expect(res.body.body.songs[0].id).toBe(songLegacyId); + expect(res.body.body.songs[0].id).toBe(songPgId); expect(res.body.body.songs[0].title).toContain("Sieglinde GPT Test"); }); @@ -108,7 +108,7 @@ describe("GET /api/v1/games/bms/:playtype/sieglinde-charts", () => { sglHC: 9, }); - const res = await mockApi.get("/api/v1/games/bms/7K/sieglinde-charts"); + const res = await mockApi.get("/api/v1/games/bms-7k/sieglinde-charts"); expect(res.status).toBe(200); const withHc = res.body.body.charts.filter((c: { data: { sglHC: number | null } }) => @@ -137,7 +137,7 @@ describe("GET /api/v1/games/bms/:playtype/sieglinde-charts", () => { sglHC: null, }); - const res = await mockApi.get("/api/v1/games/bms/7K/sieglinde-charts"); + const res = await mockApi.get("/api/v1/games/bms-7k/sieglinde-charts"); expect(res.status).toBe(200); expect(res.body.body.charts).toHaveLength(1); @@ -151,8 +151,8 @@ describe("GET /api/v1/games/bms/:playtype/sieglinde-charts", () => { sglHC: null, }); - const res7 = await mockApi.get("/api/v1/games/bms/7K/sieglinde-charts"); - const res14 = await mockApi.get("/api/v1/games/bms/14K/sieglinde-charts"); + const res7 = await mockApi.get("/api/v1/games/bms-7k/sieglinde-charts"); + const res14 = await mockApi.get("/api/v1/games/bms-14k/sieglinde-charts"); expect(res7.status).toBe(200); expect(res14.status).toBe(200); @@ -166,9 +166,9 @@ describe("GET /api/v1/games/bms/:playtype/sieglinde-charts", () => { }); }); -describe("GET /api/v1/games/bms/:playtype/custom-tables", () => { - it("lists public custom tables for the playtype", async () => { - const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables"); +describe("GET /api/v1/games/:game/custom-tables", () => { + it("lists public custom tables for the game", async () => { + const res = await mockApi.get("/api/v1/games/bms-7k/custom-tables"); expect(res.status).toBe(200); expect(res.body.success).toBe(true); diff --git a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts index e7af326f1..a5ecb24a1 100644 --- a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts +++ b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts @@ -1,5 +1,3 @@ -import type { Playtypes } from "tachi-common"; - import { CUSTOM_TACHI_BMS_TABLES, HandleBMSTableBodyRequest, @@ -7,73 +5,71 @@ import { HandleBMSTableHTMLRequest, type TachiBMSTable, } from "#lib/game-specific/custom-bms-tables"; +import { withGame } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { FindBMSSieglindeRatedCharts } from "#utils/queries/charts"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; -import { type RequestHandler, Router } from "express"; +import { ExpectedErr } from "bliss"; +import { type GamesForGroup, GameToGameGroup } from "tachi-common"; -import { ValidatePlaytypeFromParamFor } from "../../_game/_playtype/middleware"; - -const router: Router = Router({ mergeParams: true }); - -const FindCustomBMSTable: RequestHandler = (req, res, next) => { - const { playtype, tableUrlName } = req.params; - - // find the table +function resolveGamesCustomBMSTableOrThrow( + tableUrlName: string, + game: GamesForGroup["bms"], +): TachiBMSTable { const customTable = CUSTOM_TACHI_BMS_TABLES.find((t) => t.urlName === tableUrlName); if (!customTable) { - return res.status(404).json({ - success: false, - description: `No such table with the ID '${tableUrlName}' exists.`, - }); + throw new ExpectedErr(404, `No such table with the ID '${tableUrlName}' exists.`); } - if (customTable.playtype && customTable.playtype !== playtype) { - return res.status(404).json({ - success: false, - description: `The table '${tableUrlName}' exists, but is for ${customTable.playtype}, not ${playtype}.`, - }); + if (customTable.game && customTable.game !== game) { + throw new ExpectedErr( + 404, + `The table '${tableUrlName}' exists, but is for ${customTable.game}, not ${game}.`, + ); } if (customTable.forSpecificUser === true) { - return res.status(404).json({ - success: false, - description: `The table '${tableUrlName}' exists, but is user-specific. You should be fetching this table from /api/v1/users/:userID instead of /api/v1/games.`, - }); + throw new ExpectedErr( + 404, + `The table '${tableUrlName}' exists, but is user-specific. You should be fetching this table from /api/v1/users/:userID instead of /api/v1/games.`, + ); } - AssignToReqTachiData(req, { customBMSTable: customTable }); - - next(); -}; + return customTable; +} /** * List all custom BMS tables this instance of Tachi is emitting. * - * @name GET /api/v1/games/bms/:playtype/custom-tables + * @name GET /api/v1/games/:game/custom-tables */ -router.get("/:playtype/custom-tables", ValidatePlaytypeFromParamFor("bms"), (req, res) => { - const tables: Array< - Pick - > = []; +API_V1_ROUTER.add("GET /games/:game/custom-tables", withGame, ({ ctx }) => { + const tables: Array<{ + description: string; + forSpecificUser: boolean; + symbol: string; + tableName: string; + urlName: string; + }> = []; - for (const table of CUSTOM_TACHI_BMS_TABLES.filter( - (e) => e.playtype === req.params.playtype || e.playtype === null, - )) { + const game = ctx.game as GamesForGroup["bms"]; + + if (GameToGameGroup(game) !== "bms") { + throw new ExpectedErr(404, `No custom tables exist for ${game}.`); + } + + for (const table of CUSTOM_TACHI_BMS_TABLES.filter((e) => e.game === game || e.game === null)) { tables.push({ - forSpecificUser: table.forSpecificUser, - urlName: table.urlName, - tableName: table.tableName, - symbol: table.symbol, description: table.description, + forSpecificUser: table.forSpecificUser === true, + symbol: table.symbol, + tableName: table.tableName, + urlName: table.urlName, }); } - return res.status(200).json({ - success: true, - description: `Found ${tables.length} custom table(s).`, - body: tables, - }); + return success(`Found ${tables.length} custom table(s).`, tables); }); /** @@ -82,72 +78,79 @@ router.get("/:playtype/custom-tables", ValidatePlaytypeFromParamFor("bms"), (req * @note Since this is the GPT route, trying to fetch user specific custom tables * will result in a 404. This applies for all subsequent :tableUrlName routes. * - * @name GET /api/v1/games/bms/:playtype/custom-tables/:tableUrlName + * @name GET /api/v1/games/:game/custom-tables/:tableUrlName */ -router.get( - "/:playtype/custom-tables/:tableUrlName", - ValidatePlaytypeFromParamFor("bms"), - FindCustomBMSTable, - (req, res) => { - const customTable = GetTachiData(req, "customBMSTable"); +API_V1_ROUTER.add( + "GET /games/:game/custom-tables/:tableUrlName", + withGame, + ({ ctx, params, req, res }) => { + const game = ctx.game as GamesForGroup["bms"]; - // This handles returning a response for us. - return HandleBMSTableHTMLRequest(customTable, req, res); + if (GameToGameGroup(game) !== "bms") { + throw new ExpectedErr(404, `No custom tables exist for ${game}.`); + } + + const customTable = resolveGamesCustomBMSTableOrThrow(params.tableUrlName, game); + HandleBMSTableHTMLRequest(customTable, req, res); + return success("stub", {}); }, ); /** * Return the header.json for this custom table. * - * @name GET /api/v1/games/bms/:playtype/custom-tables/:tableUrlName/header.json + * @name GET /api/v1/games/:game/custom-tables/:tableUrlName/header.json */ -router.get( - "/:playtype/custom-tables/:tableUrlName/header.json", - ValidatePlaytypeFromParamFor("bms"), - FindCustomBMSTable, - (req, res) => { - const customTable = GetTachiData(req, "customBMSTable"); +API_V1_ROUTER.add( + "GET /games/:game/custom-tables/:tableUrlName/header.json", + withGame, + async ({ ctx, params, req, res }) => { + const game = ctx.game as GamesForGroup["bms"]; - // This handles returning a response for us. - return HandleBMSTableHeaderRequest(customTable, req, res); + if (GameToGameGroup(game) !== "bms") { + throw new ExpectedErr(404, `No custom tables exist for ${game}.`); + } + + const customTable = resolveGamesCustomBMSTableOrThrow(params.tableUrlName, game); + await HandleBMSTableHeaderRequest(customTable, req, res); + return success("stub", {}); }, ); /** * Return the body.json for this custom table. * - * @name GET /api/v1/games/bms/:playtype/custom-tables/:tableUrlName/body.json + * @name GET /api/v1/games/:game/custom-tables/:tableUrlName/body.json */ -router.get( - "/:playtype/custom-tables/:tableUrlName/body.json", - ValidatePlaytypeFromParamFor("bms"), - FindCustomBMSTable, - (req, res) => { - const customTable = GetTachiData(req, "customBMSTable"); +API_V1_ROUTER.add( + "GET /games/:game/custom-tables/:tableUrlName/body.json", + withGame, + async ({ ctx, params, req, res }) => { + const game = ctx.game as GamesForGroup["bms"]; - // This handles returning a response for us. - return HandleBMSTableBodyRequest(customTable, req, res); + if (GameToGameGroup(game) !== "bms") { + throw new ExpectedErr(404, `No custom tables exist for ${game}.`); + } + + const customTable = resolveGamesCustomBMSTableOrThrow(params.tableUrlName, game); + await HandleBMSTableBodyRequest(customTable, req, res); + return success("stub", {}); }, ); /** * Return *all* the charts that have defined sieglinde values for this game. * - * @name GET /api/v1/games/bms/:playtype/sieglinde-charts + * @name GET /api/v1/games/:game/sieglinde-charts */ -router.get("/:playtype/sieglinde-charts", ValidatePlaytypeFromParamFor("bms"), async (req, res) => { - const playtype = req.params.playtype as Playtypes["bms"]; +API_V1_ROUTER.add("GET /games/:game/sieglinde-charts", withGame, async ({ ctx }) => { + const game = ctx.game as GamesForGroup["bms"]; - const { charts, songs } = await FindBMSSieglindeRatedCharts(playtype); + if (GameToGameGroup(game) !== "bms") { + throw new ExpectedErr(404, `No sieglinde charts exist for ${game}.`); + } - return res.status(200).json({ - success: true, - description: `Found ${charts.length} chart(s).`, - body: { - songs, - charts, - }, - }); + const { charts, songs } = await FindBMSSieglindeRatedCharts(game); + + return success(`Found ${charts.length} chart(s).`, { songs, charts }); }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts index 655b92a25..1cf8f5ae9 100644 --- a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts +++ b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts @@ -2,73 +2,80 @@ import { CUSTOM_TACHI_IIDX_PLAYLISTS, type TachiIIDXPlaylist, } from "#lib/game-specific/iidx-playlists"; -import { Router } from "express"; - -import { ValidatePlaytypeFromParamFor } from "../../_game/_playtype/middleware"; - -const router: Router = Router({ mergeParams: true }); +import { withGame } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import { ExpectedErr } from "bliss"; +import { type GamesForGroup, GameToGameGroup } from "tachi-common"; /** * List all the playlists we have available. * - * @name GET /api/v1/games/iidx/:playtype/playlists + * @name GET /api/v1/games/:game/playlists */ -router.get("/:playtype/playlists", ValidatePlaytypeFromParamFor("iidx"), (req, res) => { - const playlists = CUSTOM_TACHI_IIDX_PLAYLISTS.filter( - (e) => e.playtype === null || e.playtype === req.params.playtype, - ); +API_V1_ROUTER.add("GET /games/:game/playlists", withGame, ({ ctx }) => { + const game = ctx.game as GamesForGroup["iidx"]; - const body = []; + if (GameToGameGroup(game) !== "iidx") { + throw new ExpectedErr(404, `No playlists exist for ${game}.`); + } + + const playlists = CUSTOM_TACHI_IIDX_PLAYLISTS.filter((e) => e.game === null || e.game === game); + + const body: Array<{ + description: string; + forSpecificUser?: boolean; + playlistName: string; + urlName: string; + }> = []; for (const playlist of playlists) { body.push({ - forSpecificUser: playlist.forSpecificUser, - urlName: playlist.urlName, - playlistName: playlist.playlistName, description: playlist.description, + forSpecificUser: playlist.forSpecificUser, + playlistName: playlist.playlistName, + urlName: playlist.urlName, }); } - return res.status(200).json({ - success: true, - description: `Found ${playlists.length} playlist(s)`, - body: playlists, - }); + return success(`Found ${playlists.length} playlist(s)`, body); }); /** * Retrieve this playlist. * - * @name GET /api/v1/games/iidx/:playtype/playlists/:playlistID + * @name GET /api/v1/games/:game/playlists/:playlistID */ -router.get( - "/:playtype/playlists/:playlistID", - ValidatePlaytypeFromParamFor("iidx"), - async (req, res) => { +API_V1_ROUTER.add( + "GET /games/:game/playlists/:playlistID", + withGame, + async ({ ctx, params, res }) => { + const game = ctx.game as GamesForGroup["iidx"]; + + if (GameToGameGroup(game) !== "iidx") { + throw new ExpectedErr(404, `No playlists exist for ${game}.`); + } + const playlist: TachiIIDXPlaylist | undefined = CUSTOM_TACHI_IIDX_PLAYLISTS.find( - (e) => - (e.playtype === null || e.playtype === req.params.playtype) && - e.urlName === req.params.playlistID, + (e) => (e.game === null || e.game === game) && e.urlName === params.playlistID, ); if (!playlist) { - return res.status(404).json({ - success: false, - description: `No such playlist '${req.params.playlistID}' exists for '${req.params.playtype}'.`, - }); + throw new ExpectedErr( + 404, + `No such playlist '${params.playlistID}' exists for '${game}'.`, + ); } if (playlist.forSpecificUser === true) { - return res.status(404).json({ - success: false, - description: `This playlist is for a specific user. Use the /users/:userID endpoint instead.`, - }); + throw new ExpectedErr( + 404, + `This playlist is for a specific user. Use the /users/:userID endpoint instead.`, + ); } - const body = await playlist.getPlaylists(req.params.playtype as "DP" | "SP"); - - return res.status(200).json(body); + const payload = await playlist.getPlaylists(game as "iidx-dp" | "iidx-sp"); + res.status(200).json(payload); + return success("unused", null); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/router.ts b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/router.ts index 04783afdf..2ceb53f97 100644 --- a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/router.ts +++ b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/router.ts @@ -1,14 +1,4 @@ -// This file is special. These routes are "gptSpecific". They only apply to certain games -// and playtypes. This is for things like - say - custom BMS tables. +// This file is special. These routes are "gameSpecific". They only apply to certain games. +// This is for things like - say - custom BMS tables. -import { Router } from "express"; - -import bmsRouter from "./bms/router"; -import iidxRouter from "./iidx/router"; - -const router: Router = Router({ mergeParams: true }); - -router.use("/bms", bmsRouter); -router.use("/iidx", iidxRouter); - -export default router; +// -- no routes -- diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/middleware.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/middleware.ts deleted file mode 100644 index 7dc587555..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/middleware.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { RequestHandler } from "express"; - -import { GetChartById } from "#lib/db-formats/chart"; -import { AssignToReqTachiData, GetGPT } from "#utils/req-tachi-data"; -import { GamePTToV3 } from "tachi-common"; - -export const ValidateAndGetChart: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - - const chart = await GetChartById(GamePTToV3(game, playtype), req.params.chartID); - - if (!chart) { - return res.status(404).json({ - success: false, - description: `The chart ${req.params.chartID} does not exist.`, - }); - } - - AssignToReqTachiData(req, { chartDoc: chart }); - - next(); -}; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.oldtest.ts deleted file mode 100644 index bf2b10e7d..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.oldtest.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { PBScoreDocument, UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { dmf } from "#test-utils/misc.js"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA, TestingIIDXSPScorePB } from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/games/:game/:playtype/charts/:chartID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the chart at this ID", async (t) => { - const res = await mockApi.get(`/api/v1/games/iidx/SP/charts/${Testing511SPA.chartID}`); - - t.hasStrict(res.body.body, { - song: { - id: 1, - }, - chart: { - chartID: Testing511SPA.chartID, - }, - }); - t.end(); - }); - - t.test("Should return 404 if the chart doesnt exist", async (t) => { - const res = await mockApi.get(`/api/v1/games/iidx/SP/charts/FAKECHART`); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/charts/:chartID/pbs", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the best PBs on that chart.", async (t) => { - await MONGODB_KILL["personal-bests"].insert([ - TestingIIDXSPScorePB, - dmf(TestingIIDXSPScorePB, { - userID: 2, - rankingData: { rank: 2 }, - scoreData: { score: 123 }, - }), - dmf(TestingIIDXSPScorePB, { - chartID: "other_chart", - }) as PBScoreDocument, - ]); - - await MONGODB_KILL.users.insert({ - id: 2, - username: "foo", - usernameLowercase: "foo", - } as UserDocument); - - const res = await mockApi.get(`/api/v1/games/iidx/SP/charts/${Testing511SPA.chartID}/pbs`); - - t.equal(res.body.body.pbs.length, 2); - t.equal(res.body.body.users.length, 2); - - t.strictSame( - res.body.body.pbs.map((e: PBScoreDocument) => e.chartID), - [Testing511SPA.chartID, Testing511SPA.chartID], - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/charts/:chartID/playcount", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the total playcount on this chart.", async (t) => { - const pbs: Array = []; - - for (let i = 1; i <= 132; i++) { - pbs.push(dmf(TestingIIDXSPScorePB, { userID: i }) as PBScoreDocument); - } - - await MONGODB_KILL["personal-bests"].insert(pbs); - - const res = await mockApi.get( - `/api/v1/games/iidx/SP/charts/${Testing511SPA.chartID}/playcount`, - ); - - t.equal(res.body.body.count, 132); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts deleted file mode 100644 index 694ce36cc..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { LoadFolderDocumentsByIds } from "#lib/db-formats/folders"; -import { - CountPbsOnChart, - LoadPbsOnChartByRankAsc, - LoadPbsOnChartForUserSearch, -} from "#lib/db-formats/pb"; -import { GetSongByLegacyID } from "#lib/db-formats/song"; -import { GetFolderIDsForChartId } from "#lib/folders/folders"; -import { log } from "#lib/log/log"; -import { SearchUsersRegExp } from "#lib/search/search"; -import { IsString } from "#utils/misc"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { apiSuccess } from "#utils/response"; -import { ParseStrPositiveNonZeroInt } from "#utils/string-checks"; -import { GetUsersWithIDs } from "#utils/user"; -import { Router } from "express"; -import { - FormatChart, - type MONGO_FolderDocument, - type MONGO_PBScoreDocument, - type MONGO_UserDocument, - MongoChartLegacyId, -} from "tachi-common"; - -import { ValidateAndGetChart } from "./middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(ValidateAndGetChart); - -/** - * Returns the chart (and the parent song) at this chart ID. - * - * @name GET /api/v1/games/:game/:playtype/charts/:chartID - */ -router.get("/", async (req, res) => { - const chart = GetTachiData(req, "chartDoc"); - const game = GetTachiData(req, "game"); - - const songRes = await GetSongByLegacyID(game, chart.songID); - - if (!songRes) { - log.error( - `Song ${chart.songID} does not exist, yet chart ${chart.chartID} has it as a parent?`, - ); - - return res.status(500).json({ - success: false, - description: `An internal server error has occured.`, - }); - } - - const song = songRes.doc; - - return res.status(200).json({ - success: true, - description: `Returned ${FormatChart(game, song, chart)}.`, - body: { - song, - chart, - }, - }); -}); - -/** - * Returns any folders that contain this chart. - * - * @param inactive - Also include inactive folders. - * - * @name GET /api/v1/games/:game/:playtype/charts/:chartID/folders - */ -router.get("/folders", async (req, res) => { - const chart = GetTachiData(req, "chartDoc"); - - const folderIds = await GetFolderIDsForChartId(chart.chartID); - const byId = await LoadFolderDocumentsByIds(folderIds); - let folders = folderIds - .map((id) => byId.get(id)) - .filter((f): f is MONGO_FolderDocument => f !== undefined); - - if (req.query.inactive === undefined) { - folders = folders.filter((f) => !f.inactive); - } - - return res.status(200).json({ - success: true, - description: `Found ${folders.length} folders that contain this chart.`, - body: folders, - }); -}); - -/** - * Returns the total amount of unique players that have played this chart. - * - * @name GET /api/v1/games/:game/:playtype/charts/:chartID/playcount - */ -router.get("/playcount", async (req, res) => { - const chart = GetTachiData(req, "chartDoc"); - - const count = await CountPbsOnChart(chart.chartID); - - return res.status(200).json({ - success: true, - description: `Counted scores for chart.`, - body: { - count, - }, - }); -}); - -/** - * Returns the personal bests for this chart in batches of 100. - * These are returned sorted by their ranking. - * - * @param startRanking - The ranking to start iterating from - defaults to 1. - * - * @name GET /api/v1/games/:game/:playtype/charts/:chartID/pbs - */ -router.get("/pbs", async (req, res) => { - const chart = GetTachiData(req, "chartDoc"); - - const startRanking = ParseStrPositiveNonZeroInt(req.query.startRanking) ?? 1; - - const pbs = await LoadPbsOnChartByRankAsc(MongoChartLegacyId(chart), startRanking, 100); - - const users = await GetUsersWithIDs(pbs.map((e) => e.userID)); - - return res.status(200).json({ - success: true, - description: `Returned ${pbs.length} scores.`, - body: { - pbs, - users, - }, - }); -}); - -/** - * Searches the PBs on this chart for the given user(s). - * - * @param search - The user to search for - * - * @name GET /api/v1/games/:game/:playtype/charts/:chartID/pbs/search - */ -router.get("/pbs/search", async (req, res) => { - const chart = GetTachiData(req, "chartDoc"); - - if (!IsString(req.query.search)) { - return res.status(400).json({ - success: false, - description: `Invalid parameter for search.`, - }); - } - - const users = await SearchUsersRegExp(req.query.search); - - const pbs = await LoadPbsOnChartForUserSearch(MongoChartLegacyId(chart), req.query.search); - - return res.status(200).json( - apiSuccess<{ pbs: Array; users: Array }>( - `Returned ${pbs.length} scores.`, - { - pbs, - users, - }, - ), - ); -}); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.oldtest.ts deleted file mode 100644 index 22804ff59..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.oldtest.ts +++ /dev/null @@ -1,220 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { mkFakePBIIDXSP } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { LoadTachiIIDXData, Testing511SPA } from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/games/:game/:playtype/charts", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should return the most popular charts if no param is set.", async (t) => { - await MONGODB_KILL["personal-bests"].insert([ - mkFakePBIIDXSP({ - chartID: Testing511SPA.chartID, - userID: 1, - }), - mkFakePBIIDXSP({ - chartID: Testing511SPA.chartID, - userID: 2, - }), - mkFakePBIIDXSP({ - chartID: Testing511SPA.chartID, - userID: 3, - }), - mkFakePBIIDXSP({ - // gambol hyper - songID: 7, - chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b", - userID: 1, - }), - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/charts"); - - t.hasStrict(res.body.body.charts[0], { - __playcount: 3, - chartID: Testing511SPA.chartID, - }); - - t.hasStrict(res.body.body.charts[1], { - __playcount: 1, - chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b", - }); - - t.equal(res.body.body.charts.length, 100); - - t.end(); - }); - - t.test("Should search charts if a search param is set.", async (t) => { - await MONGODB_KILL["personal-bests"].insert([ - mkFakePBIIDXSP({ - // gambol hyper - songID: 7, - chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b", - userID: 1, - }), - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/charts?search=gambol"); - - t.hasStrict(res.body.body.charts[0], { - __playcount: 1, - chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b", - }); - - // gambol has SPB, SPN and SPH - t.equal(res.body.body.charts.length, 3); - - t.end(); - }); - - t.test( - "Should only return charts the requester has played if requesterHasPlayed is set.", - async (t) => { - await MONGODB_KILL["personal-bests"].insert([ - mkFakePBIIDXSP({ - // gambol hyper - songID: 7, - chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b", - userID: 2, - }), - mkFakePBIIDXSP({ - songID: 1, - chartID: Testing511SPA.chartID, - userID: 1, - }), - ]); - - const res = await mockApi - .get("/api/v1/games/iidx/SP/charts?requesterHasPlayed=true") - .set("Authorization", "Bearer fake_api_token"); - - t.hasStrict(res.body.body.charts[0], { - __playcount: 1, - chartID: Testing511SPA.chartID, - }); - - // The user has played 5.1.1., but not anything else loaded in the db. - // note that this endpoint works on played songs, rather than played charts. - t.equal(res.body.body.charts.length, 4); - - t.end(); - }, - ); - - t.test( - "Should only return charts the requester has played if requesterHasPlayed is set, and work with searches at the same time.", - async (t) => { - await MONGODB_KILL["personal-bests"].insert([ - mkFakePBIIDXSP({ - // gambol hyper - songID: 7, - chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b", - userID: 1, - }), - mkFakePBIIDXSP({ - songID: 1, - chartID: Testing511SPA.chartID, - userID: 1, - }), - ]); - - const res = await mockApi - .get("/api/v1/games/iidx/SP/charts?requesterHasPlayed=true&search=gambol") - .set("Authorization", "Bearer fake_api_token"); - - t.hasStrict(res.body.body.charts[0], { - __playcount: 1, - chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b", - }); - - // gambol has SPB, SPN and SPH, but only SPH has been played by the requester - // although 5.1.1 has been played by the requester, it should not match the - // search. - t.equal(res.body.body.charts.length, 3); - - t.end(); - }, - ); - - t.end(); -}); - -t.test("POST /api/v1/games/:game/:playtype/charts/resolve", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should resolve a chart using tachiSongID matchType.", async (t) => { - const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({ - matchType: "tachiSongID", - identifier: "1", - difficulty: "ANOTHER", - }); - - t.equal(res.statusCode, 200); - t.equal(res.body.success, true); - t.equal(res.body.body.chart.chartID, Testing511SPA.chartID); - t.equal(res.body.body.song.id, 1); - t.equal(res.body.body.song.title, "5.1.1."); - - t.end(); - }); - - t.test("Should resolve a chart using songTitle matchType.", async (t) => { - const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({ - matchType: "songTitle", - identifier: "5.1.1.", - difficulty: "ANOTHER", - }); - - t.equal(res.statusCode, 200); - t.equal(res.body.success, true); - t.equal(res.body.body.chart.chartID, Testing511SPA.chartID); - t.equal(res.body.body.song.id, 1); - - t.end(); - }); - - t.test("Should return 404 when chart cannot be resolved.", async (t) => { - const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({ - matchType: "tachiSongID", - identifier: "99999", - difficulty: "ANOTHER", - }); - - t.equal(res.statusCode, 404); - t.equal(res.body.success, false); - t.match(res.body.description, /Could not resolve this chart/u); - - t.end(); - }); - - t.test("Should return 400 for invalid request body.", async (t) => { - const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({ - matchType: "invalidMatchType", - identifier: "1", - }); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.test("Should return 400 when required fields are missing.", async (t) => { - const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({ - matchType: "tachiSongID", - // missing identifier - }); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts deleted file mode 100644 index b49eb16df..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { GetSongsByLegacyIDs } from "#lib/db-formats/song"; -import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings"; -import { log } from "#lib/log/log"; -import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter"; -import { SearchSpecificGameSongs } from "#lib/search/songs.js"; -import prValidate from "#server/middleware/prudence-validate"; -import DB from "#services/pg/db"; -import { IsString } from "#utils/misc"; -import { FindChartsOnPopularity } from "#utils/queries/charts"; -import { GetGPT } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { - GamePTToV3, - type integer, - type MatchTypeResolver, - type MONGO_ChartDocument, - type MONGO_UGPTSettingsDocument, -} from "tachi-common"; -import { PR_RESOLVER } from "tachi-common/lib/schemas"; -import { type Game } from "tachi-db"; - -import chartIDRouter from "./_chartID/router"; - -const router: Router = Router({ mergeParams: true }); - -/** - * Searches for charts on this game - if no search parameter is given, - * returns the 100 most popular charts for this game. - * - * @param search - The song title to match on. - * @param noIntelligentOmit - If present, will not perform intelligent - * chart omissions from results. - * @param requesterHasPlayed - If present, will only return charts the - * requesting user has a PB on. If this request doesn't belong to a user, - * this returns 401. - * - * @name GET /api/v1/games/:game/:playtype/charts - */ -router.get("/", async (req, res) => { - const { game, playtype } = GetGPT(req); - - let songIDs: Array | undefined; - - if (IsString(req.query.search)) { - const songs = await SearchSpecificGameSongs(game, req.query.search, 100); - - songIDs = songs.map((e) => e.id); - } - - if (IsString(req.query.requesterHasPlayed)) { - const userID = req[SYMBOL_TACHI_API_AUTH].userID; - - if (userID === null) { - return res.status(401).json({ - success: false, - description: `You must be authorised as a user to use the requesterHasPlayed option.`, - }); - } - - const v3Game = GamePTToV3(game, playtype) as Game; - const playedSongRows = await DB.selectFrom("pb") - .innerJoin("chart", "chart.id", "pb.chart_id") - .innerJoin("song", "song.id", "chart.song_id") - .select("song.legacy_id as song_legacy_id") - .distinct() - .where("pb.user_id", "=", userID) - .where("chart.game", "=", v3Game) - .execute(); - const playedSongs = playedSongRows.map((e) => e.song_legacy_id); - - if (songIDs) { - songIDs = songIDs.filter((e) => playedSongs.includes(e)); - } else { - songIDs = playedSongs; - } - } - - const skip = 0; - const limit = 100; - - let charts = (await FindChartsOnPopularity( - game, - playtype, - - // if empty, we want the set of all songs. Otherwise, constrict input. - songIDs, - skip, - limit, - "personal-bests", - )) as Array; - - // @optimisable - // could use songIDs from above instead of refetching - // but this is not very expensive. - const songs = await GetSongsByLegacyIDs( - game, - charts.map((e) => e.songID), - ); - - // Edge case. - // If the game is IIDX and the player does not want - // to see 2dxtra charts, we need to remove them from the - // result of a search. - // - // Since most players will have this off, this is not a significant - // performance hit. - if (game === "iidx" && req.query.noIntelligentOmit === undefined) { - if (req[SYMBOL_TACHI_API_AUTH].userID === null) { - charts = charts.filter( - (e) => (e as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] === null, - ); - } else { - const iidxSettings = await GetUGPTSettingsDocument( - req[SYMBOL_TACHI_API_AUTH].userID, - game, - playtype, - ); - - const iidxGameSpecific = iidxSettings?.preferences - .gameSpecific as MONGO_UGPTSettingsDocument< - "iidx:DP" | "iidx:SP" - >["preferences"]["gameSpecific"]; - - if (!iidxGameSpecific?.display2DXTra) { - charts = charts.filter( - (e) => - (e as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] === - null, - ); - } - } - } - - return res.status(200).json({ - success: true, - description: `Returned ${charts.length} charts.`, - body: { - charts, - songs, - }, - }); -}); - -/** - * Use the tachi "resolve" engine to identify a chart instead of - * using the Tachi IDs. Used to get a chart. - * - * @name POST /api/v1/users/:userID/games/:game/:playtype/pbs/resolve - */ -router.post("/resolve", prValidate(PR_RESOLVER), async (req, res) => { - const { game, playtype } = GetGPT(req); - - const safeBody = { - ...req.safeBody, - game, - playtype, - } as unknown as MatchTypeResolver; - const got = await ResolveSongAndChart(safeBody, log); - - if (!got) { - return res.status(404).json({ - success: false, - description: `Could not resolve this chart with details: ${safeBody.matchType}:${safeBody.identifier} (Extra specifiers: version=${safeBody.version}, artist=${safeBody.artist})`, - }); - } - - return res.status(200).json({ - success: true, - description: "Successfully retrieved chart info.", - body: { - chart: got.chart, - song: got.song, - }, - }); -}); - -router.use("/:chartID", chartIDRouter); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/middleware.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/middleware.ts deleted file mode 100644 index 1bf0e7e06..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/middleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { RequestHandler } from "express"; - -import { LoadFolderDocumentById } from "#lib/db-formats/folders.js"; -import { AssignToReqTachiData, GetGPT } from "#utils/req-tachi-data"; - -export const GetFolderFromParam: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - - const folder = await LoadFolderDocumentById(req.params.folderID); - - if (!folder || folder.game !== game || folder.playtype !== playtype) { - return res.status(404).json({ - success: false, - description: `This folder does not exist.`, - }); - } - - AssignToReqTachiData(req, { folderDoc: folder }); - - next(); -}; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/router.oldtest.ts deleted file mode 100644 index 2efe3dfd4..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/router.oldtest.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { FolderDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA } from "#test-utils/test-data"; -import { CreateFolderChartLookup } from "#utils/folder"; -import deepmerge from "deepmerge"; -import t from "tap"; - -const mockFolder: FolderDocument = { - folderID: "foo", - game: "iidx", - playtype: "SP", - title: "12", - data: { - level: "10", - }, - type: "charts", - searchTerms: [], - inactive: false, -}; - -t.test("GET /api/v1/games/:game/:playtype/folders", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should search the folders for this game.", async (t) => { - await MONGODB_KILL.folders.insert([ - deepmerge(mockFolder, {}), - deepmerge(mockFolder, { folderID: "bar", playtype: "DP" }), - deepmerge(mockFolder, { folderID: "baz", game: "bms" }), - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/folders?search=12"); - - t.equal(res.body.body.length, 1); - t.equal(res.body.body[0].folderID, "foo"); - - t.end(); - }); - - t.test("Should return 400 if no search parameter is given.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/folders"); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/folders/:folderID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the folder at this ID.", async (t) => { - await MONGODB_KILL.folders.insert(deepmerge(mockFolder, {})); - await CreateFolderChartLookup(mockFolder, true); - - const res = await mockApi.get("/api/v1/games/iidx/SP/folders/foo"); - - t.equal(res.body.body.folder.folderID, "foo"); - t.equal(res.body.body.songs.length, 1); - t.equal(res.body.body.charts.length, 1); - t.equal(res.body.body.songs[0].id, 1); - t.equal(res.body.body.charts[0].chartID, Testing511SPA.chartID); - - t.end(); - }); - - t.test("Should return 404 if the folder does not exist.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/folders/bar"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/router.ts deleted file mode 100644 index 053d6f69c..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/folders/router.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { GetFolderChartsAndSongs } from "#lib/folders/folders.js"; -import { SearchFoldersForGameFtsAndTrgm } from "#lib/search/folders.js"; -import { IsString } from "#utils/misc"; -import { GetGPT, GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; - -import { GetFolderFromParam } from "./middleware"; - -const router: Router = Router({ mergeParams: true }); - -/** - * Search the folders for this GPT. - * - * @param search - The query to search for. - * @param inactive - Also show inactive folders, such as those on old versions. - * - * @name GET /api/v1/games/:game/:playtype/folders - */ -router.get("/", async (req, res) => { - const { game, playtype } = GetGPT(req); - - if (!IsString(req.query.search)) { - return res.status(400).json({ - success: false, - description: `Invalid value for search.`, - }); - } - - // If `inactive` is passed, include inactive folders; otherwise only active folders. - const onlyActiveFolders = req.query.inactive === undefined; - - const folders = await SearchFoldersForGameFtsAndTrgm(game, playtype, req.query.search, { - limit: 100, - onlyActiveFolders, - }); - - return res.status(200).json({ - success: true, - description: `Returned ${folders.length} folders.`, - body: folders, - }); -}); - -/** - * Get the folder at this ID, alongside its charts and songs. - * - * @name GET /api/v1/games/:game/:playtype/folders/:folderID - */ -router.get("/:folderID", GetFolderFromParam, async (req, res) => { - const folder = GetTachiData(req, "folderDoc"); - - const { songs, charts } = await GetFolderChartsAndSongs(folder); - - return res.status(200).json({ - success: true, - description: `Returned data for folder ${folder.title}`, - body: { - songs, - charts, - folder, - }, - }); -}); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/middleware.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/middleware.ts deleted file mode 100644 index e69cfd65b..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/middleware.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { RequestHandler } from "express"; - -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; -import { type GameGroup, GetGameGroupConfig, type Playtype } from "tachi-common"; - -export const ValidatePlaytypeFromParam: RequestHandler = (req, res, next) => { - const game = GetTachiData(req, "game"); - - const gameConfig = GetGameGroupConfig(game); - - if (!gameConfig.playtypes.includes(req.params.playtype as Playtype)) { - return res.status(400).json({ - success: false, - description: `The playtype ${req.params.playtype} is not supported.`, - }); - } - - AssignToReqTachiData(req, { playtype: req.params.playtype as Playtype }); - - next(); -}; - -export const ValidatePlaytypeFromParamFor = - (game: GameGroup): RequestHandler => - (req, res, next) => { - const gameConfig = GetGameGroupConfig(game); - - if (!gameConfig.playtypes.includes(req.params.playtype as Playtype)) { - return res.status(400).json({ - success: false, - description: `The playtype ${req.params.playtype} is not supported.`, - }); - } - - AssignToReqTachiData(req, { playtype: req.params.playtype as Playtype }); - - next(); - }; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.oldtest.ts deleted file mode 100644 index 79ae4df9b..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.oldtest.ts +++ /dev/null @@ -1,165 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { mkFakeGameStats, mkFakeUser } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { FakeOtherUser } from "#test-utils/test-data"; -import dm from "deepmerge"; -import { GetGamePTConfig, type UserDocument, type UserGameStats } from "tachi-common"; -import t from "tap"; - -t.test("GET /api/v1/games/:game/:playtype", (t) => { - t.test("Should return information about the game:playtype.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP"); - - t.hasStrict(GetGamePTConfig("iidx", "SP"), res.body.body.config); - - t.equal(res.body.body.chartCount, 1); - t.equal(res.body.body.playerCount, 1); - t.equal(res.body.body.scoreCount, 1); - - t.end(); - }); - - t.test("Should reject invalid playtypes for this game.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/Single"); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/leaderboard", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the leaderboards for this game", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/leaderboard"); - - t.equal(res.statusCode, 200); - t.equal(res.body.body.gameStats.length, 1); - t.equal(res.body.body.users.length, 1); - - t.hasStrict(res.body.body, { - gameStats: [ - { - userID: 1, - game: "iidx", - playtype: "SP", - }, - ], - users: [ - { - id: 1, - }, - ], - }); - - t.end(); - }); - - t.test("Should reject unknown alg", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/leaderboard?alg=naiveRating"); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should use provided algorithm to resort results.", async (t) => { - await MONGODB_KILL["game-stats"].insert([ - { - userID: 2, - game: "iidx", - playtype: "SP", - ratings: { - BPI: 100, - }, - }, - { - userID: 3, - game: "iidx", - playtype: "SP", - ratings: { - BPI: 50, - }, - }, - ] as Array); - - await MONGODB_KILL.users.insert([ - FakeOtherUser, - dm(FakeOtherUser, { - username: "foo", - usernameLowercase: "foo", - id: 3, - }) as UserDocument, - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/leaderboard?alg=BPI"); - - t.strictSame( - res.body.body.gameStats.map((e: UserGameStats) => e.userID), - [2, 3, 1], - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/players", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL.users.insert([ - mkFakeUser(2, { usernameLowercase: "scrimblo" }), - mkFakeUser(3, { usernameLowercase: "scrimblo_2" }), - mkFakeUser(4, { usernameLowercase: "scrimblo_3" }), - mkFakeUser(5, { usernameLowercase: "cloudy" }), - ]); - - await MONGODB_KILL["game-stats"].insert([ - mkFakeGameStats(2), - mkFakeGameStats(3, { game: "iidx", playtype: "DP" }), - mkFakeGameStats(4, { game: "bms", playtype: "7K" }), - mkFakeGameStats(5), - ]); - }); - - t.test("Should find the users where this game has been played.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/players?search=scrimblo"); - - t.hasStrict(res.body.body, [{ id: 2 }]); - t.equal(res.body.body.length, 1); - - t.end(); - }); - - t.test("Should definitely honour the search parameter.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/players?search=nobody"); - - t.strictSame(res.body.body, []); - - t.end(); - }); - - t.test("Should definitely honour the GPT.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/DP/players?search=scrimblo"); - - t.hasStrict(res.body.body, [{ id: 3 }]); - t.equal(res.body.body.length, 1); - - t.end(); - }); - - t.test("Should require the search parameter.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/DP/players"); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.ts deleted file mode 100644 index c8e2c0a24..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { CreateActivityRouteHandler } from "#lib/activity/activity"; -import { ONE_HOUR } from "#lib/constants/time"; -import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js"; -import { LoadPbDocumentsForGameSortedByCalculatedAlg } from "#lib/db-formats/pb"; -import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user.js"; -import prValidate from "#server/middleware/prudence-validate"; -import DB from "#services/pg/db.js"; -import { GetRelevantSongsAndCharts } from "#utils/db"; -import { EscapeForILIKE, IsString } from "#utils/misc"; -import { GetGPT } from "#utils/req-tachi-data"; -import { - CheckStrProfileAlg, - CheckStrScoreAlg, - ParseStrPositiveNonZeroInt, -} from "#utils/string-checks"; -import { GetUsersWithIDs } from "#utils/user"; -import { Router } from "express"; -import { sql } from "kysely"; -import NodeCache from "node-cache"; -import { - FormatGameGroup, - type GameGroup, - GamePTToV3, - GetGamePTConfig, - type integer, - type Playtype, -} from "tachi-common"; - -import chartsRouter from "./charts/router"; -import foldersRouter from "./folders/router"; -import { ValidatePlaytypeFromParam } from "./middleware"; -import songIDRouter from "./songs/_songID/router"; -import tablesRouter from "./tables/router"; -import targetsRouter from "./targets/router"; - -const router: Router = Router({ mergeParams: true }); - -router.use(ValidatePlaytypeFromParam); - -const gptStatCache = new NodeCache(); - -async function GetGameStats( - game: GameGroup, - playtype: Playtype, -): Promise<{ chartCount: integer; playerCount: integer; scoreCount: integer }> { - const cacheRes = gptStatCache.get(`${game}:${playtype}`); - - if (cacheRes === undefined) { - const v3Game = GamePTToV3(game, playtype); - - const [scoreCount, playerCount, chartCount] = await Promise.all([ - DB.selectFrom("score") - .select((eb) => eb.fn.countAll().as("c")) - .where("score.game", "=", v3Game) - .executeTakeFirst() - .then((r) => Number(r?.c ?? 0)), - DB.selectFrom("game_profile") - .select((eb) => eb.fn.countAll().as("c")) - .where("game_profile.game", "=", v3Game) - .executeTakeFirst() - .then((r) => Number(r?.c ?? 0)), - DB.selectFrom("chart") - .select((eb) => eb.fn.countAll().as("c")) - .where("chart.game", "=", v3Game) - .executeTakeFirst() - .then((r) => Number(r?.c ?? 0)), - ]); - - gptStatCache.set(`${game}:${playtype}`, { scoreCount, playerCount, chartCount }, ONE_HOUR); - - return { scoreCount, playerCount, chartCount }; - } - - return cacheRes as { chartCount: integer; playerCount: integer; scoreCount: integer }; -} - -/** - * Returns the configuration for this game along with some statistics. - * - * @name GET /api/v1/games/:game/:playtype - */ -router.get("/", async (req, res) => { - const { game, playtype } = GetGPT(req); - - const { scoreCount, playerCount, chartCount } = await GetGameStats(game, playtype); - - return res.status(200).json({ - success: true, - description: `Retrieved information about ${FormatGameGroup(game, playtype)}`, - body: { - config: GetGamePTConfig(game, playtype), - scoreCount, - playerCount, - chartCount, - }, - }); -}); - -/** - * Returns user-game-stats for this game in batches of 500. - * This is sorted by the games default-sorting-statistic. - * - * @param alg - An alternative algorithm to use instead of the gpts default. - * @param limit - How many users to return at most. Defaults (and is limited to) 500. - * - * @name GET /api/v1/games/:game/:playtype/leaderboard - */ -router.get("/leaderboard", async (req, res) => { - const { game, playtype } = GetGPT(req); - const gptConfig = GetGamePTConfig(game, playtype); - - const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 100; - - if (limit > 500) { - return res.status(400).json({ - success: false, - description: `Invalid limit. Limit is capped at 500.`, - }); - } - - let alg = gptConfig.defaultProfileRatingAlg; - - if (IsString(req.query.alg)) { - const temp = CheckStrProfileAlg(game, playtype, req.query.alg); - - if (temp === null) { - return res.status(400).json({ - success: false, - description: `Invalid value of ${ - req.query.alg - } for alg. Expected one of ${Object.keys(gptConfig.profileRatingAlgs).join(", ")}`, - }); - } - - alg = temp; - } - - const v3Game = GamePTToV3(game, playtype); - const ratingCol = sql`coalesce((game_profile.ratings::jsonb->>${sql.lit(alg)})::numeric, 0)`; - - const gameStats = await DB.selectFrom("game_profile") - .select(SELECT_GAME_PROFILE) - .where("game_profile.game", "=", v3Game) - .orderBy(ratingCol, "desc") - .limit(limit) - .execute() - .then((rows) => rows.map(ToGameStatsDocument)); - - const users = await GetUsersWithIDs(gameStats.map((e) => e.userID)); - - return res.status(200).json({ - success: true, - description: `Returned ${gameStats.length} user's game stats.`, - body: { - gameStats, - users, - }, - }); -}); - -/** - * Returns the best scores for this game. - * - * @param alg - An alternative algorithm to use instead of the gpts default. - * @param limit - How many scores to return. - * - * @name GET /api/v1/games/:game/:playtype/pb-leaderboard - */ -router.get("/pb-leaderboard", async (req, res) => { - const { game, playtype } = GetGPT(req); - const gptConfig = GetGamePTConfig(game, playtype); - - const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 50; - - if (limit > 50) { - return res.status(400).json({ - success: false, - description: `Cannot specify a limit higher than 50.`, - }); - } - - let alg = gptConfig.defaultScoreRatingAlg; - - if (IsString(req.query.alg)) { - const temp = CheckStrScoreAlg(game, playtype, req.query.alg); - - if (temp === null) { - return res.status(400).json({ - success: false, - description: `Invalid value of ${ - req.query.alg - } for alg. Expected one of ${Object.keys(gptConfig.profileRatingAlgs).join(", ")}`, - }); - } - - alg = temp; - } - - const v3Game = GamePTToV3(game, playtype); - const pbs = await LoadPbDocumentsForGameSortedByCalculatedAlg(v3Game, alg, limit); - - const users = await GetUsersWithIDs(pbs.map((e) => e.userID)); - - const { songs, charts } = await GetRelevantSongsAndCharts(pbs, game); - - return res.status(200).send({ - success: true, - description: `Successfully returned ${pbs.length} pbs.`, - body: { - pbs, - songs, - charts, - users, - }, - }); -}); - -/** - * Search users that have played this game. - * - * @param search - The username to search for. - * - * @name GET /api/v1/games/:game/:playtype/players - */ -router.get( - "/players", - prValidate({ - search: "string", - }), - async (req, res) => { - const { game, playtype } = GetGPT(req); - const v3Game = GamePTToV3(game, playtype); - - const { search } = req.query as { - search: string; - }; - - const gptPlayers = await DB.selectFrom("account") - .innerJoin("game_profile", "game_profile.user_id", "account.id") - .select(SELECT_USER) - .where("game_profile.game", "=", v3Game) - .where("account.username", "ilike", `%${EscapeForILIKE(search)}%`) - .execute() - .then((res) => res.map(ToUserDocument)); - - return res.status(200).json({ - success: true, - description: `Found ${gptPlayers.length} user(s)`, - body: gptPlayers, - }); - }, -); - -/** - * Retrieve activity for this GPT. - * - * @name GET /api/v1/games/:game/:playtype/activity - */ -router.get("/activity", (req, res) => { - const { game, playtype } = GetGPT(req); - - const route = CreateActivityRouteHandler({ - game, - playtype, - }); - - // this handles responding - void route(req, res); -}); - -router.use("/charts", chartsRouter); -router.use("/songs/:songID", songIDRouter); -router.use("/folders", foldersRouter); -router.use("/tables", tablesRouter); -router.use("/targets", targetsRouter); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/middleware.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/middleware.ts deleted file mode 100644 index 3615b2428..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/middleware.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { RequestHandler } from "express"; - -import { GetSongByLegacyID } from "#lib/db-formats/song"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; -import { ParseStrPositiveInt } from "#utils/string-checks"; - -export const ValidateAndGetSong: RequestHandler = async (req, res, next) => { - const songID = ParseStrPositiveInt(req.params.songID); - - if (songID === null) { - return res.status(400).json({ - success: false, - description: `Invalid songID - could not be converted into integer?`, - }); - } - - const game = GetTachiData(req, "game"); - - const result = await GetSongByLegacyID(game, songID); - - if (!result) { - return res.status(404).json({ - success: false, - description: `No song exists with the songID ${songID}.`, - }); - } - - AssignToReqTachiData(req, { songDoc: result.doc, songNewID: result.newSongID }); - - next(); -}; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.oldtest.ts deleted file mode 100644 index 8b1f49abe..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.oldtest.ts +++ /dev/null @@ -1,41 +0,0 @@ -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { LoadTachiIIDXData } from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/games/:game/:playtype/songs/:songID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should return the song at this ID and all of its charts.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/songs/1"); - - t.equal(res.body.body.charts.length, 4); - - t.equal(res.body.body.song.id, 1); - - t.end(); - }); - - t.test("Should return 404 if this songID does not exist.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/songs/0"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Should return 400 if songID is not coercible into an integer.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/songs/1.5"); - - t.equal(res.statusCode, 400); - - const res2 = await mockApi.get("/api/v1/games/iidx/SP/songs/FOO"); - - t.equal(res2.statusCode, 400); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts deleted file mode 100644 index ff2cf0ca7..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { GetChartsBySongId } from "#lib/db-formats/chart"; -import { GetGPT, GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { GamePTToV3 } from "tachi-common"; - -import { ValidateAndGetSong } from "./middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(ValidateAndGetSong); - -/** - * Returns the song at this ID and its child chart documents. - * - * @name GET /api/v1/games/:game/:playtype/songs/:songID - */ -router.get("/", async (req, res) => { - const song = GetTachiData(req, "songDoc"); - const songNewID = GetTachiData(req, "songNewID"); - const { game, playtype } = GetGPT(req); - - const charts = await GetChartsBySongId(GamePTToV3(game, playtype), songNewID); - - return res.status(200).json({ - success: true, - description: `Returned ${charts.length} charts for song ${song.title}.`, - body: { - song, - charts, - }, - }); -}); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/middleware.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/middleware.ts deleted file mode 100644 index c7456f813..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/middleware.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { RequestHandler } from "express"; - -import { LoadTableDocumentByLegacyIdForGamePlaytype } from "#lib/db-formats/table"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; - -export const GetTableFromParam: RequestHandler = async (req, res, next) => { - const game = GetTachiData(req, "game"); - const playtype = GetTachiData(req, "playtype"); - - const table = await LoadTableDocumentByLegacyIdForGamePlaytype( - req.params.tableID, - game, - playtype, - ); - - if (!table) { - return res.status(404).json({ - success: false, - description: `This table does not exist.`, - }); - } - - AssignToReqTachiData(req, { tableDoc: table }); - - next(); -}; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.oldtest.ts deleted file mode 100644 index 0fc18ad95..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.oldtest.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { FolderDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { TestingIIDXFolderSP10 } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -t.test("GET /api/v1/games/:game/:playtype/tables", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return all folders for this game.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/tables"); - - t.equal(res.body.body.length, 1); - t.equal(res.body.body[0].tableID, "mock_table"); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/tables/:tableID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the table at this ID and its folders.", async (t) => { - await MONGODB_KILL.folders.insert( - deepmerge(TestingIIDXFolderSP10, { folderID: "testing_folder" }) as FolderDocument, - ); - - const res = await mockApi.get("/api/v1/games/iidx/SP/tables/mock_table"); - - t.equal(res.body.body.folders.length, 1); - t.equal(res.body.body.folders[0].folderID, "testing_folder"); - t.equal(res.body.body.table.tableID, "mock_table"); - - t.end(); - }); - - t.test("Should return 404 if the table does not exist.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/tables/non_existent_table"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts deleted file mode 100644 index f5462fd1e..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { GetTableDocumentsForGamePlaytype } from "#lib/db-formats/table"; -import { log } from "#lib/log/log"; -import { GetFoldersFromTable } from "#utils/folder"; -import { GetGPT, GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { FormatGameGroup } from "tachi-common"; - -import { GetTableFromParam } from "./middleware"; - -const router: Router = Router({ mergeParams: true }); - -/** - * Return all the tables for this game. - * - * @param showInactive - If present, also show "inactive" tables. - * - * @name GET /api/v1/games/:game/:playtype/tables - */ -router.get("/", async (req, res) => { - const { game, playtype } = GetGPT(req); - - const includeInactive = req.query.showInactive !== undefined; - - const tables = await GetTableDocumentsForGamePlaytype(game, playtype, includeInactive); - - if (tables.length === 0) { - log.error( - `The game ${FormatGameGroup( - game, - playtype, - )} has no tables. This renders table support for the game broken!`, - ); - return res.status(500).json({ - success: false, - description: "This game has no tables.", - }); - } - - return res.status(200).json({ - success: true, - description: `Returned ${tables.length} tables.`, - body: tables, - }); -}); - -/** - * Return the folder documents that make up this table. - * - * @name GET /api/v1/games/:game/:playtype/tables/:tableID - */ -router.get("/:tableID", GetTableFromParam, async (req, res) => { - const table = GetTachiData(req, "tableDoc"); - - const folders = await GetFoldersFromTable(table); - - return res.status(200).json({ - success: true, - description: `Returned ${folders.length} for table ${table.title}.`, - body: { - folders, - table, - }, - }); -}); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.oldtest.ts deleted file mode 100644 index a99a94ae4..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.oldtest.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { GoalDocument, GoalSubscriptionDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - FakeOtherUser, - IIDXSPQuestGoals, - IIDXSPQuestGoalSubs, - TestingIIDXSPQuest, -} from "#test-utils/test-data"; -import dm from "deepmerge"; -import t from "tap"; - -// this is my lazy sample data for these tests. -const LoadLazySampleData = async () => { - await MONGODB_KILL.users.insert(FakeOtherUser); - await MONGODB_KILL.goals.insert(IIDXSPQuestGoals); - await MONGODB_KILL["goal-subs"].insert([ - ...IIDXSPQuestGoalSubs, - dm(IIDXSPQuestGoalSubs[0]!, { - userID: 2, - }), - ] as Array); -}; - -t.test("GET /api/v1/games/:game/:playtype/targets/goals/popular", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadLazySampleData); - - t.test("Should return the most popular subscribed goals for this game.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/goals/popular"); - - t.equal(res.statusCode, 200, "Should return 200."); - - // note: we have to sort the output here such that it's deterministic. - t.strictSame( - (res.body.body as Array).sort((a, b) => a.goalID.localeCompare(b.goalID)), - ( - [ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dm(IIDXSPQuestGoals[0] as any, { __subscriptions: 2 }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dm(IIDXSPQuestGoals[1] as any, { __subscriptions: 1 }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dm(IIDXSPQuestGoals[2] as any, { __subscriptions: 1 }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dm(IIDXSPQuestGoals[3] as any, { __subscriptions: 1 }), - ] as unknown as Array - ).sort((a, b) => a.goalID.localeCompare(b.goalID)), - "Should return the most subscribed goals.", - ); - - t.end(); - }); - - t.test("Should return an empty array if nobody has done anything.", async (t) => { - const res = await mockApi.get("/api/v1/games/chunithm/Single/targets/goals/popular"); - - t.equal(res.statusCode, 200, "Should return 200."); - t.strictSame(res.body.body, []); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/targets/goals/:goalID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadLazySampleData); - - t.test("Should return information about the specified goal.", async (t) => { - await MONGODB_KILL.quests.insert(TestingIIDXSPQuest); - - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/goals/eg_goal_1"); - - t.hasStrict(res.body.body, { - goal: { - goalID: "eg_goal_1", - }, - goalSubs: [ - { userID: 1, goalID: "eg_goal_1" }, - { userID: 2, goalID: "eg_goal_1" }, - ], - users: [{ id: 1 }, { id: 2 }], - parentQuests: [{ questID: TestingIIDXSPQuest.questID }], - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts deleted file mode 100644 index 30a53b529..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; -import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents"; -import { CreateGoalTitle, ValidateGoalChartsAndCriteria } from "#lib/targets/goal-utils"; -import { GetQuestsThatContainGoal } from "#lib/targets/goals"; -import prValidate from "#server/middleware/prudence-validate"; -import DB from "#services/pg/db"; -import { GetMostSubscribedGoals } from "#utils/db"; -import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data"; -import { GetUsersWithIDs } from "#utils/user"; -import { type RequestHandler, Router } from "express"; -import { p } from "prudence"; -import { GamePTToV3, type MONGO_GoalDocument } from "tachi-common"; - -const router: Router = Router({ mergeParams: true }); - -/** - * Get the most popular goals for this GPT. - * - * @name GET /api/v1/games/:game/:playtype/targets/goals/popular - */ -router.get("/popular", async (req, res) => { - const { game, playtype } = GetGPT(req); - - const goals = await GetMostSubscribedGoals({ game, playtype }); - - return res.status(200).json({ - success: true, - description: `Returned ${goals.length} goals.`, - body: goals, - }); -}); - -/** - * Given a partial goal, return a name for it. This formats the goal into something like - * "AAA 100 charts in the Level 12 folder". - * - * This is used by the quest editor, as the controls for formatting charts are done - * on the backend. - * - * This is a post request because it expects nested data, and get requests suck - * for that. - * - * @name POST /api/v1/games/:game/:playtype/targets/goals/format - */ -router.post( - "/format", - prValidate( - { - criteria: { - // we do proper validation on this later. - key: "string", - value: p.gte(0), - - mode: p.isIn("single", "absolute", "proportion"), - countNum: (self, parent) => { - if (parent.mode === "single") { - return ( - self === undefined || - "Invalid countNum for mode 'single'. Must not have one!" - ); - } - - // proper validation later. - return p.gte(0)(self); - }, - }, - charts: { - type: p.isIn("single", "multi", "folder"), - data: (self, parent) => { - if (parent.type === "single") { - return ( - typeof self === "string" || - "Expected a string in charts.data due to charts.type being 'single'." - ); - } else if (parent.type === "multi") { - return ( - (Array.isArray(self) && - self.every((k) => typeof k === "string") && - self.length <= 10 && - self.length > 1) || - "Expected an array of 2 to 10 strings in charts.data due to charts.type being 'multi'." - ); - /* istanbul ignore next */ - } else if (parent.type === "folder") { - return ( - typeof self === "string" || - "Expected a string in charts.data due to charts.type being 'folder'." - ); - } - - // impossible to reach, so doesn't count for coverage. - /* istanbul ignore next */ - return "Unknown charts.type."; - }, - }, - }, - undefined, - undefined, - "debug", - ), - async (req, res) => { - const { game, playtype } = GetGPT(req); - - const { charts, criteria } = req.safeBody as { - charts: MONGO_GoalDocument["charts"]; - criteria: MONGO_GoalDocument["criteria"]; - }; - - try { - await ValidateGoalChartsAndCriteria(charts, criteria, game, playtype); - } catch (e) { - const err = e as Error; - - return res.status(400).json({ - success: false, - description: `Invalid goal: ${err.message}.`, - }); - } - - const title = await CreateGoalTitle(charts, criteria, game, playtype); - - return res.status(200).json({ - success: true, - description: `Formatted goal.`, - body: title, - }); - }, -); - -const ResolveGoalID: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - const goalID = req.params.goalID; - - const v3Game = GamePTToV3(game, playtype); - - const row = await DB.selectFrom("goal") - .select(SELECT_GOAL) - .where("goal.id", "=", goalID) - .where("goal.game", "=", v3Game) - .executeTakeFirst(); - - if (!row) { - return res.status(404).json({ - success: false, - description: `A goal with ID ${goalID} doesn't exist.`, - }); - } - - AssignToReqTachiData(req, { goalDoc: ToGoalDocument(row) }); - - next(); -}; - -/** - * Retrieve information about this goal and who is subscribed to it. - * - * @name GET /api/v1/games/:game/:playtype/targets/goals/:goalID - */ -router.get("/:goalID", ResolveGoalID, async (req, res) => { - const goal = GetTachiData(req, "goalDoc"); - - const subRows = await DB.selectFrom("goal_sub") - .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) - .where("goal_sub.goal_id", "=", goal.goalID) - .execute(); - - const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); - - const users = await GetUsersWithIDs(goalSubs.map((e) => e.userID)); - - const parentQuests = await GetQuestsThatContainGoal(goal.goalID); - - return res.status(200).json({ - success: true, - description: `Retrieved information about ${goal.name}.`, - body: { - goal, - goalSubs, - users, - parentQuests, - }, - }); -}); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.oldtest.ts deleted file mode 100644 index aa0a21e7e..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.oldtest.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { QuestDocument, QuestlineDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { TestingIIDXSPQuest } from "#test-utils/test-data"; -import dm from "deepmerge"; -import t from "tap"; - -const TestingIIDXSPQuestline: QuestlineDocument = { - name: "Testing Questline", - desc: "foo", - game: "iidx", - quests: [TestingIIDXSPQuest.questID, "other_quest"], - playtype: "SP", - questlineID: "quest_set", -}; - -t.test("GET /api/v1/games/:game/:playtype/targets/questlines", (t) => { - t.beforeEach(ResetDBState); - - function mkSet(merge: any) { - return dm(TestingIIDXSPQuestline, merge) as QuestlineDocument; - } - - t.test("Should return all questlines for this game.", async (t) => { - await MONGODB_KILL.questlines.insert([ - mkSet({ name: "Testing Set", questlineID: "name" }), - mkSet({ name: "Testing Other Set", questlineID: "similar_name" }), - mkSet({ name: "Different Name", questlineID: "radically_different_name" }), - mkSet({ - game: "chunithm", - playtype: "Single", - questlineID: "matching name but different gpt", - }), - mkSet({ playtype: "DP", questlineID: "matching name but different playtype" }), - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines"); - - t.hasStrict( - (res.body.body.questlines as Array).sort((a, b) => - a.name.localeCompare(b.name), - ), - [ - { questlineID: "radically_different_name" }, - { questlineID: "similar_name" }, - { questlineID: "name" }, - ], - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL.questlines.insert(TestingIIDXSPQuestline); - await MONGODB_KILL.quests.insert([ - TestingIIDXSPQuest, - dm(TestingIIDXSPQuest, { questID: "other_quest" }) as QuestDocument, - ]); - }); - - t.test("Should return the questline and its quests.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines/quest_set"); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.hasStrict(res.body.body.questline, { - questlineID: TestingIIDXSPQuestline.questlineID, - }); - - t.hasStrict( - (res.body.body.quests as Array).sort((a, b) => - a.questID.localeCompare(b.questID), - ), - [{ questID: TestingIIDXSPQuest.questID }, { questID: "other_quest" }], - ); - - t.end(); - }); - - t.test("Should return 404 if the questline doesn't exist.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines/foobar"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts deleted file mode 100644 index 91397ce01..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { FindStandaloneQuests, GetGoalsInQuests } from "#lib/targets/quests"; -import { GetChildQuests } from "#utils/db"; -import { GetQuestlineById, GetQuestlinesForGamePlaytype } from "#utils/queries/questlines"; -import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data"; -import { type RequestHandler, Router } from "express"; - -const router: Router = Router({ mergeParams: true }); - -const ResolveQuestlineID: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - const questlineID = req.params.questlineID; - - const questline = await GetQuestlineById(game, playtype, questlineID); - - if (!questline) { - return res.status(404).json({ - success: false, - description: `A questline with ID ${questlineID} doesn't exist.`, - }); - } - - AssignToReqTachiData(req, { questlineDoc: questline }); - - next(); -}; - -/** - * Retrieve all questlines for this GPT. Also, return any standalone quests. - * - * @name GET /api/v1/games/:game/:playtype/targets/questlines - */ -router.get("/", async (req, res) => { - const { game, playtype } = GetGPT(req); - - const questlines = await GetQuestlinesForGamePlaytype(game, playtype); - - const standalone = await FindStandaloneQuests(game, playtype); - const standaloneGoals = await GetGoalsInQuests(standalone); - - return res.status(200).json({ - success: true, - description: `Returned ${questlines.length} questlines.`, - body: { questlines, standalone, standaloneGoals }, - }); -}); - -/** - * Retrieve a specific questline. - * - * @name GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID - */ -router.get("/:questlineID", ResolveQuestlineID, async (req, res) => { - const questline = GetTachiData(req, "questlineDoc"); - - const quests = await GetChildQuests(questline); - - const goals = await GetGoalsInQuests(quests); - - return res.status(200).json({ - success: true, - description: `Retrieved questline '${questline.name}'.`, - body: { - quests, - questline, - goals, - }, - }); -}); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.oldtest.ts deleted file mode 100644 index 58a13e2f4..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.oldtest.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { QuestDocument, QuestlineDocument, QuestSubscriptionDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - FakeOtherUser, - IIDXSPQuestGoals, - IIDXSPQuestGoalSubs, - TestingIIDXSPQuest, - TestingIIDXSPQuestSub, -} from "#test-utils/test-data"; -import dm from "deepmerge"; -import t from "tap"; - -t.test("GET /api/v1/games/:game/:playtype/targets/quests", (t) => { - t.beforeEach(ResetDBState); - - function mkQuest(merge: any) { - return dm(TestingIIDXSPQuest, merge) as QuestDocument; - } - - t.test("Should search quests.", async (t) => { - await MONGODB_KILL.quests.insert([ - mkQuest({ name: "Testing Set", questID: "name" }), - mkQuest({ name: "Testing Other Set", questID: "similar_name" }), - mkQuest({ name: "Different Name", questID: "radically_different_name" }), - mkQuest({ - game: "chunithm", - playtype: "Single", - questID: "matching name but different gpt", - }), - mkQuest({ playtype: "DP", questID: "matching name but different playtype" }), - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/quests?search=Testing"); - - t.equal(res.statusCode, 200); - - t.hasStrict( - (res.body.body.quests as Array).sort((a, b) => - a.name.localeCompare(b.name), - ), - [{ questID: "similar_name" }, { questID: "name" }], - ); - - t.end(); - }); - - t.end(); -}); - -// this is my lazy sample data for these tests. -const LoadLazySampleData = async () => { - await MONGODB_KILL.users.insert(FakeOtherUser); - await MONGODB_KILL.goals.insert(IIDXSPQuestGoals); - await MONGODB_KILL.quests.insert([ - TestingIIDXSPQuest, - dm(TestingIIDXSPQuest, { questID: "other_quest" }) as QuestDocument, - ]); - await MONGODB_KILL["quest-subs"].insert([ - TestingIIDXSPQuestSub, - dm(TestingIIDXSPQuestSub, { questID: "other_quest" }), - dm(TestingIIDXSPQuestSub, { - userID: 2, - }), - ] as Array); -}; - -t.test("GET /api/v1/games/:game/:playtype/targets/quests/:questID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await Promise.all([ - MONGODB_KILL["goal-subs"].insert(IIDXSPQuestGoalSubs), - MONGODB_KILL.questlines.insert({ - questlineID: "set_id", - quests: [TestingIIDXSPQuest.questID], - } as QuestlineDocument), - ]); - }); - - t.beforeEach(LoadLazySampleData); - - t.test("Should return the quest and its goals.", async (t) => { - const res = await mockApi.get( - `/api/v1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`, - ); - - t.hasStrict(res.body.body, { - quest: { questID: TestingIIDXSPQuest.questID }, - questSubs: [{ userID: 1, questID: TestingIIDXSPQuest.questID }], - users: [{ id: 1 }, { id: 2 }], - goals: [ - { goalID: "eg_goal_1" }, - { goalID: "eg_goal_2" }, - { goalID: "eg_goal_3" }, - { goalID: "eg_goal_4" }, - ], - parentQuestlines: [{ questlineID: "set_id" }], - }); - - t.end(); - }); - - t.test("Should return 404 if the requested quest doesn't exist.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/quests/fake_quest"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Should return 404 if the quest exists but for a different GPT.", async (t) => { - const res = await mockApi.get( - `/api/v1/games/iidx/DP/targets/quests/${TestingIIDXSPQuest.questID}`, - ); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts deleted file mode 100644 index a511936e6..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; -import { SELECT_QUESTLINE_ROW } from "#lib/db-formats/questline"; -import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents"; -import { GetGoalsInQuest, GetGoalsInQuests } from "#lib/targets/quests"; -import DB from "#services/pg/db"; -import { EscapeForILIKE } from "#utils/misc"; -import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data"; -import { GetUsersWithIDs } from "#utils/user"; -import { type RequestHandler, Router } from "express"; -import { GamePTToV3, V3ToGamePT } from "tachi-common"; - -const router: Router = Router({ mergeParams: true }); - -const ResolveQuestID: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - const questID = req.params.questID; - - const v3Game = GamePTToV3(game, playtype); - - const row = await DB.selectFrom("quest") - .select(SELECT_QUEST) - .where("quest.id", "=", questID) - .where("quest.game", "=", v3Game) - .executeTakeFirst(); - - if (!row) { - return res.status(404).json({ - success: false, - description: `A quest with ID ${questID} doesn't exist.`, - }); - } - - AssignToReqTachiData(req, { questDoc: ToQuestDocument(row) }); - - next(); -}; - -/** - * Search quests for this GPT. - * - * @param search - The query to search for. - * - * @name GET /api/v1/games/:game/:playtype/targets/quests - */ -router.get("/", async (req, res) => { - const { game, playtype } = GetGPT(req); - - if (typeof req.query.search !== "string") { - return res.status(400).json({ - success: false, - description: `Invalid value for search.`, - }); - } - - const v3Game = GamePTToV3(game, playtype); - const likeEsc = EscapeForILIKE(req.query.search.trim()); - const pattern = `%${likeEsc}%`; - - const rows = await DB.selectFrom("quest") - .select(SELECT_QUEST) - .where("quest.game", "=", v3Game) - .where((eb) => - eb.or([eb("quest.name", "ilike", pattern), eb("quest.description", "ilike", pattern)]), - ) - .limit(50) - .execute(); - - const quests = rows.map(ToQuestDocument); - const goals = await GetGoalsInQuests(quests); - - return res.status(200).json({ - success: true, - description: `Returned ${quests.length} quests.`, - body: { quests, goals }, - }); -}); - -/** - * Retrieve information about this quest and who is subscribed to it. - * - * @name GET /api/v1/games/:game/:playtype/targets/quests/:questID - */ -router.get("/:questID", ResolveQuestID, async (req, res) => { - const quest = GetTachiData(req, "questDoc"); - - const questSubRows = await DB.selectFrom("quest_sub") - .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) - .where("quest_sub.quest_id", "=", quest.questID) - .execute(); - - const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); - - const users = await GetUsersWithIDs(questSubs.map((e) => e.userID)); - - const goals = await GetGoalsInQuest(quest); - - const qlRows = await DB.selectFrom("questline") - .innerJoin("questline_quest", "questline_quest.questline_id", "questline.id") - .select(SELECT_QUESTLINE_ROW) - .where("questline_quest.quest_id", "=", quest.questID) - .execute(); - - const parentQuestlines = qlRows.map((ql) => { - const { game: gg, playtype: pt } = V3ToGamePT(ql.game); - - return { - questlineID: ql.id, - name: ql.name, - desc: ql.description, - game: gg, - playtype: pt, - }; - }); - - return res.status(200).json({ - success: true, - description: `Retrieved information about ${quest.name}.`, - body: { - quest, - questSubs, - users, - goals, - parentQuestlines, - }, - }); -}); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/router.oldtest.ts deleted file mode 100644 index 4afc80139..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/router.oldtest.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { GoalSubscriptionDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { mkFakeGoal, mkFakeGoalSub } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { HC511UserGoal } from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/games/:game/:playtype/targets/recently-achieved", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return some recently achieved goals.", async (t) => { - await MONGODB_KILL.goals.insert([ - mkFakeGoal({ goalID: "achieved" }), - mkFakeGoal({ goalID: "achieved_more_recently" }), - mkFakeGoal({ goalID: "achieved_instantly" }), - ]); - - await MONGODB_KILL["goal-subs"].insert([ - // not achieved - HC511UserGoal, - mkFakeGoalSub({ goalID: "achieved", achieved: true, timeAchieved: 1000 }), - mkFakeGoalSub({ goalID: "achieved_more_recently", achieved: true, timeAchieved: 2000 }), - mkFakeGoalSub({ - goalID: "achieved_instantly", - achieved: true, - timeAchieved: 1000, - wasInstantlyAchieved: true, - }), - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/recently-achieved"); - - t.equal(res.statusCode, 200); - - // Shouldn't have the unachieved goal, shouldn't have the instantly achieved goal. - // should also have them in the right order. - t.strictSame( - res.body.body.goalSubs.map((e: GoalSubscriptionDocument) => e.goalID), - ["achieved_more_recently", "achieved"], - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/targets/recently-raised", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return some recently interacted goals.", async (t) => { - await MONGODB_KILL.goals.insert([ - mkFakeGoal({ goalID: "interacted" }), - mkFakeGoal({ goalID: "interacted_more_recently" }), - mkFakeGoal({ goalID: "achieved" }), - mkFakeGoal({ goalID: "achieved_instantly" }), - ]); - - await MONGODB_KILL["goal-subs"].insert([ - // not achieved - HC511UserGoal, - mkFakeGoalSub({ goalID: "interacted", achieved: false, lastInteraction: 1000 }), - - // happened more recently - mkFakeGoalSub({ - goalID: "interacted_more_recently", - achieved: false, - lastInteraction: 2000, - }), - - // shouldnt be included -- just recently-raised. - mkFakeGoalSub({ - goalID: "achieved", - achieved: true, - lastInteraction: 1000, - }), - mkFakeGoalSub({ - goalID: "achieved_instantly", - achieved: true, - lastInteraction: 1000, - wasInstantlyAchieved: true, - }), - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/recently-raised"); - - t.equal(res.statusCode, 200); - - // Shouldn't have the uninteracted goal, shouldn't have the instantly achieved goal. - // should also have them in the right order. - t.strictSame( - res.body.body.goalSubs.map((e: GoalSubscriptionDocument) => e.goalID), - ["interacted_more_recently", "interacted"], - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts deleted file mode 100644 index 3fd4f95cf..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - GetRecentlyAchievedGoals, - GetRecentlyAchievedQuests, - GetRecentlyInteractedGoals, - GetRecentlyInteractedQuests, -} from "#utils/db"; -import { GetGPT } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { FormatGameGroup } from "tachi-common"; - -import goalsRouter from "./goals/router"; -import questlineRouter from "./questlines/router"; -import questsRouter from "./quests/router"; - -const router: Router = Router({ mergeParams: true }); - -/** - * Retrieve all of this game's recently achieved goals and quests. - * - * @name GET /api/v1/games/:game/:playtype/targets/recently-achieved - */ -router.get("/recently-achieved", async (req, res) => { - const { game, playtype } = GetGPT(req); - - const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ - GetRecentlyAchievedGoals({ game, playtype }), - GetRecentlyAchievedQuests({ game, playtype }), - ]); - - return res.status(200).json({ - success: true, - description: `Retrieved some recently achieved targets for ${FormatGameGroup(game, playtype)}`, - body: { - goals, - goalSubs, - quests, - questSubs, - }, - }); -}); - -/** - * Retrieve all of this game's recently interacted-with goals and quests. - * - * @name GET /api/v1/games/:game/:playtype/targets/recently-raised - */ -router.get("/recently-raised", async (req, res) => { - const { game, playtype } = GetGPT(req); - - const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ - GetRecentlyInteractedGoals({ game, playtype }), - GetRecentlyInteractedQuests({ game, playtype }), - ]); - - return res.status(200).json({ - success: true, - description: `Retrieved some recently interacted-with targets for ${FormatGameGroup( - game, - playtype, - )}`, - body: { - goals, - goalSubs, - quests, - questSubs, - }, - }); -}); - -router.use("/goals", goalsRouter); -router.use("/quests", questsRouter); -router.use("/questlines", questlineRouter); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_game/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/_game/router.oldtest.ts deleted file mode 100644 index e7fce157f..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/router.oldtest.ts +++ /dev/null @@ -1,23 +0,0 @@ -import mockApi from "#test-utils/mock-api"; -import { GetGameGroupConfig } from "tachi-common"; -import t from "tap"; - -t.test("GET /api/v1/games/:game", (t) => { - t.test("Should parse the game from the header", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx"); - - t.hasStrict(GetGameGroupConfig("iidx"), res.body.body); - - t.end(); - }); - - t.test("Should reject an unsupported game.", async (t) => { - const res = await mockApi.get("/api/v1/games/invalid_game"); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/router.ts b/typescript/server/src/server/router/api/v1/games/_game/router.ts deleted file mode 100644 index aa9f10ca8..000000000 --- a/typescript/server/src/server/router/api/v1/games/_game/router.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { GetGameGroupConfig } from "tachi-common"; - -import playtypeRouter from "./_playtype/router"; -import { ValidateGameFromParam } from "./middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(ValidateGameFromParam); - -/** - * Returns the configuration for this game. - * - * @name GET /api/v1/games/:game - */ -router.get("/", (req, res) => { - const game = GetTachiData(req, "game"); - - return res.status(200).json({ - success: true, - description: `Returned information for ${game}`, - body: GetGameGroupConfig(game), - }); -}); - -router.use("/:playtype", playtypeRouter); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/middleware.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/middleware.ts new file mode 100644 index 000000000..62619da39 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/middleware.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from "express"; + +import { GetChartByIdForGame } from "#lib/db-formats/chart"; +import { REQ_AssignToReqTachiData, REQ_GetGame } from "#utils/req-tachi-data"; + +export const ValidateAndGetChart: RequestHandler = async (req, res, next) => { + const game = REQ_GetGame(req); + + const chart = await GetChartByIdForGame(game, req.params.chartID); + + if (!chart) { + return res.status(404).json({ + success: false, + description: `The chart ${req.params.chartID} does not exist.`, + }); + } + + REQ_AssignToReqTachiData(req, { chartDoc: chart }); + + next(); +}; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/router.test.ts similarity index 87% rename from typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.test.ts rename to typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/router.test.ts index b1486fd6e..a68d17e86 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/router.test.ts @@ -42,7 +42,7 @@ async function seedChart() { .execute(); } -describe("GET /api/v1/games/iidx/SP/charts/:chartID", () => { +describe("GET /api/v1/games/iidx-sp/charts/:chartID", () => { beforeEach(async () => { await DB.deleteFrom("chart").where("id", "=", CHART_PG_ID).execute(); await DB.deleteFrom("song").where("id", "=", SONG_PG_ID).execute(); @@ -51,16 +51,16 @@ describe("GET /api/v1/games/iidx/SP/charts/:chartID", () => { }); it("returns 200 when chartID is the Postgres chart id", async () => { - const res = await mockApi.get(`/api/v1/games/iidx/SP/charts/${CHART_PG_ID}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/charts/${CHART_PG_ID}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.body.chart.chartID).toBe(CHART_PG_ID); - expect(res.body.body.song.id).toBe(SONG_LEGACY_ID); + expect(res.body.body.song.id).toBe(SONG_PG_ID); }); it("returns 404 when the chart does not exist", async () => { - const res = await mockApi.get("/api/v1/games/iidx/SP/charts/nonexistent-chart-id"); + const res = await mockApi.get("/api/v1/games/iidx-sp/charts/nonexistent-chart-id"); expect(res.status).toBe(404); expect(res.body.success).toBe(false); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/_chartID/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.test.ts new file mode 100644 index 000000000..080800919 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.test.ts @@ -0,0 +1,95 @@ +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function seed511Chart() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: [], + alt_titles: [], + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: true, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +describe("POST /api/v1/games/:game/charts/resolve", () => { + beforeEach(seed511Chart); + + it("resolves a chart using tachiSongID matchType", async () => { + const res = await mockApi.post("/api/v1/games/iidx-sp/charts/resolve").send({ + matchType: "tachiSongID", + identifier: Testing511Song.id, + difficulty: "ANOTHER", + }); + + expect(res.status).toBe(200); + expect(res.body.body.chart.chartID).toBe(Testing511SPA.chartID); + expect(res.body.body.song.title).toBe("5.1.1."); + }); + + it("resolves a chart using songTitle matchType", async () => { + const res = await mockApi.post("/api/v1/games/iidx-sp/charts/resolve").send({ + matchType: "songTitle", + identifier: "5.1.1.", + difficulty: "ANOTHER", + }); + + expect(res.status).toBe(200); + expect(res.body.body.chart.chartID).toBe(Testing511SPA.chartID); + }); + + it("returns 404 when the chart cannot be resolved", async () => { + const res = await mockApi.post("/api/v1/games/iidx-sp/charts/resolve").send({ + matchType: "tachiSongID", + identifier: "99999", + difficulty: "ANOTHER", + }); + + expect(res.status).toBe(404); + }); + + it("returns 400 for an invalid matchType", async () => { + const res = await mockApi.post("/api/v1/games/iidx-sp/charts/resolve").send({ + matchType: "invalidMatchType", + identifier: "1", + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 when required fields are missing", async () => { + const res = await mockApi.post("/api/v1/games/iidx-sp/charts/resolve").send({ + matchType: "tachiSongID", + }); + + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/v1/games/:game/charts (popular & search)", () => { + it.todo("requires large IIDX chart/PB corpus — port from router.oldtest.ts"); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/middleware.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/middleware.ts new file mode 100644 index 000000000..6c1eff2ab --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/middleware.ts @@ -0,0 +1,23 @@ +import type { RequestHandler } from "express"; + +import { LoadFolderDocumentByGameAndSlug } from "#lib/db-formats/folders"; +import { LEGACY_REQ_GetGPT, REQ_AssignToReqTachiData } from "#utils/req-tachi-data"; +import { LEGACY_GameGroupPTToGame } from "tachi-common"; + +export const GetFolderFromParam: RequestHandler = async (req, res, next) => { + const { gameGroup: game, playtype } = LEGACY_REQ_GetGPT(req); + const v3Game = LEGACY_GameGroupPTToGame(game, playtype); + + const folder = await LoadFolderDocumentByGameAndSlug(v3Game, req.params.folderSlug); + + if (!folder || folder.game !== v3Game) { + return res.status(404).json({ + success: false, + description: `This folder does not exist.`, + }); + } + + REQ_AssignToReqTachiData(req, { folderDoc: folder }); + + next(); +}; diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/router.test.ts new file mode 100644 index 000000000..51e820d3a --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/router.test.ts @@ -0,0 +1,135 @@ +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +describe("GET /api/v1/games/:game/folders", () => { + beforeEach(async () => { + const id = "folder-foo"; + + await DB.insertInto("folder") + .values({ + id, + legacy_id: "foo", + game: "iidx-sp", + inactive: false, + title: "12", + slug: id, + where: "chart.level_num = 10", + version_filter: null, + search_terms: ["12"], + }) + .execute(); + + await DB.insertInto("folder") + .values({ + id: "folder-bar", + legacy_id: "bar", + game: "iidx-dp", + inactive: false, + title: "12", + slug: "folder-bar", + where: "chart.level_num = 10", + version_filter: null, + search_terms: ["12"], + }) + .execute(); + + await DB.insertInto("folder") + .values({ + id: "folder-baz", + legacy_id: "baz", + game: "bms-7k", + inactive: false, + title: "12", + slug: "folder-baz", + where: "chart.level_num = 10", + version_filter: null, + search_terms: ["12"], + }) + .execute(); + }); + + it("returns folders matching the search query for this GPT", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/folders?search=12"); + + expect(res.status).toBe(200); + expect(res.body.body.length).toBe(1); + expect(res.body.body[0].folderID).toBe("folder-foo"); + }); + + it("returns 400 when search is missing", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/folders"); + + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/v1/games/:game/folders/:folderSlug", () => { + beforeEach(async () => { + const songId = "fsong1"; + const chartId = "fchart1"; + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: 1, + game_group: "iidx", + title: "T", + artist: "A", + search_terms: [], + alt_titles: [], + data: {}, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: songId, + difficulty: "ANOTHER", + level: "10", + level_num: 10, + is_primary: true, + versions: [], + data: {}, + }) + .execute(); + + await DB.insertInto("folder") + .values({ + id: "folder-foo2", + legacy_id: "foo", + game: "iidx-sp", + inactive: false, + title: "12", + slug: "foo", + where: `chart.id = '${chartId}'`, + version_filter: null, + search_terms: [], + }) + .execute(); + + await DB.insertInto("folder_chart_lookup") + .values({ folder_id: "folder-foo2", chart_id: chartId }) + .execute(); + }); + + it("returns the folder and matching charts/songs", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/folders/foo"); + + expect(res.status).toBe(200); + expect(res.body.body.folder.folderID).toBe("folder-foo2"); + expect(res.body.body.charts.length).toBeGreaterThanOrEqual(1); + }); + + it("returns 404 when the folder does not exist", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/folders/bar"); + + expect(res.status).toBe(404); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/folders/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/middleware.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/middleware.ts new file mode 100644 index 000000000..29318a330 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/middleware.ts @@ -0,0 +1,79 @@ +import type { MiddlewareFn } from "#lib/router/typed-router"; +import type { RequestHandler } from "express"; + +import { REQ_AssignToReqTachiData, REQ_GetTachiData } from "#utils/req-tachi-data"; +import { ExpectedErr } from "bliss"; +import { + type GameGroup, + GetGameGroupConfig, + LEGACY_GameGroupPTToGame, + type LEGACY_Playtype, +} from "tachi-common"; + +export const ValidatePlaytypeFromParam: RequestHandler = (req, res, next) => { + const gameGroup = REQ_GetTachiData(req, "gameGroup"); + + const gameConfig = GetGameGroupConfig(gameGroup); + + if (!gameConfig.playtypes.includes(req.params.playtype as LEGACY_Playtype)) { + return res.status(400).json({ + success: false, + description: `The playtype ${req.params.playtype} is not supported.`, + }); + } + + const v3Game = LEGACY_GameGroupPTToGame(gameGroup, req.params.playtype as LEGACY_Playtype); + + REQ_AssignToReqTachiData(req, { + playtype: req.params.playtype as LEGACY_Playtype, + game: v3Game, + }); + + next(); +}; + +/** + * TypedRouter middleware: validates `:playtype` for a fixed game group and assigns + * `playtype` plus the v3 `game` onto request tachi data. + */ +export const withPlaytypeParamFor = + (game: GameGroup): MiddlewareFn => + async (req) => { + const gameConfig = GetGameGroupConfig(game); + const playtype = req.params.playtype as LEGACY_Playtype | undefined; + + if (!playtype || !gameConfig.playtypes.includes(playtype)) { + throw new ExpectedErr(400, `The playtype ${playtype} is not supported.`); + } + + const v3Game = LEGACY_GameGroupPTToGame(game, playtype); + + REQ_AssignToReqTachiData(req, { + playtype, + game: v3Game, + }); + + return {}; + }; + +export const ValidatePlaytypeFromParamFor = + (game: GameGroup): RequestHandler => + (req, res, next) => { + const gameConfig = GetGameGroupConfig(game); + + if (!gameConfig.playtypes.includes(req.params.playtype as LEGACY_Playtype)) { + return res.status(400).json({ + success: false, + description: `The playtype ${req.params.playtype} is not supported.`, + }); + } + + const v3Game = LEGACY_GameGroupPTToGame(game, req.params.playtype as LEGACY_Playtype); + + REQ_AssignToReqTachiData(req, { + playtype: req.params.playtype as LEGACY_Playtype, + game: v3Game, + }); + + next(); + }; diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.test.ts new file mode 100644 index 000000000..2bf46620d --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.test.ts @@ -0,0 +1,210 @@ +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function seedMinimalIidxSpCorpus() { + const chartId = Testing511SPA.chartID; + + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); + + const now = new Date().toISOString(); + + await DB.insertInto("game_profile") + .values({ + user_id: 1, + game: "iidx-sp", + ratings: JSON.stringify({ BPI: 1 }), + classes: JSON.stringify({}), + }) + .execute(); + + await DB.insertInto("score") + .values({ + id: "seed_score_iidx", + user_id: 1, + chart_id: chartId, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify({}), + derived_data: JSON.stringify({}), + judgements: JSON.stringify({}), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: now, + time_added: now, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("GET /api/v1/games/:game", () => { + beforeEach(async () => { + await seedUser({ username: "gpt_info_user" }); + await seedMinimalIidxSpCorpus(); + }); + + it.todo( + "GET /api/v1/games/:v3Game stats (chart/player/score): blocked — Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group", + ); + + it("returns 400 for an unsupported game slug", async () => { + const res = await mockApi.get("/api/v1/games/__not_a_real_game_slug__"); + + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/v1/games/:game/leaderboard", () => { + beforeEach(async () => { + await seedUser({ username: "lb_u1" }); + await seedMinimalIidxSpCorpus(); + + await seedUser({ username: "lb_u2" }); + await seedUser({ username: "lb_u3" }); + + await DB.insertInto("game_profile") + .values([ + { + user_id: 2, + game: "iidx-sp", + ratings: JSON.stringify({ BPI: 100 }), + classes: JSON.stringify({}), + }, + { + user_id: 3, + game: "iidx-sp", + ratings: JSON.stringify({ BPI: 50 }), + classes: JSON.stringify({}), + }, + ]) + .execute(); + }); + + it("returns the leaderboard for this game", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/leaderboard"); + + expect(res.status).toBe(200); + expect(res.body.body.gameStats.length).toBe(3); + expect(res.body.body.users.length).toBe(3); + + expect(res.body.body.gameStats).toEqual( + expect.arrayContaining([expect.objectContaining({ userID: 1, game: "iidx-sp" })]), + ); + expect(res.body.body.users).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 1 })]), + ); + }); + + it("returns 400 for an unknown profile algorithm", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/leaderboard?alg=naiveRating"); + + expect(res.status).toBe(400); + }); + + it("sorts by the requested algorithm when provided", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/leaderboard?alg=BPI"); + + expect(res.status).toBe(200); + + expect(res.body.body.gameStats.map((e: { userID: number }) => e.userID)).toEqual([2, 3, 1]); + }); +}); + +describe("GET /api/v1/games/:game/players", () => { + beforeEach(async () => { + await seedUser({ username: "players_sink" }); + await seedUser({ username: "scrimblo" }); + await seedUser({ username: "scrimblo_2" }); + await seedUser({ username: "scrimblo_3" }); + await seedUser({ username: "cloudy" }); + + await DB.insertInto("game_profile") + .values([ + { + user_id: 2, + game: "iidx-sp", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + }, + { + user_id: 3, + game: "iidx-dp", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + }, + { + user_id: 4, + game: "bms-7k", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + }, + { + user_id: 5, + game: "iidx-sp", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + }, + ]) + .execute(); + }); + + it("finds users that match the search string for this GPT", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/players?search=scrimblo"); + + expect(res.status).toBe(200); + expect(res.body.body).toEqual([expect.objectContaining({ id: 2 })]); + }); + + it("returns an empty list when nothing matches", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/players?search=nobody"); + + expect(res.status).toBe(200); + expect(res.body.body).toEqual([]); + }); + + it("scopes results to the requested GPT", async () => { + const res = await mockApi.get("/api/v1/games/iidx-dp/players?search=scrimblo"); + + expect(res.status).toBe(200); + expect(res.body.body).toEqual([expect.objectContaining({ id: 3 })]); + }); + + it("returns 400 when search is missing", async () => { + const res = await mockApi.get("/api/v1/games/iidx-dp/players"); + + expect(res.status).toBe(400); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/middleware.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/middleware.ts new file mode 100644 index 000000000..f999260ce --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/middleware.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from "express"; + +import { GetSongByID } from "#lib/db-formats/song"; +import { REQ_AssignToReqTachiData, REQ_GetTachiData } from "#utils/req-tachi-data"; + +export const ValidateAndGetSong: RequestHandler = async (req, res, next) => { + const gameGroup = REQ_GetTachiData(req, "gameGroup"); + + const result = await GetSongByID(gameGroup, req.params.songID); + + if (!result) { + return res.status(404).json({ + success: false, + description: `No song exists with the songID ${req.params.songID}.`, + }); + } + + REQ_AssignToReqTachiData(req, { songDoc: result.doc, songNewID: result.newSongID }); + + next(); +}; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.test.ts similarity index 78% rename from typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.test.ts rename to typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.test.ts index 394f42dbf..4fbe7f5e1 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.test.ts @@ -62,40 +62,40 @@ async function seedChart({ .execute(); } -// ─── GET /api/v1/games/iidx/SP/songs/:songID ───────────────────────────────── +// ─── GET /api/v1/games/iidx-sp/songs/:songID ───────────────────────────────── -describe("GET /api/v1/games/iidx/SP/songs/:songID", () => { +describe("GET /api/v1/games/iidx-sp/songs/:songID", () => { beforeEach(async () => { await seedSong(); await seedChart(); }); it("returns 200 with the song and its charts", async () => { - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/${SONG_LEGACY_ID}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/${SONG_PG_ID}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.body.song.title).toBe("5.1.1."); - expect(res.body.body.song.id).toBe(SONG_LEGACY_ID); + expect(res.body.body.song.id).toBe(SONG_PG_ID); expect(res.body.body.charts).toHaveLength(1); }); it("returns the correct chart fields", async () => { - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/${SONG_LEGACY_ID}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/${SONG_PG_ID}`); const [chart] = res.body.body.charts; expect(chart.chartID).toBe(CHART_PG_ID); - expect(chart.songID).toBe(SONG_LEGACY_ID); + expect(chart.song.id).toBe(SONG_PG_ID); expect(chart.difficulty).toBe("ANOTHER"); expect(chart.level).toBe("10"); expect(chart.levelNum).toBe(10); expect(chart.isPrimary).toBe(true); - expect(chart.playtype).toBe("SP"); + expect(chart.game).toBe("iidx-sp"); }); it("includes chart versions", async () => { - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/${SONG_LEGACY_ID}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/${SONG_PG_ID}`); const [chart] = res.body.body.charts; @@ -114,7 +114,7 @@ describe("GET /api/v1/games/iidx/SP/songs/:songID", () => { altTitles: ["Five One One"], }); - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/${OTHER_LEGACY_ID}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/${OTHER_PG_ID}`); expect(res.status).toBe(200); expect(res.body.body.song.searchTerms).toContain("511"); @@ -130,7 +130,7 @@ describe("GET /api/v1/games/iidx/SP/songs/:songID", () => { levelNum: 8, }); - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/${SONG_LEGACY_ID}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/${SONG_PG_ID}`); expect(res.status).toBe(200); expect(res.body.body.charts).toHaveLength(2); @@ -157,36 +157,36 @@ describe("GET /api/v1/games/iidx/SP/songs/:songID", () => { }) .execute(); - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/${SONG_LEGACY_ID}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/${SONG_PG_ID}`); expect(res.status).toBe(200); expect(res.body.body.charts).toHaveLength(1); - expect(res.body.body.charts[0].playtype).toBe("SP"); + expect(res.body.body.charts[0].game).toBe("iidx-sp"); }); it("returns 404 when the song does not exist", async () => { - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/99999`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/99999`); expect(res.status).toBe(404); expect(res.body.success).toBe(false); }); - it("returns 400 when songID is not a positive integer", async () => { - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/not-a-number`); + it("returns 404 when songID is not a known song id", async () => { + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/not-a-number`); - expect(res.status).toBe(400); + expect(res.status).toBe(404); expect(res.body.success).toBe(false); }); it("returns 400 for an invalid game", async () => { - const res = await mockApi.get(`/api/v1/games/notreal/SP/songs/${SONG_LEGACY_ID}`); + const res = await mockApi.get(`/api/v1/games/not-a-real-game/songs/${SONG_PG_ID}`); expect(res.status).toBe(400); expect(res.body.success).toBe(false); }); - it("returns 400 for an invalid playtype", async () => { - const res = await mockApi.get(`/api/v1/games/iidx/NOTREAL/songs/${SONG_LEGACY_ID}`); + it("returns 400 for another invalid game slug", async () => { + const res = await mockApi.get(`/api/v1/games/invalid-game-slug-zzz/songs/${SONG_PG_ID}`); expect(res.status).toBe(400); expect(res.body.success).toBe(false); @@ -199,7 +199,7 @@ describe("GET /api/v1/games/iidx/SP/songs/:songID", () => { await seedSong({ pgId: BARE_PG_ID, legacyId: BARE_LEGACY_ID, title: "Bare Song" }); - const res = await mockApi.get(`/api/v1/games/iidx/SP/songs/${BARE_LEGACY_ID}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/songs/${BARE_PG_ID}`); expect(res.status).toBe(200); expect(res.body.body.song.title).toBe("Bare Song"); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/middleware.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/middleware.ts new file mode 100644 index 000000000..85b8e545b --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/middleware.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from "express"; + +import { LoadTableDocumentByLegacyIdForGame } from "#lib/db-formats/table"; +import { REQ_AssignToReqTachiData, REQ_GetGame } from "#utils/req-tachi-data"; + +export const GetTableFromParam: RequestHandler = async (req, res, next) => { + const game = REQ_GetGame(req); + + const table = await LoadTableDocumentByLegacyIdForGame(req.params.tableID, game); + + if (!table) { + return res.status(404).json({ + success: false, + description: `This table does not exist.`, + }); + } + + REQ_AssignToReqTachiData(req, { tableDoc: table }); + + next(); +}; diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/router.test.ts new file mode 100644 index 000000000..d29a5b7e3 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/router.test.ts @@ -0,0 +1,110 @@ +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { TestingIIDXFolderSP10 } from "#test-utils/test-data"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +describe("GET /api/v1/games/:game/tables", () => { + beforeEach(async () => { + const folderId = "tfolder"; + const tableId = `tbl_${Date.now()}`; + + await DB.insertInto("folder") + .values({ + id: folderId, + legacy_id: "testing_folder", + game: "iidx-sp", + inactive: false, + title: TestingIIDXFolderSP10.title, + slug: folderId, + where: "chart.level_num = 10", + version_filter: null, + search_terms: [], + }) + .execute(); + + await DB.insertInto("table") + .values({ + id: tableId, + legacy_id: "mock_table", + game: "iidx-sp", + inactive: false, + title: "Mock Table", + default_value: false, + slug: "mock_table", + }) + .execute(); + + await DB.insertInto("table_folder") + .values({ + table_id: tableId, + folder_id: folderId, + ordering: 0, + }) + .execute(); + }); + + it("returns all tables for this game", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/tables"); + + expect(res.status).toBe(200); + expect(res.body.body.length).toBe(1); + expect(res.body.body[0].tableID).toBe("mock_table"); + }); +}); + +describe("GET /api/v1/games/:game/tables/:tableID", () => { + beforeEach(async () => { + const folderId = "tfolder2"; + const tableId = `tbl2_${Date.now()}`; + + await DB.insertInto("folder") + .values({ + id: folderId, + legacy_id: "testing_folder", + game: "iidx-sp", + inactive: false, + title: "t", + slug: folderId, + where: "chart.level_num = 10", + version_filter: null, + search_terms: [], + }) + .execute(); + + await DB.insertInto("table") + .values({ + id: tableId, + legacy_id: "mock_table", + game: "iidx-sp", + inactive: false, + title: "Mock Table", + default_value: false, + slug: "mock_table", + }) + .execute(); + + await DB.insertInto("table_folder") + .values({ + table_id: tableId, + folder_id: folderId, + ordering: 0, + }) + .execute(); + }); + + it("returns the table and its folder slots", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/tables/mock_table"); + + expect(res.status).toBe(200); + expect(res.body.body.table.tableID).toBe("mock_table"); + expect(res.body.body.folders.length).toBe(1); + }); + + it("returns 404 when the table does not exist", async () => { + const res = await mockApi.get("/api/v1/games/iidx-sp/tables/non_existent_table"); + + expect(res.status).toBe(404); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/tables/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts new file mode 100644 index 000000000..794ac27bb --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts @@ -0,0 +1,5 @@ +import { describe, it } from "vitest"; + +describe("Game-group targets goals", () => { + it.todo("port router.oldtest.ts"); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/questlines/router.test.ts similarity index 91% rename from typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts rename to typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/questlines/router.test.ts index 15bda78ec..89a2004e5 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/questlines/router.test.ts @@ -4,7 +4,7 @@ import { afterAll, describe, expect, it } from "vitest"; afterAll(() => CloseServerConnection()); -describe("GET /api/v1/games/:game/:playtype/targets/questlines", () => { +describe("GET /api/v1/games/:game/targets/questlines", () => { it("returns questlines and standalone quests for the GPT", async () => { const suffix = `${Date.now()}`; const qlId = `ql-rt-${suffix}`; @@ -47,7 +47,7 @@ describe("GET /api/v1/games/:game/:playtype/targets/questlines", () => { }) .execute(); - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines"); + const res = await mockApi.get("/api/v1/games/iidx-sp/targets/questlines"); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -73,7 +73,7 @@ describe("GET /api/v1/games/:game/:playtype/targets/questlines", () => { it("returns 404 when the questline is missing", async () => { const res = await mockApi.get( - "/api/v1/games/iidx/SP/targets/questlines/no_such_questline_zzz", + "/api/v1/games/iidx-sp/targets/questlines/no_such_questline_zzz", ); expect(res.status).toBe(404); @@ -121,7 +121,7 @@ describe("GET /api/v1/games/:game/:playtype/targets/questlines", () => { ]) .execute(); - const res = await mockApi.get(`/api/v1/games/iidx/SP/targets/questlines/${qlId}`); + const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/questlines/${qlId}`); expect(res.status).toBe(200); expect(res.body.body.questline.questlineID).toBe(qlId); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/questlines/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/questlines/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/questlines/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.test.ts new file mode 100644 index 000000000..198c3cb3f --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.test.ts @@ -0,0 +1,5 @@ +import { describe, it } from "vitest"; + +describe("Game-group targets quests", () => { + it.todo("port router.oldtest.ts"); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/router.test.ts new file mode 100644 index 000000000..10edbc5d9 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/router.test.ts @@ -0,0 +1,5 @@ +import { describe, it } from "vitest"; + +describe("Game-group targets (parent)", () => { + it.todo("port recently-achieved from router.oldtest.ts"); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/_game/middleware.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/middleware.ts similarity index 64% rename from typescript/server/src/server/router/api/v1/games/_game/middleware.ts rename to typescript/server/src/server/router/api/v1/games/_gameGroup/middleware.ts index bbb3745b6..353d4f56e 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/middleware.ts +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/middleware.ts @@ -1,8 +1,8 @@ import type { RequestHandler } from "express"; import { TachiConfig } from "#lib/setup/config"; -import { IsValidGame } from "#utils/misc"; -import { AssignToReqTachiData } from "#utils/req-tachi-data"; +import { IsEnabledGameGroup } from "#utils/misc"; +import { REQ_AssignToReqTachiData } from "#utils/req-tachi-data"; export const ValidateGameFromParam: RequestHandler = (req, res, next) => { const game = req.params.game; @@ -13,16 +13,16 @@ export const ValidateGameFromParam: RequestHandler = (req, res, next) => { ); } - if (!IsValidGame(game)) { + if (!IsEnabledGameGroup(game)) { return res.status(400).json({ success: false, description: `Invalid/unsupported game ${ req.params.game - } - Expected any of ${TachiConfig.GAMES.join(", ")}`, + } - Expected any of ${TachiConfig.GAME_GROUPS.join(", ")}`, }); } - AssignToReqTachiData(req, { game }); + REQ_AssignToReqTachiData(req, { gameGroup: game }); next(); }; diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/router.test.ts new file mode 100644 index 000000000..f1475fa63 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/router.test.ts @@ -0,0 +1,26 @@ +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { GetGameGroupConfig } from "tachi-common"; +import { afterAll, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +describe("GET /api/v1/games/:gameGroup", () => { + it("returns the game group config from the route param", async () => { + const res = await mockApi.get("/api/v1/games/iidx"); + + expect(res.status).toBe(200); + expect({ + ...res.body.body, + songData: null, + }).toMatchObject({ + ...GetGameGroupConfig("iidx"), + songData: null, + }); + }); + + it("returns 400 for an unsupported game group", async () => { + const res = await mockApi.get("/api/v1/games/invalid_game"); + + expect(res.status).toBe(400); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/router.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/router.ts new file mode 100644 index 000000000..1bab33249 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/router.ts @@ -0,0 +1 @@ +// Routes migrated to games/router.ts diff --git a/typescript/server/src/server/router/api/v1/games/router.oldtest.ts b/typescript/server/src/server/router/api/v1/games/router.oldtest.ts deleted file mode 100644 index 88a2e6cf9..000000000 --- a/typescript/server/src/server/router/api/v1/games/router.oldtest.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { TachiConfig } from "#lib/setup/config"; -import mockApi from "#test-utils/mock-api"; -import { GetGameGroupConfig } from "tachi-common"; -import t from "tap"; - -t.test("GET /api/v1/games", async (t) => { - // lets just run some basic tests that this contains all of our supported games - // and also returns configs properly. - const res = await mockApi.get("/api/v1/games"); - - t.strictSame(res.body.body.supportedGames, TachiConfig.GAMES); - - t.hasStrict( - { - ...res.body.body.configs.iidx, - // songData doesn't serialise nicely as it has functions on it. - songData: null, - }, - { - ...GetGameGroupConfig("iidx"), - songData: null, - }, - ); - t.equal(Object.keys(res.body.body.configs).length, TachiConfig.GAMES.length); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/games/router.test.ts b/typescript/server/src/server/router/api/v1/games/router.test.ts new file mode 100644 index 000000000..6e32c951a --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/router.test.ts @@ -0,0 +1,24 @@ +import { TachiConfig } from "#lib/setup/config"; +import mockApi from "#test-utils/mock-api"; +import { GetGameGroupConfig } from "tachi-common"; +import { describe, expect, it } from "vitest"; + +describe("GET /api/v1/games", () => { + it("returns supported game groups and configs for each", async () => { + const res = await mockApi.get("/api/v1/games"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body.supportedGames).toEqual(TachiConfig.GAME_GROUPS); + + expect({ + ...res.body.body.configs.iidx, + songData: null, + }).toMatchObject({ + ...GetGameGroupConfig("iidx"), + songData: null, + }); + + expect(Object.keys(res.body.body.configs).length).toBe(TachiConfig.GAME_GROUPS.length); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/games/router.ts b/typescript/server/src/server/router/api/v1/games/router.ts index b1b234901..e1be273d1 100644 --- a/typescript/server/src/server/router/api/v1/games/router.ts +++ b/typescript/server/src/server/router/api/v1/games/router.ts @@ -1,36 +1,858 @@ +import { GetRecentActivityForMultipleGames } from "#lib/activity/activity"; +import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; +import { ONE_HOUR } from "#lib/constants/time"; +import { LoadFolderDocumentByGameAndSlug, LoadFolderDocumentsByIds } from "#lib/db-formats/folders"; +import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles"; +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; +import { + CountPbsOnChart, + LoadPbDocumentsForGameSortedByCalculatedAlg, + LoadPbsOnChartByRankAsc, + LoadPbsOnChartForUserSearch, +} from "#lib/db-formats/pb"; +import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; +import { SELECT_QUESTLINE_ROW } from "#lib/db-formats/questline"; +import { GetSongByID, GetSongsByIDs } from "#lib/db-formats/song"; +import { + GetTableDocumentsForGame, + LoadTableDocumentByLegacyIdForGame, +} from "#lib/db-formats/table"; +import { + AttachFolderSlugsToGoals, + ToGoalDocument, + ToGoalSubscriptionDocument, + ToQuestDocument, + ToQuestSubscriptionDocument, +} from "#lib/db-formats/target-documents"; +import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings"; +import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; +import { + GetFolderChartsAndSongs, + GetFolderIDsForChartId, + GetFoldersFromTable, +} from "#lib/folders/folders"; +import { log } from "#lib/log/log"; +import { withChart, withGame, withGameGroup } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { InvalidScoreFailure } from "#lib/score-import/framework/common/converter-failures"; +import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter"; +import { SearchFoldersForGameFtsAndTrgm } from "#lib/search/folders"; +import { SearchSpecificGameSongs } from "#lib/search/songs"; import { TachiConfig } from "#lib/setup/config"; -import { Router } from "express"; -import { GetGameGroupConfig } from "tachi-common"; +import { CreateGoalTitle, ValidateGoalChartsAndCriteria } from "#lib/targets/goal-utils"; +import { GetQuestsThatContainGoal } from "#lib/targets/goals"; +import { FindStandaloneQuests, GetGoalsInQuest, GetGoalsInQuests } from "#lib/targets/quests"; +import DB from "#services/pg/db"; +import { + GetChildQuests, + GetMostSubscribedGoals, + GetRecentlyAchievedGoals, + GetRecentlyAchievedQuests, + GetRecentlyInteractedGoals, + GetRecentlyInteractedQuests, + GetRelevantSongsAndCharts, +} from "#utils/db"; +import { EscapeForILIKE, IsString } from "#utils/misc"; +import { FindChartsOnPopularity } from "#utils/queries/charts"; +import { GetQuestlineById, GetQuestlinesForGame } from "#utils/queries/questlines"; +import { + CheckStrProfileAlg, + CheckStrScoreAlg, + ParseStrPositiveNonZeroInt, +} from "#utils/string-checks"; +import { GetUsersWithIDs } from "#utils/user"; +import { ExpectedErr } from "bliss"; +import { sql } from "kysely"; +import NodeCache from "node-cache"; +import { + type ChartDocument, + type FolderDocument, + type GamesForGroup, + GameToGameGroup, + GetGameConfig, + GetGameGroupConfig, + type GoalDocument, + type integer, + LEGACY_FormatGameGroupPT, + LEGACY_GameToGameGroupPT, + LEGACY_GetGamePTConfig, + type UGPTSettingsDocument, + type V3Game, +} from "tachi-common"; -import gameSpecificRoutes from "./@gameSpecificRoutes/router"; -import gameRouter from "./_game/router"; +import { API_V1_ROUTER } from "../router"; -const router: Router = Router({ mergeParams: true }); +const gptStatCache = new NodeCache(); + +async function GetGameStats( + game: V3Game, +): Promise<{ chartCount: integer; playerCount: integer; scoreCount: integer }> { + const cacheKey = game; + const cacheRes = gptStatCache.get(cacheKey); + + if (cacheRes !== undefined) { + return cacheRes as { chartCount: integer; playerCount: integer; scoreCount: integer }; + } + + const [scoreCount, playerCount, chartCount] = await Promise.all([ + DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("c")) + .where("score.game", "=", game) + .executeTakeFirst() + .then((r) => Number(r?.c ?? 0)), + DB.selectFrom("game_profile") + .select((eb) => eb.fn.countAll().as("c")) + .where("game_profile.game", "=", game) + .executeTakeFirst() + .then((r) => Number(r?.c ?? 0)), + DB.selectFrom("chart") + .select((eb) => eb.fn.countAll().as("c")) + .where("chart.game", "=", game) + .executeTakeFirst() + .then((r) => Number(r?.c ?? 0)), + ]); + + gptStatCache.set(cacheKey, { chartCount, playerCount, scoreCount }, ONE_HOUR); + + return { chartCount, playerCount, scoreCount }; +} + +/** Vitest: invalidate per-game stats cache after Postgres truncate (same process). */ +export function clearGameStatsCacheForTests(): void { + gptStatCache.flushAll(); +} /** * Declares the supported games for this version of tachi. - * Not sure if this endpoint has any purpose, to be honest. * * @name GET /api/v1/games */ -router.get("/", (req, res) => { - // this line is a bit too 'smart' for its own good, but whatever. - const configs = Object.fromEntries(TachiConfig.GAMES.map((e) => [e, GetGameGroupConfig(e)])); +API_V1_ROUTER.add("GET /games", () => { + const configs = Object.fromEntries( + TachiConfig.GAME_GROUPS.map((e) => [e, GetGameGroupConfig(e)]), + ); - return res.status(200).json({ - success: true, - description: `Returned support information for ${TachiConfig.GAMES.length} game(s).`, - body: { - supportedGames: TachiConfig.GAMES, - configs, - }, + return success(`Returned support information for ${TachiConfig.GAME_GROUPS.length} game(s).`, { + configs, + supportedGames: TachiConfig.GAME_GROUPS, }); }); -router.use("/:game", gameRouter); +/** + * Returns the configuration for this game. + * + * @name GET /api/v1/games/:gameGroup + */ +API_V1_ROUTER.add("GET /games/:gameGroup", withGameGroup, ({ ctx }) => + success(`Returned information for ${ctx.gameGroup}`, GetGameGroupConfig(ctx.gameGroup)), +); -// These routes are mounted at /api/v1/games and add things that are game specific, -// such as /bms/7K/tables/sieglindeEC. Simple enough. -router.use("/", gameSpecificRoutes); +/** + * Returns the configuration for this game along with some statistics. + * + * @name GET /api/v1/games/:game + */ +API_V1_ROUTER.add("GET /games/:game", withGame, async ({ ctx }) => { + const v3Game = ctx.game; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(v3Game); -export default router; + const { scoreCount, playerCount, chartCount } = await GetGameStats(v3Game); + + return success(`Retrieved information about ${LEGACY_FormatGameGroupPT(gameGroup, playtype)}`, { + chartCount, + config: LEGACY_GetGamePTConfig(gameGroup, playtype), + playerCount, + scoreCount, + }); +}); + +/** + * Returns user-game-stats for this game sorted by the default rating algorithm. + * + * @param alg - An alternative algorithm to use. + * @param limit - How many users to return at most (capped at 500). + * + * @name GET /api/v1/games/:game/leaderboard + */ +API_V1_ROUTER.add("GET /games/:game/leaderboard", withGame, async ({ input, ctx }) => { + const v3Game = ctx.game; + const gameConfig = GetGameConfig(v3Game); + + const limit = ParseStrPositiveNonZeroInt(String(input.limit ?? "")) ?? 100; + + if (limit > 500) { + throw new ExpectedErr(400, "Invalid limit. Limit is capped at 500."); + } + + let alg = gameConfig.defaultProfileRatingAlg; + + if (IsString(input.alg)) { + const temp = CheckStrProfileAlg(v3Game, input.alg); + + if (temp === null) { + throw new ExpectedErr( + 400, + `Invalid value of ${input.alg} for alg. Expected one of ${Object.keys(gameConfig.profileRatingAlgs).join(", ")}`, + ); + } + + alg = temp; + } + + const ratingCol = sql`coalesce((game_profile.ratings::jsonb->>${sql.lit(alg)})::numeric, 0)`; + + const gameStats = await DB.selectFrom("game_profile") + .select(SELECT_GAME_PROFILE) + .where("game_profile.game", "=", v3Game) + .orderBy(ratingCol, "desc") + .limit(limit) + .execute() + .then((rows) => rows.map(ToGameStatsDocument)); + + const users = await GetUsersWithIDs(gameStats.map((e) => e.userID)); + + return success(`Returned ${gameStats.length} user's game stats.`, { gameStats, users }); +}); + +/** + * Returns the best PBs for this game sorted by the default score rating algorithm. + * + * @param alg - An alternative algorithm to use. + * @param limit - How many scores to return (capped at 50). + * + * @name GET /api/v1/games/:game/pb-leaderboard + */ +API_V1_ROUTER.add("GET /games/:game/pb-leaderboard", withGame, async ({ input, ctx }) => { + const v3Game = ctx.game; + const gameConfig = GetGameConfig(v3Game); + + const limit = ParseStrPositiveNonZeroInt(String(input.limit ?? "")) ?? 50; + + if (limit > 50) { + throw new ExpectedErr(400, "Cannot specify a limit higher than 50."); + } + + let alg = gameConfig.defaultScoreRatingAlg; + + if (IsString(input.alg)) { + const temp = CheckStrScoreAlg(v3Game, input.alg); + + if (temp === null) { + throw new ExpectedErr( + 400, + `Invalid value of ${input.alg} for alg. Expected one of ${Object.keys(gameConfig.scoreRatingAlgs).join(", ")}`, + ); + } + + alg = temp; + } + + const pbs = await LoadPbDocumentsForGameSortedByCalculatedAlg(v3Game, alg, limit); + const users = await GetUsersWithIDs(pbs.map((e) => e.userID)); + const { songs, charts } = await GetRelevantSongsAndCharts(pbs); + + return success(`Successfully returned ${pbs.length} pbs.`, { charts, pbs, songs, users }); +}); + +/** + * Search users that have played this game. + * + * @param search - The username to search for. + * + * @name GET /api/v1/games/:game/players + */ +API_V1_ROUTER.add("GET /games/:game/players", withGame, async ({ input, ctx }) => { + const v3Game = ctx.game; + + const gptPlayers = await DB.selectFrom("account") + .innerJoin("game_profile", "game_profile.user_id", "account.id") + .select(SELECT_USER) + .where("game_profile.game", "=", v3Game) + .where("account.username", "ilike", `%${EscapeForILIKE(input.search)}%`) + .execute() + .then((res) => res.map(ToUserDocument)); + + return success(`Found ${gptPlayers.length} user(s)`, gptPlayers); +}); + +/** + * Retrieve activity for this GPT. + * + * @name GET /api/v1/games/:game/activity + */ +API_V1_ROUTER.add("GET /games/:game/activity", withGame, async ({ input, ctx }) => { + const data = await GetRecentActivityForMultipleGames( + [ctx.game], + input.sessions ?? 30, + input.startTime ?? null, + ); + + return success("Retrieved activity.", data); +}); + +/** + * Searches for charts on this game, or returns the 100 most popular if no search given. + * + * @param search - The song title to match on. + * @param noIntelligentOmit - If present, will not perform intelligent chart omissions. + * @param requesterHasPlayed - If present, only returns charts the requester has a PB on. + * + * @name GET /api/v1/games/:game/charts + */ +API_V1_ROUTER.add("GET /games/:game/charts", withGame, async ({ input, ctx, req }) => { + const v3Game = ctx.game; + const gameGroup = GameToGameGroup(v3Game); + + let songIDs: Array | undefined; + let chartIDs: Array | undefined; + + if (IsString(input.search) && input.search.trim() !== "") { + const songs = await SearchSpecificGameSongs(gameGroup, input.search, 100); + songIDs = songs.map((e) => e.id); + } + + if (input.requesterHasPlayed !== undefined) { + const userID = req[SYMBOL_TACHI_API_AUTH].userID; + + if (userID === null) { + throw new ExpectedErr( + 401, + "You must be authorised as a user to use the requesterHasPlayed option.", + ); + } + + const playedCharts = await DB.selectFrom("pb") + .innerJoin("chart", "chart.id", "pb.chart_id") + .select("chart.id as chart_id") + .distinct() + .where("pb.user_id", "=", userID) + .execute(); + + chartIDs = playedCharts.map((e) => e.chart_id); + } + + let charts = (await FindChartsOnPopularity( + v3Game, + { songIDs, chartIDs }, + 0, + 100, + )) as Array; + + const songs = await GetSongsByIDs(charts.map((e) => e.song.id)); + + // Edge case: for IIDX, filter out 2dxtra charts unless the user has opted in. + if (gameGroup === "iidx" && input.noIntelligentOmit === undefined) { + if (req[SYMBOL_TACHI_API_AUTH].userID === null) { + charts = charts.filter( + (e) => (e as ChartDocument).data["2dxtraSet"] === null, + ); + } else { + const iidxSettings = await GetUGPTSettingsDocument( + req[SYMBOL_TACHI_API_AUTH].userID, + v3Game, + ); + + const iidxGameSpecific = iidxSettings?.preferences.gameSpecific as UGPTSettingsDocument< + GamesForGroup["iidx"] + >["preferences"]["gameSpecific"]; + + if (!iidxGameSpecific?.display2DXTra) { + charts = charts.filter( + (e) => (e as ChartDocument).data["2dxtraSet"] === null, + ); + } + } + } + + return success(`Returned ${charts.length} charts.`, { charts, songs }); +}); + +/** + * Use the tachi resolve engine to identify a chart. + * + * @name POST /api/v1/games/:game/charts/resolve + */ +API_V1_ROUTER.add("POST /games/:game/charts/resolve", withGame, async ({ input, ctx }) => { + const v3Game = ctx.game; + + const safeBody = { + ...input, + game: v3Game, + } as Parameters[0]; + + let got: Awaited>; + try { + got = await ResolveSongAndChart(safeBody, log); + } catch (err) { + if (err instanceof InvalidScoreFailure) { + throw new ExpectedErr(400, err.message); + } + throw err; + } + + if (!got) { + throw new ExpectedErr( + 404, + `Could not resolve this chart with details: ${safeBody.matchType}:${safeBody.identifier}`, + ); + } + + return success("Successfully retrieved chart info.", { chart: got.chart, song: got.song }); +}); + +/** + * Returns the chart (and the parent song) at this chart ID. + * + * @name GET /api/v1/games/:game/charts/:chartID + */ +API_V1_ROUTER.add("GET /games/:game/charts/:chartID", withGame, withChart, async ({ ctx }) => { + const { chartDoc: chart } = ctx; + const gameGroup = GameToGameGroup(ctx.game); + + const songRes = await GetSongByID(gameGroup, chart.song.id); + + if (!songRes) { + log.error( + `Song ${chart.song.id} does not exist, yet chart ${chart.chartID} has it as a parent?`, + ); + throw new ExpectedErr(500, "An internal server error has occured."); + } + + return success(`Returned chart.`, { chart, song: songRes.doc }); +}); + +/** + * Returns any folders that contain this chart. + * + * @param inactive - Also include inactive folders. + * + * @name GET /api/v1/games/:game/charts/:chartID/folders + */ +API_V1_ROUTER.add( + "GET /games/:game/charts/:chartID/folders", + withGame, + withChart, + async ({ ctx, input }) => { + const { chartDoc: chart } = ctx; + + const folderIds = await GetFolderIDsForChartId(chart.chartID); + const byId = await LoadFolderDocumentsByIds(folderIds); + let folders = folderIds + .map((id) => byId.get(id)) + .filter((f): f is FolderDocument => f !== undefined); + + if (input.inactive === undefined) { + folders = folders.filter((f) => !f.inactive); + } + + return success(`Found ${folders.length} folders that contain this chart.`, folders); + }, +); + +/** + * Returns the total amount of unique players that have played this chart. + * + * @name GET /api/v1/games/:game/charts/:chartID/playcount + */ +API_V1_ROUTER.add( + "GET /games/:game/charts/:chartID/playcount", + withGame, + withChart, + async ({ ctx }) => { + const { chartDoc: chart } = ctx; + const count = await CountPbsOnChart(chart.chartID); + + return success("Counted scores for chart.", { count }); + }, +); + +/** + * Returns the personal bests for this chart sorted by ranking. + * + * @param startRanking - The ranking to start iterating from (defaults to 1). + * + * @name GET /api/v1/games/:game/charts/:chartID/pbs + */ +API_V1_ROUTER.add( + "GET /games/:game/charts/:chartID/pbs", + withGame, + withChart, + async ({ ctx, input }) => { + const { chartDoc: chart } = ctx; + const startRanking = ParseStrPositiveNonZeroInt(String(input.startRanking ?? "")) ?? 1; + + const pbs = await LoadPbsOnChartByRankAsc(chart.chartID, startRanking, 100); + const users = await GetUsersWithIDs(pbs.map((e) => e.userID)); + + return success(`Returned ${pbs.length} scores.`, { pbs, users }); + }, +); + +/** + * Searches the PBs on this chart by username. + * + * @param search - The user to search for. + * + * @name GET /api/v1/games/:game/charts/:chartID/pbs/search + */ +API_V1_ROUTER.add( + "GET /games/:game/charts/:chartID/pbs/search", + withGame, + withChart, + async ({ ctx, input }) => { + const { chartDoc: chart } = ctx; + + const pbs = await LoadPbsOnChartForUserSearch(chart.chartID, input.search); + const users = await GetUsersWithIDs(pbs.map((e) => e.userID)); + + return success(`Returned ${pbs.length} scores.`, { pbs, users }); + }, +); + +/** + * Returns the song at this ID and its child chart documents. + * + * @name GET /api/v1/games/:game/songs/:songID + */ +API_V1_ROUTER.add("GET /games/:game/songs/:songID", withGame, async ({ ctx, params }) => { + const v3Game = ctx.game; + const songID = params.songID; + const gameGroup = GameToGameGroup(v3Game); + + const songRes = await GetSongByID(gameGroup, songID); + + if (!songRes) { + throw new ExpectedErr(404, `No song with ID ${songID} exists.`); + } + + // TODO(zk) what the fuck? + const { GetChartsBySongId } = await import("#lib/db-formats/chart"); // avoids circular import + const charts = await GetChartsBySongId(v3Game, songRes.newSongID); + + return success(`Returned ${charts.length} charts for song ${songRes.doc.title}.`, { + charts, + song: songRes.doc, + }); +}); + +/** + * Search the folders for this GPT. + * + * @param search - The query to search for. + * @param inactive - Also show inactive folders. + * + * @name GET /api/v1/games/:game/folders + */ +API_V1_ROUTER.add("GET /games/:game/folders", withGame, async ({ ctx, input }) => { + const v3Game = ctx.game; + + const onlyActiveFolders = input.inactive === undefined; + + const folders = await SearchFoldersForGameFtsAndTrgm(v3Game, input.search, { + limit: 100, + onlyActiveFolders, + }); + + return success(`Returned ${folders.length} folders.`, folders); +}); + +/** + * Get the folder at this ID, alongside its charts and songs. + * + * @name GET /api/v1/games/:game/folders/:folderSlug + */ +API_V1_ROUTER.add("GET /games/:game/folders/:folderSlug", withGame, async ({ ctx, params }) => { + const v3Game = ctx.game; + + const folder = await LoadFolderDocumentByGameAndSlug(v3Game, params.folderSlug); + + if (!folder || folder.game !== v3Game) { + throw new ExpectedErr(404, `No folder with slug ${params.folderSlug} exists.`); + } + + const { songs, charts } = await GetFolderChartsAndSongs(folder); + + return success(`Returned data for folder ${folder.title}`, { charts, folder, songs }); +}); + +/** + * Return all the tables for this game. + * + * @param showInactive - If present, also show inactive tables. + * + * @name GET /api/v1/games/:game/tables + */ +API_V1_ROUTER.add("GET /games/:game/tables", withGame, async ({ ctx, input }) => { + const v3Game = ctx.game; + + const includeInactive = input.showInactive !== undefined; + const tables = await GetTableDocumentsForGame(v3Game, includeInactive); + + if (tables.length === 0) { + log.error(`The game ${v3Game} has no tables.`); + throw new ExpectedErr(500, "This game has no tables."); + } + + return success(`Returned ${tables.length} tables.`, tables); +}); + +/** + * Return the folder documents that make up this table. + * + * @name GET /api/v1/games/:game/tables/:tableID + */ +API_V1_ROUTER.add("GET /games/:game/tables/:tableID", withGame, async ({ ctx, params }) => { + const v3Game = ctx.game; + + const table = await LoadTableDocumentByLegacyIdForGame(params.tableID, v3Game); + + if (!table) { + throw new ExpectedErr(404, `No table with ID ${params.tableID} exists.`); + } + + const folders = await GetFoldersFromTable(table); + + return success(`Returned ${folders.length} for table ${table.title}.`, { folders, table }); +}); + +/** + * Retrieve all of this game's recently achieved goals and quests. + * + * @name GET /api/v1/games/:game/targets/recently-achieved + */ +API_V1_ROUTER.add("GET /games/:game/targets/recently-achieved", withGame, async ({ ctx }) => { + const { gameGroup: game, playtype } = LEGACY_GameToGameGroupPT(ctx.game); + + const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ + GetRecentlyAchievedGoals({ game, playtype }), + GetRecentlyAchievedQuests({ game, playtype }), + ]); + + return success( + `Retrieved some recently achieved targets for ${LEGACY_FormatGameGroupPT(game, playtype)}`, + { goalSubs, goals, questSubs, quests }, + ); +}); + +/** + * Retrieve all of this game's recently interacted-with goals and quests. + * + * @name GET /api/v1/games/:game/targets/recently-raised + */ +API_V1_ROUTER.add("GET /games/:game/targets/recently-raised", withGame, async ({ ctx }) => { + const { gameGroup: game, playtype } = LEGACY_GameToGameGroupPT(ctx.game); + + const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ + GetRecentlyInteractedGoals({ game, playtype }), + GetRecentlyInteractedQuests({ game, playtype }), + ]); + + return success( + `Retrieved some recently interacted-with targets for ${LEGACY_FormatGameGroupPT(game, playtype)}`, + { goalSubs, goals, questSubs, quests }, + ); +}); + +/** + * Get the most popular goals for this GPT. + * + * @name GET /api/v1/games/:game/targets/goals/popular + */ +API_V1_ROUTER.add("GET /games/:game/targets/goals/popular", withGame, async ({ ctx }) => { + const { gameGroup: _gameGroup, playtype } = LEGACY_GameToGameGroupPT(ctx.game); + + const goals = await GetMostSubscribedGoals({ game: ctx.game, playtype }); + + return success(`Returned ${goals.length} goals.`, goals); +}); + +/** + * Given a partial goal, return a formatted name for it. + * + * @name POST /api/v1/games/:game/targets/goals/format + */ +API_V1_ROUTER.add("POST /games/:game/targets/goals/format", withGame, async ({ ctx, input }) => { + const v3Game = ctx.game; + + const charts = input.charts as unknown as GoalDocument["charts"]; + const criteria = input.criteria as unknown as GoalDocument["criteria"]; + + try { + await ValidateGoalChartsAndCriteria(charts, criteria, v3Game); + } catch (e) { + const err = e as Error; + throw new ExpectedErr(400, `Invalid goal: ${err.message}.`); + } + + const title = await CreateGoalTitle(charts, criteria, v3Game); + + return success("Formatted goal.", title); +}); + +/** + * Retrieve information about this goal and who is subscribed to it. + * + * @name GET /api/v1/games/:game/targets/goals/:goalID + */ +API_V1_ROUTER.add("GET /games/:game/targets/goals/:goalID", withGame, async ({ ctx, params }) => { + const v3Game = ctx.game; + + const row = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "=", params.goalID) + .where("goal.game", "=", v3Game) + .executeTakeFirst(); + + if (!row) { + throw new ExpectedErr(404, `A goal with ID ${params.goalID} doesn't exist.`); + } + + const goal = ToGoalDocument(row); + await AttachFolderSlugsToGoals([goal]); + + const subRows = await DB.selectFrom("goal_sub") + .innerJoin("goal", "goal.id", "goal_sub.goal_id") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) + .where("goal_sub.goal_id", "=", goal.goalID) + .execute(); + + const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); + const users = await GetUsersWithIDs(goalSubs.map((e) => e.userID)); + const parentQuests = await GetQuestsThatContainGoal(goal.goalID); + + return success(`Retrieved information about ${goal.name}.`, { + goal, + goalSubs, + parentQuests, + users, + }); +}); + +/** + * Search quests for this GPT. + * + * @param search - The query to search for. + * + * @name GET /api/v1/games/:game/targets/quests + */ +API_V1_ROUTER.add("GET /games/:game/targets/quests", withGame, async ({ ctx, input }) => { + const v3Game = ctx.game; + + const likeEsc = EscapeForILIKE(input.search.trim()); + const pattern = `%${likeEsc}%`; + + const rows = await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.game", "=", v3Game) + .where((eb) => + eb.or([eb("quest.name", "ilike", pattern), eb("quest.description", "ilike", pattern)]), + ) + .limit(50) + .execute(); + + const quests = rows.map(ToQuestDocument); + const goals = await GetGoalsInQuests(quests); + + return success(`Returned ${quests.length} quests.`, { goals, quests }); +}); + +/** + * Retrieve information about this quest and who is subscribed to it. + * + * @name GET /api/v1/games/:game/targets/quests/:questID + */ +API_V1_ROUTER.add("GET /games/:game/targets/quests/:questID", withGame, async ({ ctx, params }) => { + const v3Game = ctx.game; + + const row = await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "=", params.questID) + .where("quest.game", "=", v3Game) + .executeTakeFirst(); + + if (!row) { + throw new ExpectedErr(404, `A quest with ID ${params.questID} doesn't exist.`); + } + + const quest = ToQuestDocument(row); + + const questSubRows = await DB.selectFrom("quest_sub") + .innerJoin("quest", "quest.id", "quest_sub.quest_id") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) + .where("quest_sub.quest_id", "=", quest.questID) + .execute(); + + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); + const users = await GetUsersWithIDs(questSubs.map((e) => e.userID)); + const goals = await GetGoalsInQuest(quest); + + const qlRows = await DB.selectFrom("questline") + .innerJoin("questline_quest", "questline_quest.questline_id", "questline.id") + .select(SELECT_QUESTLINE_ROW) + .where("questline_quest.quest_id", "=", quest.questID) + .execute(); + + const parentQuestlines = qlRows.map((ql) => { + const { gameGroup: gg, playtype: pt } = LEGACY_GameToGameGroupPT(ql.game); + + return { + desc: ql.description, + game: gg, + name: ql.name, + playtype: pt, + questlineID: ql.id, + }; + }); + + return success(`Retrieved information about ${quest.name}.`, { + goals, + parentQuestlines, + quest, + questSubs, + users, + }); +}); + +/** + * Retrieve all questlines for this GPT. Also returns any standalone quests. + * + * @name GET /api/v1/games/:game/targets/questlines + */ +API_V1_ROUTER.add("GET /games/:game/targets/questlines", withGame, async ({ ctx }) => { + const v3Game = ctx.game; + const { gameGroup: _gameGroup, playtype } = LEGACY_GameToGameGroupPT(v3Game); + + const questlines = await GetQuestlinesForGame(v3Game); + const standalone = await FindStandaloneQuests(_gameGroup, playtype); + const standaloneGoals = await GetGoalsInQuests(standalone); + + return success(`Returned ${questlines.length} questlines.`, { + questlines, + standalone, + standaloneGoals, + }); +}); + +/** + * Retrieve a specific questline. + * + * @name GET /api/v1/games/:game/targets/questlines/:questlineID + */ +API_V1_ROUTER.add( + "GET /games/:game/targets/questlines/:questlineID", + withGame, + async ({ ctx, params }) => { + const v3Game = ctx.game; + + const questline = await GetQuestlineById(v3Game, params.questlineID); + + if (!questline) { + throw new ExpectedErr(404, `A questline with ID ${params.questlineID} doesn't exist.`); + } + + const quests = await GetChildQuests(questline); + const goals = await GetGoalsInQuests(quests); + + return success(`Retrieved questline '${questline.name}'.`, { goals, questline, quests }); + }, +); diff --git a/typescript/server/src/server/router/api/v1/import/router.oldtest.ts b/typescript/server/src/server/router/api/v1/import/router.oldtest.ts deleted file mode 100644 index b9d41f4f7..000000000 --- a/typescript/server/src/server/router/api/v1/import/router.oldtest.ts +++ /dev/null @@ -1,393 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { RequireAuthPerms } from "#test-utils/api-common"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - GetKTDataBuffer, - LoadTachiIIDXData, - TestingIIDXEamusementCSV26, - TestingIIDXEamusementCSV27, -} from "#test-utils/test-data"; -import t from "tap"; - -t.test("POST /api/v1/import/file", async (t) => { - const cookie = await CreateFakeAuthCookie(mockApi); - - t.beforeEach(ResetDBState); - - RequireAuthPerms("/api/v1/import/file", "submit_score", "POST"); - - t.test("file/eamusement-iidx-csv", (t) => { - t.beforeEach(LoadTachiIIDXData); - - t.test("Mini HV import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach( - "scoreData", - GetKTDataBuffer("./eamusement-iidx-csv/small-hv-file.csv"), - "my_csv.csv", - ) - .field("importType", "file/eamusement-iidx-csv") - .field("playtype", "SP"); - - t.equal(res.body.success, true, "Should be successful."); - - t.equal(res.body.body.errors.length, 0, "Mini HV Import Should have 0 failed scores."); - - t.equal(res.body.body.scoreIDs.length, 2, "Should have 2 successful scores."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.test("Valid Rootage CSV import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach("scoreData", TestingIIDXEamusementCSV26, "my_csv.csv") - .field("importType", "file/eamusement-iidx-csv") - .field("playtype", "SP"); - - t.equal(res.body.success, true, "Should be successful."); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.test("Valid Heroic Verse CSV import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach("scoreData", TestingIIDXEamusementCSV27, "my_csv.csv") - .field("importType", "file/eamusement-iidx-csv") - .field("playtype", "SP"); - - t.equal(res.body.success, true, "Should be successful."); - - t.strictSame(res.body.body.errors, [], "Should have 0 failed scores."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.end(); - }); - - // thats right i literally just copied it - t.test("file/pli-iidx-csv", (t) => { - t.beforeEach(LoadTachiIIDXData); - - t.test("Mini HV import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach( - "scoreData", - GetKTDataBuffer("./eamusement-iidx-csv/small-hv-file.csv"), - "my_csv.csv", - ) - .field("importType", "file/pli-iidx-csv") - .field("playtype", "SP"); - - t.equal(res.body.success, true, "Should be successful."); - - t.equal(res.body.body.errors.length, 0, "Mini HV Import Should have 0 failed scores."); - - t.equal(res.body.body.scoreIDs.length, 2, "Should have 2 successful scores."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.test("Valid Rootage CSV import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach("scoreData", TestingIIDXEamusementCSV26, "my_csv.csv") - .field("importType", "file/pli-iidx-csv") - .field("playtype", "SP"); - - t.equal(res.body.success, true, "Should be successful."); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.test("Valid Heroic Verse CSV import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach("scoreData", TestingIIDXEamusementCSV27, "my_csv.csv") - .field("importType", "file/pli-iidx-csv") - .field("playtype", "SP"); - - t.equal(res.body.success, true, "Should be successful."); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.end(); - }); - - t.test("file/batch-manual", (t) => { - t.test("Empty import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach( - "scoreData", - GetKTDataBuffer("./batch-manual/empty-file.json"), - "empty-file.json", - ) - .field("importType", "file/batch-manual"); - - t.equal(res.body.success, true, "Should be successful."); - - t.equal(res.body.body.errors.length, 0, "Import Should have 0 failed scores."); - - t.equal(res.body.body.scoreIDs.length, 0, "Should have 0 successful scores."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.test("Invalid JSON", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach("scoreData", Buffer.from("{invalid JSON")) - .field("importType", "file/batch-manual"); - - t.equal(res.body.success, false, "Should not be successful."); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Single import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach( - "scoreData", - GetKTDataBuffer("./batch-manual/small-file.json"), - "small-file.json", - ) - .field("importType", "file/batch-manual"); - - t.equal(res.body.success, true, "Should be successful."); - - t.equal(res.body.body.errors.length, 0, "Import Should have 0 failed scores."); - - t.equal(res.body.body.scoreIDs.length, 1, "Should have 1 successful score."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.test("Single sdvxInGameID import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach( - "scoreData", - GetKTDataBuffer("./batch-manual/sdvx-in-game-id.json"), - "small-file.json", - ) - .field("importType", "file/batch-manual"); - - t.equal(res.body.success, true, "Should be successful."); - - t.equal(res.body.body.errors.length, 0, "Import Should have 0 failed scores."); - - t.equal(res.body.body.scoreIDs.length, 1, "Should have 1 successful score."); - - const scoreCount = await MONGODB_KILL.scores.find({ - scoreID: { $in: res.body.body.scoreIDs }, - }); - - t.equal( - scoreCount.length, - res.body.body.scoreIDs.length, - "All returned scoreIDs should be inserted to the DB.", - ); - - t.end(); - }); - - t.end(); - }); - - t.skip("file/solid-state-squad", (t) => { - t.beforeEach(LoadTachiIIDXData); - - t.test("Large Import", async (t) => { - const res = await mockApi - .post("/api/v1/import/file") - .set("Cookie", cookie) - .attach("scoreData", GetKTDataBuffer("./s3/large-example.xml"), "large.xml") - .field("importType", "file/solid-state-squad"); - - t.equal(res.body.success, true, "Should be successful"); - t.equal(res.body.body.scoreIDs.length, null, "Should parse N scores."); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/import/orphans", async (t) => { - const cookie = await CreateFakeAuthCookie(mockApi); - - t.beforeEach(ResetDBState); - - t.test("Should force a reprocessing of orphan scores.", async (t) => { - await MONGODB_KILL["orphan-scores"].insert([ - { - userID: 1, - timeInserted: 1000, - orphanID: "asdf", - importType: "ir/direct-manual", - errMsg: "foo", - context: { - game: "iidx", - playtype: "SP", - service: "foo", - version: null, - }, - data: { - score: 500, - lamp: "HARD CLEAR", - matchType: "songTitle", - identifier: "5.1.1.", - difficulty: "ANOTHER", - }, - game: "iidx", - }, - { - userID: 1, - timeInserted: 1000, - orphanID: "asdf2", - importType: "ir/direct-manual", - errMsg: "foo", - context: { - game: "iidx", - playtype: "SP", - service: "foo", - version: null, - }, - data: { - score: 500, - lamp: "HARD CLEAR", - matchType: "songTitle", - identifier: "TITLE NOBODY WILL USE", - difficulty: "ANOTHER", - }, - game: "iidx", - }, - ]); - - const res = await mockApi.post("/api/v1/import/orphans").set("Cookie", cookie); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.equal(res.body.body.success, 1, "Should successfully reprocess one orphan."); - t.equal(res.body.body.processed, 2, "Should reprocess two orphans."); - t.equal(res.body.body.failed, 1, "Should fail in de-orphaning one orphan."); - - const dbCount = await MONGODB_KILL["orphan-scores"].count({}); - - t.equal(dbCount, 1, "Should only leave one orphan-score in the database."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/import/router.test.ts b/typescript/server/src/server/router/api/v1/import/router.test.ts index 4ab711fe7..c607af8af 100644 --- a/typescript/server/src/server/router/api/v1/import/router.test.ts +++ b/typescript/server/src/server/router/api/v1/import/router.test.ts @@ -66,10 +66,9 @@ describe("POST /api/v1/import/orphans", () => { import_type: "ir/direct-manual", game_group: "iidx", context: { - game: "iidx", - playtype: "SP", - service: "foo", + game: "iidx-sp", version: "27", + service: "foo", }, data: { score: 500, @@ -91,10 +90,9 @@ describe("POST /api/v1/import/orphans", () => { import_type: "ir/direct-manual", game_group: "iidx", context: { - game: "iidx", - playtype: "SP", - service: "foo", + game: "iidx-sp", version: "27", + service: "foo", }, data: { score: 500, diff --git a/typescript/server/src/server/router/api/v1/import/router.ts b/typescript/server/src/server/router/api/v1/import/router.ts index ab4081c72..32ddac245 100644 --- a/typescript/server/src/server/router/api/v1/import/router.ts +++ b/typescript/server/src/server/router/api/v1/import/router.ts @@ -1,9 +1,10 @@ import type { ScoreImportJobData } from "#lib/score-import/worker/types"; -import type { APIImportTypes, FileUploadImportTypes } from "tachi-common"; +import type { FileUploadImportTypes } from "tachi-common"; import { SIXTEEN_MEGABTYES } from "#lib/constants/filesize"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; +import { success } from "#lib/router/typed-router"; import { ExpressWrappedScoreImportMain } from "#lib/score-import/framework/express-wrapper"; import { DeorphanScores } from "#lib/score-import/framework/orphans/orphans"; import { MakeScoreImport } from "#lib/score-import/framework/score-import"; @@ -14,26 +15,26 @@ import prValidate from "#server/middleware/prudence-validate"; import { ScoreImportRateLimiter } from "#server/middleware/rate-limiter"; import { Random20Hex } from "#utils/misc"; import { FormatUserDoc, GetUserWithIDGuaranteed } from "#utils/user"; -import { Router } from "express"; import { p } from "prudence"; -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; const ParseMultipartScoredata = CreateMulterSingleUploadMiddleware("scoreData", SIXTEEN_MEGABTYES); const fileImportTypes = TachiConfig.IMPORT_TYPES.filter((e) => e.startsWith("file/")); -const apiImportTypes = TachiConfig.IMPORT_TYPES.filter((e) => e.startsWith("api/")); /** - * Import scores from a file. Expects the post request to be multipart, and to provide a scoreData file. + * Import scores from a file. Expects the post request to be multipart, + * and to provide a scoreData file. * * @param importType - The import type for this file. * @param file - The actual file. Should be passed as multipart. * * @name POST /api/v1/import/file */ -router.post( - "/file", +API_V1_ROUTER.rawAdd( + "POST", + "/import/file", RequirePermissions("submit_score"), ScoreImportRateLimiter, ParseMultipartScoredata, @@ -95,61 +96,53 @@ router.post( /** * Import scores from another API. This typically will perform a full sync. + * * @name POST /api/v1/import/from-api */ -router.post( - "/from-api", - RequirePermissions("submit_score"), - prValidate( - { - importType: p.isIn(apiImportTypes), - }, - {}, - { allowExcessKeys: true }, - ), - async (req, res) => { - const importType = req.safeBody.importType as APIImportTypes; +API_V1_ROUTER.add("POST /import/from-api", async ({ input, req }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const importType = input.importType as any; + const importID = Random20Hex(); + const userID = req[SYMBOL_TACHI_API_AUTH].userID!; + const userIntent = req.header("X-User-Intent")?.toLowerCase() === "true"; - const importID = Random20Hex(); - - const userID = req[SYMBOL_TACHI_API_AUTH].userID!; - - const userIntent = req.header("X-User-Intent")?.toLowerCase() === "true"; - - if (ServerConfig.USE_EXTERNAL_SCORE_IMPORT_WORKER) { - const job: ScoreImportJobData = { - importID, - userID, - userIntent, - importType, - parserArguments: [userID], - }; - - // Fire the score import, but make no guarantees about its state. - void MakeScoreImport(job); - - return res.status(202).json({ - success: true, - description: - "Import loaded into queue. You can poll the provided URL for information on when its complete.", - body: { - url: `${ServerConfig.OUR_URL}/api/v1/imports/${importID}/poll-status`, - importID, - }, - }); - } - - // Fire the score import and wait for it to finish! - const importResponse = await ExpressWrappedScoreImportMain( + if (ServerConfig.USE_EXTERNAL_SCORE_IMPORT_WORKER) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void (MakeScoreImport as any)({ + importID, + importType, + parserArguments: [userID], userID, userIntent, - importType, - [userID], - ); + }); - return res.status(importResponse.statusCode).json(importResponse.body); - }, -); + return { + $status: 202, + body: { + importID, + url: `${ServerConfig.OUR_URL}/api/v1/imports/${importID}/poll-status`, + }, + description: + "Import loaded into queue. You can poll the provided URL for information on when its complete.", + success: true, + }; + } + + const importResponse = await ExpressWrappedScoreImportMain( + userID, + userIntent, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + importType as any, + [userID], + ); + + return { + $status: importResponse.statusCode, + body: importResponse.body, + description: "Import complete.", + success: true, + }; +}); /** * Force Tachi to reprocess your orphaned scores. This is automatically done @@ -157,30 +150,22 @@ router.post( * * @name POST /api/v1/import/orphans */ -router.post("/orphans", RequirePermissions("submit_score"), async (req, res) => { +API_V1_ROUTER.add("POST /import/orphans", async ({ req }) => { const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TACHI_API_AUTH].userID!); log.info(`User ${FormatUserDoc(userDoc)} forced an orphan sync.`); - const { processed, removed, failed, success } = await DeorphanScores( - { userID: userDoc.id }, - log, - ); + const { + processed, + removed, + failed, + success: orphanSuccess, + } = await DeorphanScores({ userID: userDoc.id }, log); - log.info(`Finished attempting deorphaning.`); - - log.info(`Success: ${success} | Failed ${failed} | Removed ${removed}.`); - - return res.status(200).json({ - success: true, - description: `Reprocessed ${processed} orphan scores.`, - body: { - processed, - failed, - success, - removed, - }, + return success(`Reprocessed ${processed} orphan scores.`, { + failed, + processed, + removed, + success: orphanSuccess, }); }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/imports/middleware.ts b/typescript/server/src/server/router/api/v1/imports/middleware.ts index 2c47d6be4..282ccd57a 100644 --- a/typescript/server/src/server/router/api/v1/imports/middleware.ts +++ b/typescript/server/src/server/router/api/v1/imports/middleware.ts @@ -3,7 +3,7 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { LoadImportDocumentById } from "#lib/db-formats/import-document"; import { log } from "#lib/log/log"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; +import { REQ_AssignToReqTachiData, REQ_GetTachiData } from "#utils/req-tachi-data"; import { IsRequesterAdmin } from "#utils/user"; export const GetImportFromParam: RequestHandler = async (req, res, next) => { @@ -16,13 +16,13 @@ export const GetImportFromParam: RequestHandler = async (req, res, next) => { }); } - AssignToReqTachiData(req, { importDoc }); + REQ_AssignToReqTachiData(req, { importDoc }); next(); }; export const RequireOwnershipOfImportOrAdmin: RequestHandler = async (req, res, next) => { - const importDoc = GetTachiData(req, "importDoc"); + const importDoc = REQ_GetTachiData(req, "importDoc"); const userID = req[SYMBOL_TACHI_API_AUTH].userID; if (userID === null) { diff --git a/typescript/server/src/server/router/api/v1/imports/router.oldtest.ts b/typescript/server/src/server/router/api/v1/imports/router.oldtest.ts deleted file mode 100644 index b5b52478d..000000000 --- a/typescript/server/src/server/router/api/v1/imports/router.oldtest.ts +++ /dev/null @@ -1,166 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import { mkFakeImport } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { FakeImport } from "#test-utils/test-data"; -import { UserAuthLevels } from "tachi-common"; -import t from "tap"; - -t.test("GET /api/v1/imports/:importID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.imports.insert(FakeImport)); - - t.test("Should return the import at this ID.", async (t) => { - const res = await mockApi.get(`/api/v1/imports/${FakeImport.importID}`); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.hasStrict( - res.body.body, - { - user: { - id: 1, - }, - scores: [ - { - scoreID: FakeImport.scoreIDs[0], - }, - ], - charts: [ - { - chartID: res.body.body.scores[0].chartID, - }, - ], - songs: [ - { - id: res.body.body.scores[0].songID, - }, - ], - import: { - importID: FakeImport.importID, - }, - }, - "Should return the import and some info about it.", - ); - - t.end(); - }); - - t.test("Should return 404 if the import doesn't exist.", async (t) => { - const res = await mockApi.get("/api/v1/imports/bad-import"); - - t.equal(res.statusCode, 404, "Should return 404."); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/imports/:importID/revert", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.imports.insert(FakeImport)); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should revert the import at this ID.", async (t) => { - const res = await mockApi - .post(`/api/v1/imports/${FakeImport.importID}/revert`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.strictSame(res.body.body, {}, "The response body should be empty."); - - t.resolveMatch( - MONGODB_KILL.scores.findOne({ scoreID: FakeImport.scoreIDs[0] }), - - // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020 - null, - "The scores that were part of this import should be deleted.", - ); - - t.end(); - }); - - t.test("Should return 404 if the import doesn't exist.", async (t) => { - const res = await mockApi.post(`/api/v1/imports/doesnt-exist/revert`).set("Cookie", cookie); - - t.equal(res.statusCode, 404, "Should return 404."); - - t.end(); - }); - - t.test("Should return 401 if the user isn't authed.", async (t) => { - const res = await mockApi.post(`/api/v1/imports/${FakeImport.importID}/revert`); - - t.equal(res.statusCode, 401, "Should return 401."); - - t.end(); - }); - - t.test("Should return 403 if the user is authed as someone else.", async (t) => { - await MONGODB_KILL.users.update( - { - id: 1, - }, - { - $set: { authLevel: UserAuthLevels.USER }, - }, - ); - - const someoneElsesImport = mkFakeImport({ userID: 2, importID: "someone_elses" }); - - await MONGODB_KILL.imports.insert(someoneElsesImport); - - const res = await mockApi - .post(`/api/v1/imports/${someoneElsesImport.importID}/revert`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 403, "Should return 403."); - - t.end(); - }); - - t.test( - "Should allow reverting another users import if the requester is an admin.", - async (t) => { - await MONGODB_KILL.users.update( - { - id: 1, - }, - { - $set: { authLevel: UserAuthLevels.ADMIN }, - }, - ); - - const someoneElsesImport = mkFakeImport({ userID: 2, importID: "someone_elses" }); - - await MONGODB_KILL.imports.insert(someoneElsesImport); - - const res = await mockApi - .post(`/api/v1/imports/${someoneElsesImport.importID}/revert`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.strictSame(res.body.body, {}, "The response body should be empty."); - - t.resolveMatch( - MONGODB_KILL.scores.findOne({ scoreID: FakeImport.scoreIDs[0] }), - - // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020 - null, - "The scores that were part of this import should be deleted.", - ); - - t.end(); - }, - ); - - t.end(); -}); - -t.todo("GET /api/v1/imports"); -t.todo("GET /api/v1/imports/failed"); diff --git a/typescript/server/src/server/router/api/v1/imports/router.test.ts b/typescript/server/src/server/router/api/v1/imports/router.test.ts new file mode 100644 index 000000000..24911463f --- /dev/null +++ b/typescript/server/src/server/router/api/v1/imports/router.test.ts @@ -0,0 +1,224 @@ +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { + FakeImport, + Testing511Song, + Testing511SPA, + TestingIIDXSPScore, +} from "#test-utils/test-data"; +import { type ScoreData } from "tachi-common"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +async function seedFakeImportFixture() { + await seedUser({ + username: "import_owner", + email: "import_owner@test.com", + withCredential: true, + withSettings: true, + }); + + const chartId = Testing511SPA.chartID; + const sd = TestingIIDXSPScore.scoreData as ScoreData<"iidx-sp">; + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", sd); + const now = new Date().toISOString(); + + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: [], + alt_titles: [], + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: true, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); + + await DB.insertInto("import") + .values({ + id: FakeImport.importID, + user_id: 1, + time_started: new Date(FakeImport.timeStarted).toISOString(), + time_finished: new Date(FakeImport.timeFinished).toISOString(), + game_group: "iidx", + import_type: "ir/direct-manual", + user_intent: FakeImport.userIntent, + service: "test", + }) + .execute(); + + await DB.insertInto("score") + .values({ + id: FakeImport.scoreIDs[0], + user_id: 1, + chart_id: chartId, + game: "iidx-sp", + session_id: null, + import_id: FakeImport.importID, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(TestingIIDXSPScore.calculatedData), + meta: JSON.stringify({}), + time_achieved: now, + time_added: now, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("GET /api/v1/imports/:importID", () => { + beforeEach(seedFakeImportFixture); + + it("returns the import and related entities", async () => { + const res = await mockApi.get(`/api/v1/imports/${FakeImport.importID}`); + + expect(res.status).toBe(200); + expect(res.body.body.import.importID).toBe(FakeImport.importID); + expect(res.body.body.user.id).toBe(1); + expect( + res.body.body.scores.some( + (s: { scoreID: string }) => s.scoreID === FakeImport.scoreIDs[0], + ), + ).toBe(true); + }); + + it("returns 404 when the import does not exist", async () => { + const res = await mockApi.get("/api/v1/imports/bad-import"); + + expect(res.status).toBe(404); + }); +}); + +describe("POST /api/v1/imports/:importID/revert", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedFakeImportFixture(); + cookie = await loginAs("import_owner"); + }); + + it("reverts an import and removes its scores", async () => { + const res = await mockApi + .post(`/api/v1/imports/${FakeImport.importID}/revert`) + .set("Cookie", cookie); + + expect(res.status).toBe(200); + + const score = await DB.selectFrom("score") + .select("id") + .where("id", "=", FakeImport.scoreIDs[0]) + .executeTakeFirst(); + + expect(score).toBeUndefined(); + }); + + it("returns 404 when the import does not exist", async () => { + const res = await mockApi.post(`/api/v1/imports/doesnt-exist/revert`).set("Cookie", cookie); + + expect(res.status).toBe(404); + }); + + it("returns 401 without auth", async () => { + const res = await mockApi.post(`/api/v1/imports/${FakeImport.importID}/revert`); + + expect(res.status).toBe(401); + }); + + it("returns 403 when reverting someone else's import", async () => { + await seedUser({ + username: "other_imp", + email: "other_imp@test.com", + withCredential: true, + withSettings: true, + }); + await DB.insertInto("import") + .values({ + id: "someone_elses", + user_id: 2, + time_started: new Date().toISOString(), + time_finished: new Date().toISOString(), + game_group: "iidx", + import_type: "ir/direct-manual", + user_intent: true, + service: "test", + }) + .execute(); + + const res = await mockApi + .post("/api/v1/imports/someone_elses/revert") + .set("Cookie", cookie); + + expect(res.status).toBe(403); + }); + + it("allows an admin to revert another user's import", async () => { + await seedUser({ + username: "adm_imp", + email: "adm_imp@test.com", + authLevel: "admin", + withCredential: true, + withSettings: true, + }); + await DB.insertInto("import") + .values({ + id: "someone_elses2", + user_id: 2, + time_started: new Date().toISOString(), + time_finished: new Date().toISOString(), + game_group: "iidx", + import_type: "ir/direct-manual", + user_intent: true, + service: "test", + }) + .execute(); + + const adminCookie = await loginAs("adm_imp"); + + const res = await mockApi + .post("/api/v1/imports/someone_elses2/revert") + .set("Cookie", adminCookie); + + expect(res.status).toBe(200); + }); +}); + +describe("GET /api/v1/imports (list)", () => { + it.todo("port list behaviour from router.oldtest todo"); +}); + +describe("GET /api/v1/imports/failed", () => { + it.todo("port failed-import list from router.oldtest todo"); +}); diff --git a/typescript/server/src/server/router/api/v1/imports/router.ts b/typescript/server/src/server/router/api/v1/imports/router.ts index 59ac857a2..48ce3aa27 100644 --- a/typescript/server/src/server/router/api/v1/imports/router.ts +++ b/typescript/server/src/server/router/api/v1/imports/router.ts @@ -1,7 +1,6 @@ import type { ScoreImportWorkerReturns } from "#lib/score-import/worker/types"; -import type { ImportTypes } from "tachi-common"; -import { ACTION_DeleteImport } from "#actions/delete-import.js"; +import { ACTION_DeleteImport } from "#actions/delete-import"; import { JOB_RETRY_COUNT, SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { GetImportTrackerByImportId, @@ -12,19 +11,17 @@ import { import { LoadSessionDocumentById } from "#lib/db-formats/session"; import { GetImportScores } from "#lib/imports/imports"; import { log } from "#lib/log/log"; +import { withImport } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import ScoreImportQueue, { ScoreImportQueueEvents } from "#lib/score-import/worker/queue"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; -import { RequirePermissions } from "#server/middleware/auth"; -import prValidate from "#server/middleware/prudence-validate"; import { GetRelevantSongsAndCharts } from "#utils/db"; -import { GetTachiData } from "#utils/req-tachi-data"; import { GetUsersWithIDs, GetUserWithID } from "#utils/user"; -import { Router } from "express"; -import { p } from "prudence"; +import { ExpectedErr } from "bliss"; -import { GetImportFromParam } from "./middleware"; +import { API_V1_ROUTER } from "../router"; -const router: Router = Router({ mergeParams: true }); +// ─── Admin-facing import list ───────────────────────────────────────────────── /** * Query imports. Returns the 500 most recently-finished imports. @@ -34,39 +31,19 @@ const router: Router = Router({ mergeParams: true }); * * @name GET /api/v1/imports */ -router.get( - "/", - prValidate({ - importType: p.optional(p.isIn(TachiConfig.IMPORT_TYPES)), - userIntent: p.optional(p.isIn("true", "false")), - }), - async (req, res) => { - const importType = req.query.importType as ImportTypes | undefined; +API_V1_ROUTER.add("GET /imports", async ({ input }) => { + const userIntent = input.userIntent === undefined ? undefined : input.userIntent === "true"; - // all query input ends up as strings, so we need convert it into an optional - // boolean - const userIntent = - req.query.userIntent === undefined ? undefined : req.query.userIntent === "true"; + const imports = await ListRecentImportDocuments({ + importType: input.importType as never, + limit: 500, + userIntent, + }); - const imports = await ListRecentImportDocuments({ - importType, - limit: 500, - userIntent, - }); + const users = await GetUsersWithIDs(imports.map((e) => e.userID)); - // mayaswell attach the users for better UI. - const users = await GetUsersWithIDs(imports.map((e) => e.userID)); - - return res.status(200).json({ - success: true, - description: `Found ${imports.length} imports.`, - body: { - imports, - users, - }, - }); - }, -); + return success(`Found ${imports.length} imports.`, { imports, users }); +}); /** * Query *failed* imports. Returns the 500 most recently-finished imports. @@ -80,51 +57,30 @@ router.get( * * @name GET /api/v1/imports/failed */ -router.get( - "/failed", - prValidate({ - importType: p.optional(p.isIn(TachiConfig.IMPORT_TYPES)), - userIntent: p.optional(p.isIn("true", "false")), - }), - async (req, res) => { - const importType = req.query.importType as ImportTypes | undefined; +API_V1_ROUTER.add("GET /imports/failed", async ({ input }) => { + const userIntent = input.userIntent === undefined ? undefined : input.userIntent === "true"; - // all query input ends up as strings, so we need convert it into an optional - // boolean - const userIntent = - req.query.userIntent === undefined ? undefined : req.query.userIntent === "true"; + const trackers = await ListFailedImportTrackers({ + importType: input.importType as never, + limit: 500, + userIntent, + }); - const trackers = await ListFailedImportTrackers({ - importType, - limit: 500, - userIntent, - }); + const users = await GetUsersWithIDs(trackers.map((e) => e.userID)); - // mayaswell attach the users for better UI. - const users = await GetUsersWithIDs(trackers.map((e) => e.userID)); - - return res.status(200).json({ - success: true, - description: `Found ${trackers.length} failed imports.`, - body: { - failedImports: trackers, - users, - }, - }); - }, -); + return success(`Found ${trackers.length} failed imports.`, { failedImports: trackers, users }); +}); /** * Retrieve an import with this ID. * * @name GET /api/v1/imports/:importID */ -router.get("/:importID", GetImportFromParam, async (req, res) => { - const importDoc = GetTachiData(req, "importDoc"); +API_V1_ROUTER.add("GET /imports/:importID", withImport, async ({ ctx }) => { + const { importDoc } = ctx; const scores = await GetImportScores(importDoc); - - const { songs, charts } = await GetRelevantSongsAndCharts(scores, importDoc.game); + const { songs, charts } = await GetRelevantSongsAndCharts(scores); const sessions = ( await Promise.all( @@ -135,24 +91,17 @@ router.get("/:importID", GetImportFromParam, async (req, res) => { const user = await GetUserWithID(importDoc.userID); if (!user) { - log.error(`User ${importDoc.userID} doesn't exist, yet has a session?`); - return res.status(500).json({ - success: false, - description: `An internal server error has occured.`, - }); + log.error(`User ${importDoc.userID} doesn't exist, yet has an import?`); + throw new ExpectedErr(500, "An internal server error has occurred."); } - return res.status(200).json({ - success: true, - description: `Returned info about this session.`, - body: { - scores, - songs, - charts, - sessions, - import: importDoc, - user, - }, + return success("Returned info about this import.", { + charts, + import: importDoc, + scores, + sessions, + songs, + user, }); }); @@ -167,36 +116,29 @@ router.get("/:importID", GetImportFromParam, async (req, res) => { * * @name POST /api/v1/imports/:importID/revert */ -router.post("/:importID/revert", RequirePermissions("delete_score"), async (req, res) => { +API_V1_ROUTER.add("POST /imports/:importID/revert", withImport, async ({ params, req }) => { const auth = req[SYMBOL_TACHI_API_AUTH]; if (auth.userID === null) { - return res.status(401).json({ - success: false, - description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, - }); + throw new ExpectedErr(401, "Authentication is required."); } const user = await GetUserWithID(auth.userID); if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, - }); + throw new ExpectedErr(401, "Authentication is required."); } - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + await ACTION_DeleteImport( + { acct: { id: user.id, username: user.username }, ip: req.ip }, + { id: params.importID }, + ); - await ACTION_DeleteImport(taker, { id: req.params.importID }); - - return res.status(200).json({ - success: true, - description: `Reverted import.`, - body: {}, - }); + return success("Reverted import.", {}); }); +// ─── Import poll-status ─────────────────────────────────────────────────────── + // Finding jobs is slightly harder than just doing a key lookup, because of retrying. async function FindImportJob(importID: string) { const possibleImportIDs = []; @@ -208,7 +150,6 @@ async function FindImportJob(importID: string) { try { // Note that instead of the cleaner await-inside-for here, we parallelise this // for performance. - // Just for scalings sake. const maybeJob = ( await Promise.all(possibleImportIDs.map((i) => ScoreImportQueue.getJob(i))) ).find((k) => k); @@ -228,71 +169,58 @@ async function FindImportJob(importID: string) { * If the import was never ongoing, return 404. * * If the import was finalised and was unsuccessful (i.e. threw a fatal error) - * return its error information in expressified form. + * return its error information. * - * @name GET /api/v1/import/:importID/poll-status + * @name GET /api/v1/imports/:importID/poll-status */ -router.get("/:importID/poll-status", async (req, res) => { +API_V1_ROUTER.add("GET /imports/:importID/poll-status", async ({ params }) => { if (!ServerConfig.USE_EXTERNAL_SCORE_IMPORT_WORKER) { - return res.status(501).json({ - success: false, - description: `${TachiConfig.NAME} does not use an external score import worker. Polling imports is not possible. This import may be ongoing, or it may have never occured.`, - }); + throw new ExpectedErr( + 501, + `${TachiConfig.NAME} does not use an external score import worker. Polling imports is not possible.`, + ); } - const importDoc = await LoadImportDocumentById(req.params.importID); + const importDoc = await LoadImportDocumentById(params.importID); if (importDoc) { - return res.status(200).json({ - success: true, - description: `Import was completed!`, - body: { - importStatus: "completed", - import: importDoc, - }, + return success("Import was completed!", { + import: importDoc, + importStatus: "completed", }); } - const job = await FindImportJob(req.params.importID); + const job = await FindImportJob(params.importID); if (!job) { - const tracker = await GetImportTrackerByImportId(req.params.importID); + const tracker = await GetImportTrackerByImportId(params.importID); if (!tracker) { - return res.status(404).json({ - success: false, - description: `There is no ongoing import here.`, - }); + throw new ExpectedErr(404, "There is no ongoing import here."); } - // the user has requested the status of the import before the job has even + // The user has requested the status of the import before the job has even // been sent to redis. This is rare, but prevents a race condition of saying // that an import is not ongoing when it is. - switch (tracker.type) { case "ONGOING": - return res.status(200).json({ - success: true, - description: `Import is ongoing.`, - body: { - importStatus: "ongoing", - progress: 0, - }, - }); + return success("Import is ongoing.", { importStatus: "ongoing", progress: 0 }); + case "FAILED": - return res.status(tracker.error.statusCode ?? 500).json({ - success: false, + return { + $status: tracker.error.statusCode ?? 500, + body: {}, description: tracker.error.message, - }); + success: true as const, + }; + default: - throw new Error( - // @ts-expect-error shouldn't happen - `Unknown tracker type ${tracker.type}, expected ONGOING or FAILED.`, - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + throw new Error(`Unknown tracker type ${(tracker as any).type}`); } } - let isFailed; + let isFailed: boolean; try { isFailed = await job.isFailed(); @@ -301,7 +229,7 @@ router.get("/:importID/poll-status", async (req, res) => { isFailed = true; } - let isCompleted; + let isCompleted: boolean; try { isCompleted = await job.isCompleted(); @@ -310,54 +238,38 @@ router.get("/:importID/poll-status", async (req, res) => { isCompleted = false; } - // job.isFailed() actually means a critical error has occured. - // As in, an unhandled exception was thrown. + // job.isFailed() means a critical error has occurred — an unhandled exception was thrown. if (isFailed) { log.error({ job }, "Internal Server Error with job?"); + throw new ExpectedErr(500, "An internal service error has occurred with this import."); + } - return res.status(500).json({ - success: false, - description: `An internal service error has occured with this import. This has been reported!`, - }); - } else if (isCompleted) { + if (isCompleted) { const content = (await job.waitUntilFinished( ScoreImportQueueEvents, )) as ScoreImportWorkerReturns; - // Since job.isFailed() is for whether a job had a fatal exception - // or not. We still want to check whether a job failed from say, - // nonsense user input. - // As such, if content.success == true, then the import was - // successful. - // Else, it was a "score import fatal error", which means the user - // screwed something up and we had to bail on the import. + // content.success == true means the import finished cleanly. + // Otherwise it was a ScoreImportFatalError (bad user input etc.). if (content.success) { - return res.status(200).json({ - success: true, - description: `Import was completed!`, - body: { - importStatus: "completed", - import: content.MONGO_ImportDocument, - }, + return success("Import was completed!", { + import: content.ImportDocument, + importStatus: "completed", }); } - return res.status(content.statusCode).json({ - success: false, + return { + $status: content.statusCode, + body: {}, description: content.description, - }); + success: true, + }; } const progress = job.progress; - return res.status(200).json({ - success: true, - description: `Import is ongoing.`, - body: { - importStatus: "ongoing", - progress: progress === 0 ? { description: "Starting up import." } : progress, - }, + return success("Import is ongoing.", { + importStatus: "ongoing", + progress: progress === 0 ? { description: "Starting up import." } : progress, }); }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/localdev/router.ts b/typescript/server/src/server/router/api/v1/localdev/router.ts index 141df2ef6..c9df142bf 100644 --- a/typescript/server/src/server/router/api/v1/localdev/router.ts +++ b/typescript/server/src/server/router/api/v1/localdev/router.ts @@ -1,59 +1,37 @@ -import { RequireLocalDevelopment } from "#server/middleware/type-require"; +import { withLocalDev } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import DB from "#services/pg/db"; import { GetFirstAdmin } from "#utils/user"; -import { Router } from "express"; +import { ExpectedErr } from "bliss"; -/** - * Local development helpers (also enabled in NODE_ENV=test). Not available in production. - */ -const router: Router = Router({ mergeParams: true }); - -router.use(RequireLocalDevelopment); +import { API_V1_ROUTER } from "../router"; /** * Reports whether the database has any rows in the `song` table (seed data). - * - * @name GET /api/v1/localdev/song-seed-status */ -router.get("/song-seed-status", async (_req, res) => { +API_V1_ROUTER.add("GET /localdev/song-seed-status", withLocalDev, async () => { const row = await DB.selectFrom("song") .select((eb) => eb.fn.countAll().as("count")) .executeTakeFirst(); const songCount = Number(row?.count ?? 0); - return res.status(200).json({ - success: true, - description: "Song seed status retrieved.", - body: { - missingSongSeeds: songCount === 0, - }, - }); + return success("Song seed status retrieved.", { missingSongSeeds: songCount === 0 }); }); /** * Username of the lowest-ID admin (same as {@link GetFirstAdmin}) and the dev default * password for the quick-login button on the login page. - * - * @name GET /api/v1/localdev/first-admin-login */ -router.get("/first-admin-login", async (_req, res) => { +API_V1_ROUTER.add("GET /localdev/first-admin-login", withLocalDev, async () => { try { const admin = await GetFirstAdmin(); - return res.status(200).json({ - success: true, - description: "First admin login hint retrieved.", - body: { - username: admin.username, - password: "password", - }, + + return success("First admin login hint retrieved.", { + password: "password", + username: admin.username, }); } catch { - return res.status(200).json({ - success: false, - description: "No admin account exists on this instance.", - }); + throw new ExpectedErr(404, "No admin account exists on this instance."); } }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/oauth/router.ts b/typescript/server/src/server/router/api/v1/oauth/router.ts index db110089d..6fcf83192 100644 --- a/typescript/server/src/server/router/api/v1/oauth/router.ts +++ b/typescript/server/src/server/router/api/v1/oauth/router.ts @@ -1,10 +1,9 @@ import { ACTION_CreateOAuth2AuthCode } from "#actions/create-oauth2-auth-code"; import { ANON_ACTION_OAuthTokenExchange } from "#anon-actions/oauth-token-exchange"; -import prValidate from "#server/middleware/prudence-validate"; -import { Router } from "express"; -import { p } from "prudence"; +import { success } from "#lib/router/typed-router"; +import { ExpectedErr } from "bliss"; -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; /** * Converts an auth code into a valid API key that is returned. @@ -21,66 +20,35 @@ const router: Router = Router({ mergeParams: true }); * * @name POST /api/v1/oauth/token */ -router.post( - "/token", - prValidate({ - client_id: "string", - client_secret: "string", - grant_type: p.is("authorization_code"), - redirect_uri: "string", - code: "string", - }), - async (req, res) => { - const body = req.safeBody as { - client_id: string; - client_secret: string; - code: string; - grant_type: "authorization_code"; - redirect_uri: string; - }; +API_V1_ROUTER.add("POST /oauth/token", async ({ input, req }) => { + const apiDoc = await ANON_ACTION_OAuthTokenExchange( + { ip: req.ip }, + { + client_id: input.client_id, + client_secret: input.client_secret, + code: input.code, + grant_type: input.grant_type, + redirect_uri: input.redirect_uri, + }, + ); - const apiDoc = await ANON_ACTION_OAuthTokenExchange( - { ip: req.ip }, - { - client_id: body.client_id, - client_secret: body.client_secret, - grant_type: body.grant_type, - redirect_uri: body.redirect_uri, - code: body.code, - }, - ); - - return res.status(200).json({ - success: true, - description: `Successfully authenticated.`, - body: apiDoc, - }); - }, -); + return success("Successfully authenticated.", apiDoc); +}); /** * Creates an authorization code for this user (inferred from session). * * @name POST /api/v1/oauth/create-code */ -router.post("/create-code", async (req, res) => { +API_V1_ROUTER.add("POST /oauth/create-code", async ({ req }) => { if (!req.session.tachi?.user) { - return res.status(401).json({ - success: false, - description: `You are not authenticated.`, - }); + throw new ExpectedErr(401, "You are not authenticated."); } const user = req.session.tachi.user; - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; const doc = await ACTION_CreateOAuth2AuthCode(taker, {}); - return res.status(200).json({ - success: true, - description: `Successfully created code.`, - body: doc, - }); + return success("Successfully created code.", doc); }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/router.oldtest.ts b/typescript/server/src/server/router/api/v1/router.oldtest.ts deleted file mode 100644 index f75b352a5..000000000 --- a/typescript/server/src/server/router/api/v1/router.oldtest.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter"; -import mockApi from "#test-utils/mock-api"; -import t from "tap"; - -// just a rudimentary test for rate-limiting. We fire 150 requests at GET /api/v1 -// (which does a server status check) -// and then check any of them return 429. -t.test("Rate Limiting Test", async (t) => { - ClearTestingRateLimitCache(); - - const promises = []; - - // default rate limit is 500, so lets go a bit over - for (let i = 0; i < 520; i++) { - promises.push(mockApi.get("/api/v1/status")); - } - - const res = await Promise.all(promises); - - const rateLimited = res.filter((e) => e.statusCode === 429); - - t.ok(rateLimited.length > 0, "Some requests should be rate limited."); - - t.end(); -}); - -t.test("404 Handler", async (t) => { - ClearTestingRateLimitCache(); - - const res = await mockApi.get("/api/v1/invalid_route_that_will_never_exist"); - - t.equal(res.statusCode, 404); - t.strictSame(res.body, { - success: false, - description: "Endpoint Not Found.", - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/router.test.ts b/typescript/server/src/server/router/api/v1/router.test.ts new file mode 100644 index 000000000..f57ebc6af --- /dev/null +++ b/typescript/server/src/server/router/api/v1/router.test.ts @@ -0,0 +1,35 @@ +import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter"; +import mockApi from "#test-utils/mock-api"; +import { describe, expect, it } from "vitest"; + +describe("/ir rate limiting", () => { + it("returns 429 after enough sequential requests (normal limit)", async () => { + ClearTestingRateLimitCache(); + + let rateLimited = 0; + for (let i = 0; i < 520; i++) { + // NormalRateLimitMiddleware is mounted on `/ir`, not `/api/v1`. + // Run sequentially so the in-memory counter updates deterministically. + const res = await mockApi.get("/ir/__rate_limit_probe__"); + if (res.status === 429) { + rateLimited++; + } + } + + expect(rateLimited).toBeGreaterThan(0); + }); +}); + +describe("404 handler", () => { + it("returns a stable JSON body for unknown routes", async () => { + ClearTestingRateLimitCache(); + + const res = await mockApi.get("/api/v1/invalid_route_that_will_never_exist"); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ + success: false, + description: "Endpoint Not Found.", + }); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/router.ts b/typescript/server/src/server/router/api/v1/router.ts index 9936151f7..99f2446cb 100644 --- a/typescript/server/src/server/router/api/v1/router.ts +++ b/typescript/server/src/server/router/api/v1/router.ts @@ -1,54 +1,72 @@ -import { NormalRateLimitMiddleware } from "#server/middleware/rate-limiter"; -import { Router } from "express"; +import { TypedRouter } from "#lib/router/typed-router"; -import activityRouter from "./activity/router"; -import adminRouter from "./admin/router"; -import authRouter from "./auth/router"; -import clientsRouter from "./clients/router"; -import configRouter from "./config/router"; -import gamesRouter from "./games/router"; -import importRouter from "./import/router"; -import importsRouter from "./imports/router"; -import localdevRouter from "./localdev/router"; -import oauthRouter from "./oauth/router"; -import scoresRouter from "./scores/router"; -import searchRouter from "./search/router"; -import seedsRouter from "./seeds/router"; -import sessionsRouter from "./sessions/router"; -import statusRouter from "./status/router"; -import usersRouter from "./users/router"; +import { API_V1_SPEC } from "./spec"; -const router: Router = Router({ mergeParams: true }); +export const API_V1_ROUTER = new TypedRouter(API_V1_SPEC); -// Auth is up here so it can have special rate limiting rules, -// since it needs slightly harsher ones! -router.use("/auth", authRouter); +// this sucks, but there's no "mod" tree in typescript, unlike rust. +await import("./status/router"); +await import("./auth/router"); +await import("./admin/router"); +await import("./import/router"); +await import("./imports/router"); +await import("./users/router"); +await import("./games/router"); +await import("./games/@gameSpecificRoutes/bms/router"); +await import("./games/@gameSpecificRoutes/iidx/router"); +await import("./search/router"); +await import("./sessions/router"); +await import("./sessions/_sessionID/router"); +await import("./oauth/router"); +await import("./clients/router"); +await import("./activity/router"); +await import("./config/router"); +await import("./localdev/router"); +await import("./seeds/router"); +await import("./scores/_scoreID/router"); -// Everything else can use the normal rate limiter! -router.use(NormalRateLimitMiddleware); +await import("./users/_userID/router"); +await import("./users/_userID/pfp/router"); +await import("./users/_userID/banner/router"); +await import("./users/_userID/api-tokens/router"); +await import("./users/_userID/invites/router"); +await import("./users/_userID/following/router"); +await import("./users/_userID/notifications/router"); +await import("./users/_userID/sessions/router"); +await import("./users/_userID/imports/router"); +await import("./users/_userID/settings/router"); +await import("./users/_userID/integrations/router"); +await import("./users/_userID/integrations/cg/_cgType/router"); +await import("./users/_userID/integrations/kai/_kaiType/router"); +await import("./users/_userID/integrations/fervidex/router"); +await import("./users/_userID/integrations/kshook-sv6c/router"); +await import("./users/_userID/integrations/myt/router"); -router.use("/admin", adminRouter); -router.use("/activity", activityRouter); -router.use("/status", statusRouter); -router.use("/import", importRouter); -router.use("/imports", importsRouter); -router.use("/users", usersRouter); -router.use("/games", gamesRouter); -router.use("/search", searchRouter); -router.use("/scores", scoresRouter); -router.use("/sessions", sessionsRouter); -router.use("/oauth", oauthRouter); -router.use("/clients", clientsRouter); -router.use("/config", configRouter); -router.use("/localdev", localdevRouter); -router.use("/seeds", seedsRouter); +await import("./users/_userID/games/@gameSpecificRoutes/bms/router"); +await import("./users/_userID/games/@gameSpecificRoutes/iidx/router"); +await import("./users/_userID/games/@gameSpecificRoutes/jubeat/router"); +await import("./users/_userID/games/_game/_playtype/pbs/router"); +await import("./users/_userID/games/_game/_playtype/scores/router"); +await import("./users/_userID/games/_game/_playtype/sessions/router"); +await import("./users/_userID/games/_game/_playtype/tables/router"); +await import("./users/_userID/games/_game/_playtype/showcase/router"); +await import("./users/_userID/games/_game/_playtype/settings/router"); +await import("./users/_userID/games/_game/_playtype/targets/router"); +await import("./users/_userID/games/_game/_playtype/targets/goals/router"); +await import("./users/_userID/games/_game/_playtype/targets/quests/router"); +await import("./users/_userID/games/_game/_playtype/folders/router"); +await import("./users/_userID/games/_game/_playtype/folders/_folderSlug/router"); +await import("./users/_userID/games/_game/_playtype/router"); +await import("./users/_userID/games/_game/_playtype/rivals/router"); + +const router = API_V1_ROUTER.build(); /** * Return a JSON 404 response if an endpoint is hit that does not exist. * * @name ALL /api/v1/* */ -router.all("*", (req, res) => +router.all("*", (_req, res) => res.status(404).json({ success: false, description: "Endpoint Not Found.", diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts index 6e339bcd2..4799a624b 100644 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts @@ -3,7 +3,7 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { LoadScoreDocumentById } from "#lib/db-formats/score"; import { log } from "#lib/log/log"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; +import { REQ_AssignToReqTachiData, REQ_GetTachiData } from "#utils/req-tachi-data"; import { IsRequesterAdmin } from "#utils/user"; export const GetScoreFromParam: RequestHandler = async (req, res, next) => { @@ -16,13 +16,13 @@ export const GetScoreFromParam: RequestHandler = async (req, res, next) => { }); } - AssignToReqTachiData(req, { scoreDoc: score }); + REQ_AssignToReqTachiData(req, { scoreDoc: score }); next(); }; export const RequireOwnershipOfScoreOrAdmin: RequestHandler = async (req, res, next) => { - const score = GetTachiData(req, "scoreDoc"); + const score = REQ_GetTachiData(req, "scoreDoc"); const userID = req[SYMBOL_TACHI_API_AUTH].userID; if (userID === null) { diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.oldtest.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.oldtest.ts deleted file mode 100644 index 2c0fce4f6..000000000 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.oldtest.ts +++ /dev/null @@ -1,292 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import { mkFakeScoreIIDXSP } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA } from "#test-utils/test-data"; -import { UserAuthLevels } from "tachi-common"; -import t from "tap"; - -t.test("GET /api/v1/scores/:scoreID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the score at that ID.", async (t) => { - const res = await mockApi.get("/api/v1/scores/TESTING_SCORE_ID"); - - t.equal(res.body.body.score.scoreID, "TESTING_SCORE_ID"); - - t.end(); - }); - - t.test("Should return the associated data if the param is set.", async (t) => { - const res = await mockApi.get("/api/v1/scores/TESTING_SCORE_ID?getRelated=true"); - - t.equal(res.body.body.score.scoreID, "TESTING_SCORE_ID"); - t.equal(res.body.body.user.id, 1); - t.equal(res.body.body.chart.chartID, Testing511SPA.chartID); - t.equal(res.body.body.song.id, 1); - - t.end(); - }); - - t.test("Should return 404 if the score does not exist.", async (t) => { - const res = await mockApi.get("/api/v1/scores/not_real"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("PATCH /api/v1/scores/:scoreID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should modify the session if the user has permission to.", async (t) => { - const res = await mockApi - .patch("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer fake_api_token") - .send({ - comment: "hello_world", - }); - - t.equal(res.body.body.comment, "hello_world"); - - const score = await MONGODB_KILL.scores.findOne({ scoreID: "TESTING_SCORE_ID" }); - - t.equal(score?.comment, "hello_world"); - - t.end(); - }); - - t.test("Should modify highlighted status.", async (t) => { - const res = await mockApi - .patch("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer fake_api_token") - .send({ - highlight: true, - }); - - t.equal(res.body.body.highlight, true); - - const score = await MONGODB_KILL.scores.findOne({ scoreID: "TESTING_SCORE_ID" }); - - t.equal(score?.highlight, true); - - t.end(); - }); - - t.test("Should set comment status to null.", async (t) => { - const res = await mockApi - .patch("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer fake_api_token") - .send({ - comment: null, - }); - - t.equal(res.body.body.comment, null); - - const score = await MONGODB_KILL.scores.findOne({ scoreID: "TESTING_SCORE_ID" }); - - t.equal(score?.comment, null); - - t.end(); - }); - - t.test("Should restrict comments to those between 1 and 120 characters.", async (t) => { - const res = await mockApi - .patch("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer fake_api_token") - .send({ - comment: "", - }); - - t.equal(res.statusCode, 400); - t.match(res.body.description, /\[comment\]/u); - - const res2 = await mockApi - .patch("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer fake_api_token") - .send({ - comment: "a".repeat(121), - }); - - t.equal(res2.statusCode, 400); - t.match(res2.body.description, /\[comment\]/u); - - t.end(); - }); - - t.test("Should reject empty bodies.", async (t) => { - const res = await mockApi - .patch("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer fake_api_token") - .send({}); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should require authorisation as this user.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - token: "some_dude", - userID: 2, - identifier: "Fake Token", - permissions: { - customise_score: true, - }, - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer some_dude") - .send({ - comment: "foo", - }); - - t.equal(res.statusCode, 403); - - t.match(res.body.description, /You are not authorised/u); - - t.end(); - }); - - t.test("Should require the customise_score permission", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - token: "some_token", - userID: 1, - identifier: "another fake token", - permissions: {}, - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer some_token") - .send({ - comment: "foo", - }); - - t.equal(res.statusCode, 403); - - t.match(res.body.description, /customise_score/u); - - t.end(); - }); - - t.end(); -}); - -t.test("DELETE /api/v1/scores/:scoreID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should delete a score if the requester can.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "foo", - permissions: { - delete_score: true, - }, - token: "foo", - fromAPIClient: null, - }); - - const res = await mockApi - .delete("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer foo"); - - t.equal(res.statusCode, 200); - - const dbScore = await MONGODB_KILL.scores.findOne({ scoreID: "TESTING_SCORE_ID" }); - - t.equal(dbScore, null, "Should remove the score from the database."); - - t.end(); - }); - - t.test("Should require authorisation as this user.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - token: "some_dude", - userID: 2, - identifier: "Fake Token", - permissions: { - delete_score: true, - }, - fromAPIClient: null, - }); - - const res = await mockApi - .delete("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer some_dude") - .send({ - comment: "foo", - }); - - t.equal(res.statusCode, 403); - - t.match(res.body.description, /You are not authorised/u); - - t.end(); - }); - - t.test("Should require the delete_score permission", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - token: "some_token", - userID: 1, - identifier: "another fake token", - permissions: {}, - fromAPIClient: null, - }); - - const res = await mockApi - .delete("/api/v1/scores/TESTING_SCORE_ID") - .set("Authorization", "Bearer some_token") - .send(); - - t.equal(res.statusCode, 403); - - t.match(res.body.description, /delete_score/u); - - t.end(); - }); - - t.test("Should allow admins to delete other's scores", async (t) => { - const cookie = await CreateFakeAuthCookie(mockApi); - - await MONGODB_KILL.users.update( - { - id: 1, - }, - { - $set: { authLevel: UserAuthLevels.ADMIN }, - }, - ); - - const someoneElsesScore = mkFakeScoreIIDXSP({ userID: 2, scoreID: "someone_elses" }); - - await MONGODB_KILL.scores.insert(someoneElsesScore); - - const res = await mockApi - .delete(`/api/v1/scores/${someoneElsesScore.scoreID}`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.strictSame(res.body.body, {}, "The response body should be empty."); - - t.resolveMatch( - MONGODB_KILL.scores.findOne({ scoreID: someoneElsesScore.scoreID }), - - // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020 - null, - "The score should be deleted.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.test.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.test.ts new file mode 100644 index 000000000..7afdbe7ce --- /dev/null +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.test.ts @@ -0,0 +1,311 @@ +import { seedApiToken } from "#actions/test-utils/api-tokens"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { Testing511Song, Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data"; +import { type ScoreData } from "tachi-common"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function seedTestingIidxScore() { + await seedUser({ username: "score_owner", withCredential: true, withSettings: true }); + + const chartId = Testing511SPA.chartID; + const sd = TestingIIDXSPScore.scoreData as ScoreData<"iidx-sp">; + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", sd); + const now = new Date().toISOString(); + + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); + + await DB.insertInto("score") + .values({ + id: TestingIIDXSPScore.scoreID, + user_id: 1, + chart_id: chartId, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify(TestingIIDXSPScore.calculatedData), + meta: JSON.stringify({}), + time_achieved: new Date(TestingIIDXSPScore.timeAchieved ?? Date.now()).toISOString(), + time_added: now, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("GET /api/v1/scores/:scoreID", () => { + beforeEach(seedTestingIidxScore); + + it("returns the score at that id", async () => { + const res = await mockApi.get("/api/v1/scores/TESTING_SCORE_ID"); + + expect(res.status).toBe(200); + expect(res.body.body.score.scoreID).toBe("TESTING_SCORE_ID"); + }); + + it("returns related user, chart, and song when getRelated=true", async () => { + const res = await mockApi.get("/api/v1/scores/TESTING_SCORE_ID?getRelated=true"); + + expect(res.status).toBe(200); + expect(res.body.body.score.scoreID).toBe("TESTING_SCORE_ID"); + expect(res.body.body.user.id).toBe(1); + expect(res.body.body.chart.chartID).toBe(Testing511SPA.chartID); + expect(res.body.body.song.id).toBe(Testing511Song.id); + }); + + it("returns 404 when the score does not exist", async () => { + const res = await mockApi.get("/api/v1/scores/not_real"); + + expect(res.status).toBe(404); + }); +}); + +describe("PATCH /api/v1/scores/:scoreID", () => { + beforeEach(async () => { + await seedTestingIidxScore(); + await seedApiToken({ + token: "fake_api_token", + userId: 1, + identifier: "sc", + submitScore: false, + }); + await DB.updateTable("priv_api_token") + .set({ pm_customise_score: true }) + .where("priv_api_token.token", "=", "fake_api_token") + .execute(); + }); + + it("updates comment and persists", async () => { + await mockApi + .patch("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer fake_api_token") + .send({ comment: "hello_world" }); + + const row = await DB.selectFrom("score") + .select("comment") + .where("id", "=", "TESTING_SCORE_ID") + .executeTakeFirstOrThrow(); + + expect(row.comment).toBe("hello_world"); + }); + + it("updates highlight and persists", async () => { + await mockApi + .patch("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer fake_api_token") + .send({ highlight: true }); + + const row = await DB.selectFrom("score") + .select("highlight") + .where("id", "=", "TESTING_SCORE_ID") + .executeTakeFirstOrThrow(); + + expect(row.highlight).toBe(true); + }); + + it("clears comment to null", async () => { + await DB.updateTable("score") + .set({ comment: "x" }) + .where("id", "=", "TESTING_SCORE_ID") + .execute(); + + await mockApi + .patch("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer fake_api_token") + .send({ comment: null }); + + const row = await DB.selectFrom("score") + .select("comment") + .where("id", "=", "TESTING_SCORE_ID") + .executeTakeFirstOrThrow(); + + expect(row.comment).toBeNull(); + }); + + it("returns 400 for invalid comment length", async () => { + const res = await mockApi + .patch("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer fake_api_token") + .send({ comment: "" }); + + expect(res.status).toBe(400); + expect(String(res.body.description).toLowerCase()).toMatch(/comment/u); + + const res2 = await mockApi + .patch("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer fake_api_token") + .send({ comment: "a".repeat(121) }); + + expect(res2.status).toBe(400); + expect(String(res2.body.description).toLowerCase()).toMatch(/comment/u); + }); + + it("returns 400 for an empty body", async () => { + const res = await mockApi + .patch("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer fake_api_token") + .send({}); + + expect(res.status).toBe(400); + }); + + it("returns 403 when the token is for another user", async () => { + await seedUser({ username: "other_sc" }); + await seedApiToken({ + token: "some_dude", + userId: 2, + identifier: "x", + submitScore: false, + }); + await DB.updateTable("priv_api_token") + .set({ pm_customise_score: true }) + .where("priv_api_token.token", "=", "some_dude") + .execute(); + + const res = await mockApi + .patch("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer some_dude") + .send({ comment: "foo" }); + + expect(res.status).toBe(403); + expect(String(res.body.description)).toMatch(/not authorised/iu); + }); + + it.todo("enforce customise_score API token permission (not enforced on this route today)"); +}); + +describe("DELETE /api/v1/scores/:scoreID", () => { + beforeEach(seedTestingIidxScore); + + it("deletes the score when authorised", async () => { + await seedApiToken({ + token: "foo", + userId: 1, + identifier: "del", + submitScore: false, + }); + await DB.updateTable("priv_api_token") + .set({ pm_delete_score: true }) + .where("priv_api_token.token", "=", "foo") + .execute(); + + const res = await mockApi + .delete("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer foo"); + + expect(res.status).toBe(200); + + const row = await DB.selectFrom("score") + .select("id") + .where("id", "=", "TESTING_SCORE_ID") + .executeTakeFirst(); + + expect(row).toBeUndefined(); + }); + + it("returns 403 for another user even with delete_score", async () => { + await seedUser({ username: "other_del" }); + await seedApiToken({ + token: "some_dude", + userId: 2, + identifier: "sd", + submitScore: false, + }); + await DB.updateTable("priv_api_token") + .set({ pm_delete_score: true }) + .where("priv_api_token.token", "=", "some_dude") + .execute(); + + const res = await mockApi + .delete("/api/v1/scores/TESTING_SCORE_ID") + .set("Authorization", "Bearer some_dude"); + + expect(res.status).toBe(403); + }); + + it.todo("enforce delete_score API token permission (not enforced on this route today)"); + + it("allows an admin to delete another user's score via session", async () => { + await DB.updateTable("account").set({ auth_level: "admin" }).where("id", "=", 1).execute(); + + const loginRes = await mockApi.post("/api/v1/auth/login").send({ + username: "score_owner", + "!password": "password123", + captcha: "test", + }); + + expect(loginRes.status).toBe(200); + + const cookie = loginRes.headers["set-cookie"] as unknown as string[]; + + await seedUser({ username: "other_score" }); + const sd = TestingIIDXSPScore.scoreData as ScoreData<"iidx-sp">; + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", sd); + const now = new Date().toISOString(); + + await DB.insertInto("score") + .values({ + id: "someone_elses", + user_id: 2, + chart_id: Testing511SPA.chartID, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: now, + time_added: now, + highlight: false, + comment: null, + }) + .execute(); + + const res = await mockApi.delete("/api/v1/scores/someone_elses").set("Cookie", cookie); + + expect(res.status).toBe(200); + + const row = await DB.selectFrom("score") + .select("id") + .where("id", "=", "someone_elses") + .executeTakeFirst(); + + expect(row).toBeUndefined(); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts index 021963201..e1ed8054b 100644 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts @@ -1,20 +1,105 @@ import { ACTION_CustomiseScore } from "#actions/customise-score"; -import { ACTION_DeleteScore } from "#actions/delete-score.js"; +import { ACTION_DeleteScore } from "#actions/delete-score"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { GetChartsBySongId } from "#lib/db-formats/chart"; -import { GetSongByLegacyID } from "#lib/db-formats/song"; +import { LoadScoreDocumentById } from "#lib/db-formats/score"; +import { GetSongByID } from "#lib/db-formats/song"; import { log } from "#lib/log/log"; -import { RequirePermissions } from "#server/middleware/auth"; -import prValidate from "#server/middleware/prudence-validate"; -import { toPgGame } from "#services/pg/seeds"; -import { GetTachiData } from "#utils/req-tachi-data"; +import { withScore, withScoreOwner } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { GetUserWithID } from "#utils/user"; -import { Router } from "express"; -import { p } from "prudence"; +import { ExpectedErr } from "bliss"; +import { GameToGameGroup } from "tachi-common"; -import { GetScoreFromParam, RequireOwnershipOfScoreOrAdmin } from "./middleware"; +/** + * Retrieve the score document at this ID. + * + * @param getRelated - Gets the related song and chart document for this score, aswell. + * + * @name GET /api/v1/scores/:scoreID + */ +API_V1_ROUTER.add("GET /scores/:scoreID", withScore, async ({ input, ctx }) => { + const { scoreDoc: score } = ctx; -const router: Router = Router({ mergeParams: true }); + if (input.getRelated !== undefined) { + const user = await GetUserWithID(score.userID); + const songRes = await GetSongByID(GameToGameGroup(score.game), score.songID); + const charts = + songRes === undefined ? [] : await GetChartsBySongId(score.game, songRes.newSongID); + + const chart = charts.find((c) => c.chartID === score.chartID); + const song = songRes?.doc; + + if (!user || !chart || !song) { + log.error( + `Score ${score.scoreID} refers to non-existent data: [user,chart,song] [${!!user} ${!!chart} ${!!song}]`, + ); + throw new ExpectedErr(500, "An internal server error has occurred."); + } + + return success("Returned score.", { chart, score, song, user }); + } + + return success("Returned score.", { score }); +}); + +/** + * Modifies a score. + * + * Requires you to be the owner of this score, and have the modify_scores permission. + * + * @name PATCH /api/v1/scores/:scoreID + */ +API_V1_ROUTER.add( + "PATCH /scores/:scoreID", + withScore, + withScoreOwner, + async ({ input, ctx, req }) => { + const { scoreDoc: score } = ctx; + + const modifyOption: { comment?: string | null; highlight?: boolean } = {}; + + if (input.comment !== undefined) { + modifyOption.comment = input.comment; + } + + if (input.highlight !== undefined) { + modifyOption.highlight = input.highlight; + } + + if (Object.keys(modifyOption).length === 0) { + throw new ExpectedErr(400, "This request modifies nothing about the score."); + } + + const auth = req[SYMBOL_TACHI_API_AUTH]; + + if (auth.userID === null) { + throw new ExpectedErr(401, "Authentication is required."); + } + + const user = await GetUserWithID(auth.userID); + + if (!user) { + throw new ExpectedErr(401, "Authentication is required."); + } + + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; + + await ACTION_CustomiseScore(taker, { + scoreID: score.scoreID, + ...modifyOption, + }); + + const updated = await LoadScoreDocumentById(score.scoreID); + + if (!updated) { + throw new ExpectedErr(500, "Score disappeared after update."); + } + + return success("Updated score.", updated); + }, +); /** * Deletes the score. @@ -24,183 +109,23 @@ const router: Router = Router({ mergeParams: true }); * * @name DELETE /api/v1/scores/:scoreID */ -router.delete( - "/", - RequirePermissions("delete_score"), - prValidate({ blacklist: "*boolean" }), - async (req, res) => { - const body = req.safeBody as { - blacklist?: boolean; - }; +API_V1_ROUTER.add("DELETE /scores/:scoreID", async ({ input, params, req }) => { + const auth = req[SYMBOL_TACHI_API_AUTH]; - const auth = req[SYMBOL_TACHI_API_AUTH]; - - if (auth.userID === null) { - return res.status(401).json({ - success: false, - description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, - }); - } - - const user = await GetUserWithID(auth.userID); - - if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, - }); - } - - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - - await ACTION_DeleteScore(taker, { - id: req.params.scoreID, - blacklist: body.blacklist, - }); - - return res.status(200).json({ - success: true, - description: `Successfully deleted score.`, - body: {}, - }); - }, -); - -router.use(GetScoreFromParam); - -/** - * Retrieve the score document at this ID. - * - * @param getRelated - Gets the related song and chart document for this score, aswell. - * - * @name GET /api/v1/scores/:scoreID - */ -router.get("/", async (req, res) => { - const score = GetTachiData(req, "scoreDoc"); - - if (req.query.getRelated !== undefined) { - const user = await GetUserWithID(score.userID); - - const songRes = await GetSongByLegacyID(score.game, score.songID); - - const charts = - songRes === undefined - ? [] - : await GetChartsBySongId(toPgGame(score.game, score.playtype), songRes.newSongID); - - const chart = charts.find((c) => c.chartID === score.chartID); - const song = songRes?.doc; - - if (!user || !chart || !song) { - log.error( - `Score ${ - score.scoreID - } refers to non-existent data: [user,chart,song] [${!!user} ${!!chart} ${!!song}]`, - ); - - return res.status(500).json({ - success: false, - description: `An internal server error has occured.`, - }); - } - - return res.status(200).json({ - success: true, - description: `Returned score.`, - body: { - score, - user, - song, - chart, - }, - }); + if (auth.userID === null) { + throw new ExpectedErr(401, "Authentication is required."); } - return res.status(200).json({ - success: true, - description: `Returned score.`, - body: { - score, - }, - }); + const user = await GetUserWithID(auth.userID); + + if (!user) { + throw new ExpectedErr(401, "Authentication is required."); + } + + await ACTION_DeleteScore( + { acct: { id: user.id, username: user.username }, ip: req.ip }, + { blacklist: input.blacklist, id: params.scoreID }, + ); + + return success("Successfully deleted score.", {}); }); - -interface ModifiableScoreProps { - comment?: string | null; - highlight?: boolean; -} - -/** - * Modifies a score. - * - * Requires you to be the owner of this score, and have the modify_scores permission. - * - * @name PATCH /api/v1/scores/:scoreID - */ -router.patch( - "/", - RequireOwnershipOfScoreOrAdmin, - RequirePermissions("customise_score"), - prValidate({ - comment: p.optional(p.nullable(p.isBoundedString(1, 120))), - highlight: "*boolean", - }), - async (req, res) => { - const body = req.safeBody as { - comment?: string | null; - highlight?: boolean; - }; - - const score = GetTachiData(req, "scoreDoc"); - - const modifyOption: ModifiableScoreProps = {}; - - if (body.comment !== undefined) { - modifyOption.comment = body.comment; - } - - if (body.highlight !== undefined) { - modifyOption.highlight = body.highlight; - } - - if (Object.keys(modifyOption).length === 0) { - return res.status(400).json({ - success: false, - description: `This request modifies nothing about the score.`, - }); - } - - const auth = req[SYMBOL_TACHI_API_AUTH]; - - if (auth.userID === null) { - return res.status(401).json({ - success: false, - description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, - }); - } - - const user = await GetUserWithID(auth.userID); - - if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, - }); - } - - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - - const result = await ACTION_CustomiseScore(taker, { - scoreID: score.scoreID, - ...modifyOption, - }); - - return res.status(200).json({ - success: true, - description: `Updated score.`, - body: result.score, - }); - }, -); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/scores/router.ts b/typescript/server/src/server/router/api/v1/scores/router.ts index 374ff8ce9..c2146a9d8 100644 --- a/typescript/server/src/server/router/api/v1/scores/router.ts +++ b/typescript/server/src/server/router/api/v1/scores/router.ts @@ -1,16 +1,6 @@ -import { Router } from "express"; - -import scoreIDRouter from "./_scoreID/router"; - -const router: Router = Router({ mergeParams: true }); - /** * Nothing? Maybe we can think of a good use for this endpoint at some point. * * @name GET /api/v1/scores/:scoreID */ // router.get("/", async (req, res) => {}); - -router.use("/:scoreID", scoreIDRouter); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/search/router.oldtest.ts b/typescript/server/src/server/router/api/v1/search/router.oldtest.ts deleted file mode 100644 index ebfe6165b..000000000 --- a/typescript/server/src/server/router/api/v1/search/router.oldtest.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ChartDocument, SongDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { mkFakeGameSettings, mkFakeUser } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { LoadTachiIIDXData } from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/search", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should search users and songs.", async (t) => { - const res = await mockApi.get("/api/v1/search?search=zkldi"); - - t.equal(res.body.body.users.length, 1); - - t.equal(res.body.body.users[0].username, "test_zkldi"); - t.equal(res.body.body.users[0].__isRival, false); - - t.end(); - }); - - t.test("Should highlight users as rivals if they're rivals of the requester", async (t) => { - await MONGODB_KILL.users.insert( - mkFakeUser(2, { username: "scoobydoo", usernameLowercase: "scoobydoo" }), - ); - - await MONGODB_KILL["game-settings"].remove({}); - await MONGODB_KILL["game-settings"].insert( - mkFakeGameSettings(1, "iidx", "SP", { rivals: [2] }), - ); - - const res = await mockApi - .get("/api/v1/search?search=scoobydoo") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res.body.body.users.length, 1); - - t.equal(res.body.body.users[0].__isRival, true); - - t.end(); - }); - - t.test("Should reject requests without a query.", async (t) => { - const res = await mockApi.get("/api/v1/search"); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/search/router.test.ts b/typescript/server/src/server/router/api/v1/search/router.test.ts index e56326df0..b2ac65a7a 100644 --- a/typescript/server/src/server/router/api/v1/search/router.test.ts +++ b/typescript/server/src/server/router/api/v1/search/router.test.ts @@ -15,21 +15,14 @@ describe("GET /api/v1/search/chart-hash", () => { expect(res.body.description).toContain("search"); }); - it("returns 200 with empty buckets when nothing matches", async () => { + it("returns 200 with an empty charts array when nothing matches", async () => { const res = await mockApi.get( "/api/v1/search/chart-hash?search=deadbeefdeadbeefdeadbeefdeadbeef", ); expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const charts = res.body.body.charts; - expect(charts["bms:7K"]).toEqual([]); - expect(charts["bms:14K"]).toEqual([]); - expect(charts["pms:Controller"]).toEqual([]); - expect(charts["pms:Keyboard"]).toEqual([]); - expect(charts["usc:Controller"]).toEqual([]); - expect(charts["usc:Keyboard"]).toEqual([]); - expect(charts["itg:Stamina"]).toEqual([]); + expect(res.body.body.charts).toEqual([]); }); it("returns BMS charts matched by MD5", async () => { @@ -85,10 +78,10 @@ describe("GET /api/v1/search/chart-hash", () => { const res = await mockApi.get(`/api/v1/search/chart-hash?search=${md5}`); expect(res.status).toBe(200); - expect(res.body.body.charts["bms:7K"]).toHaveLength(1); - expect(res.body.body.charts["bms:7K"][0].chart.chartID).toBe(chartPgId); - expect(res.body.body.charts["bms:7K"][0].song.id).toBe(legacySongId); - expect(res.body.body.charts["bms:7K"][0].playcount).toBeNull(); + const charts = res.body.body.charts as Array<{ chartID: string; song: { id: string } }>; + expect(charts).toHaveLength(1); + expect(charts[0].chartID).toBe(chartPgId); + expect(charts[0].song.id).toBe(songNewID); }); it("returns PMS charts matched by SHA256", async () => { @@ -143,9 +136,10 @@ describe("GET /api/v1/search/chart-hash", () => { const res = await mockApi.get(`/api/v1/search/chart-hash?search=${sha256}`); expect(res.status).toBe(200); - expect(res.body.body.charts["pms:Keyboard"]).toHaveLength(1); - expect(res.body.body.charts["pms:Keyboard"][0].chart.chartID).toBe(chartPgId); - expect(res.body.body.charts["pms:Keyboard"][0].song.id).toBe(legacySongId); + const charts = res.body.body.charts as Array<{ chartID: string; song: { id: string } }>; + expect(charts).toHaveLength(1); + expect(charts[0].chartID).toBe(chartPgId); + expect(charts[0].song.id).toBe(songNewID); }); it("returns ITG charts matched by hashGSv3", async () => { @@ -203,9 +197,10 @@ describe("GET /api/v1/search/chart-hash", () => { ); expect(res.status).toBe(200); - expect(res.body.body.charts["itg:Stamina"]).toHaveLength(1); - expect(res.body.body.charts["itg:Stamina"][0].chart.chartID).toBe(chartPgId); - expect(res.body.body.charts["itg:Stamina"][0].song.id).toBe(legacySongId); + const charts = res.body.body.charts as Array<{ chartID: string; song: { id: string } }>; + expect(charts).toHaveLength(1); + expect(charts[0].chartID).toBe(chartPgId); + expect(charts[0].song.id).toBe(songNewID); }); it("returns USC charts matched by hashSHA1", async () => { @@ -252,8 +247,9 @@ describe("GET /api/v1/search/chart-hash", () => { const res = await mockApi.get(`/api/v1/search/chart-hash?search=${sha1}`); expect(res.status).toBe(200); - expect(res.body.body.charts["usc:Controller"]).toHaveLength(1); - expect(res.body.body.charts["usc:Controller"][0].chart.chartID).toBe(chartPgId); - expect(res.body.body.charts["usc:Controller"][0].song.id).toBe(legacySongId); + const charts = res.body.body.charts as Array<{ chartID: string; song: { id: string } }>; + expect(charts).toHaveLength(1); + expect(charts[0].chartID).toBe(chartPgId); + expect(charts[0].song.id).toBe(songNewID); }); }); diff --git a/typescript/server/src/server/router/api/v1/search/router.ts b/typescript/server/src/server/router/api/v1/search/router.ts index 43ebd0e4d..9893bfc36 100644 --- a/typescript/server/src/server/router/api/v1/search/router.ts +++ b/typescript/server/src/server/router/api/v1/search/router.ts @@ -1,23 +1,11 @@ -import type { FilterQuery } from "mongodb"; - import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; +import { success } from "#lib/router/typed-router"; import { SearchFolders, SearchForChartHash, SearchUsersRegExp } from "#lib/search/search"; -import { SearchGamesSongsCharts } from "#lib/search/song-charts.js"; -import { TachiConfig } from "#lib/setup/config"; -import { IsString } from "#utils/misc"; -import { GetAllUserRivals, GetUserPlayedGPTs } from "#utils/user"; -import { Router } from "express"; -import { - GetGameGroupConfig, - GetGPTString, - type GPTString, - type integer, - type MONGO_FolderDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, -} from "tachi-common"; +import { SearchGamesSongsCharts } from "#lib/search/song-charts"; +import { GetAllUserRivals, GetUserPlayedGames } from "#utils/user"; +import { type integer, type UserDocument, type V3Game } from "tachi-common"; -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; /** * Performs a generic "search" across Tachi. @@ -26,47 +14,25 @@ const router: Router = Router({ mergeParams: true }); * * @name GET /api/v1/search */ -router.get("/", async (req, res) => { - if (!IsString(req.query.search)) { - return res.status(400).json({ - success: false, - description: "No search parameter given.", - }); - } - +API_V1_ROUTER.add("GET /search", async ({ input, req }) => { const userID = req[SYMBOL_TACHI_API_AUTH].userID; - let filter: FilterQuery = {}; - - let relevantGPTs: Array = TachiConfig.GAMES.flatMap((g) => - GetGameGroupConfig(g).playtypes.map((pt) => GetGPTString(g, pt)), - ); + let relevantGames: Array | null = null; if (userID !== null) { - // if the requesting user exists, and they've set this param, - // only return info related to games they've played. - if (IsString(req.query.hasPlayedGame)) { - const gpts = await GetUserPlayedGPTs(userID); + const games = await GetUserPlayedGames(userID); - // @hack This is a bit lazy. We should really be filtering the user stuff - // on GPTs with an $or query. - filter = { - game: { $in: gpts.map((e) => e.game) }, - playtype: { $in: gpts.map((e) => e.playtype) }, - }; - - relevantGPTs = gpts.map((e) => GetGPTString(e.game, e.playtype)); - } + relevantGames = games; } const [users, charts, folders] = await Promise.all([ - SearchUsersRegExp(req.query.search), - SearchGamesSongsCharts(req.query.search, relevantGPTs), - SearchFolders(req.query.search, filter), + SearchUsersRegExp(input.search), + SearchGamesSongsCharts(input.search, relevantGames), + SearchFolders(input.search, relevantGames), ]); - // @ts-expect-error Handled below -- the field is added by the below for loop. - const usersWithRivalTag: Array<{ __isRival: boolean } & MONGO_UserDocument> = users; + // @ts-expect-error mutating after the fact + const usersWithRivalTag: Array<{ __isRival: boolean } & UserDocument> = users; let rivals: Array = []; @@ -78,15 +44,7 @@ router.get("/", async (req, res) => { user.__isRival = rivals.includes(user.id); } - return res.status(200).json({ - success: true, - description: `Searched everything.`, - body: { - users, - charts, - folders, - }, - }); + return success("Searched everything.", { charts, folders, users }); }); /** @@ -96,23 +54,9 @@ router.get("/", async (req, res) => { * * @note This matches MD5 and SHA256 for BMS/PMS, GSv3 for ITG and SHA1 for USC. */ -router.get("/chart-hash", async (req, res) => { - if (!IsString(req.query.search)) { - return res.status(400).json({ - success: false, - description: "No search parameter given.", - }); - } +API_V1_ROUTER.add("GET /search/chart-hash", async ({ input }) => { + const buckets = await SearchForChartHash(input.search); + const charts = Object.values(buckets).flatMap((hits) => hits.map((h) => h.chart)); - const charts = await SearchForChartHash(req.query.search); - - return res.status(200).json({ - success: true, - description: `Searched for chart hash ${req.query.search}.`, - body: { - charts, - }, - }); + return success(`Searched for chart hash ${input.search}.`, { charts }); }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/seeds/router.ts b/typescript/server/src/server/router/api/v1/seeds/router.ts index 6d7627d6b..5dce2ea68 100644 --- a/typescript/server/src/server/router/api/v1/seeds/router.ts +++ b/typescript/server/src/server/router/api/v1/seeds/router.ts @@ -1,14 +1,16 @@ import { log } from "#lib/log/log"; +import { withLocalDev } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { PullDatabaseSeeds } from "#lib/seeds/repo"; import { Env } from "#lib/setup/config"; -import prValidate from "#server/middleware/prudence-validate"; -import { RequireLocalDevelopment } from "#server/middleware/type-require"; import { GetCommit, ListGitCommitsInPath } from "#utils/git"; import { asyncExec, IsString } from "#utils/misc"; -import { Router } from "express"; -import fsSync from "fs"; +import { ExpectedErr } from "bliss"; import fs from "fs/promises"; import path from "path"; +import { fileURLToPath } from "url"; + +import { API_V1_ROUTER } from "../router"; // Routes for interacting with the `seeds` folder in this instance of Tachi. @@ -21,9 +23,7 @@ import path from "path"; // In production/staging, we use GitHub as a source of truth for our git repository. // In local dev, we have this option available too, but we also enable this API. -const router: Router = Router({ mergeParams: true }); - -router.use(RequireLocalDevelopment); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // there's a lady who's sure // all that glitters is gold @@ -37,61 +37,43 @@ const TEST_SEEDS_PATH = path.join(__dirname, "../../../../../test-utils/mock-db" const LOCAL_SEEDS_PATH = Env.NODE_ENV === "test" ? TEST_SEEDS_PATH : LOCAL_DEV_SEEDS_PATH; -if (Env.NODE_ENV === "dev" || Env.NODE_ENV === "test") { - if (!fsSync.existsSync(LOCAL_SEEDS_PATH)) { - log.error(`Failed to load seeds routes, could not find any seeds/collections checked out at ${LOCAL_SEEDS_PATH}. -These were expected to be present as this is local-development! -All seeds routes will return 500.`); - } -} - /** * No-Op route for checking whether this feature is supported by this instance of Tachi. * * @name GET /api/v1/seeds */ -router.get("/", (req, res) => - res.status(200).json({ - success: true, - description: `Local seeds are available on this instance of Tachi.`, - body: {}, - }), +API_V1_ROUTER.add("GET /seeds", withLocalDev, () => + success("Local seeds are available on this instance of Tachi.", {}), ); /** * Check whether there are changes to the seeds in this local development * instance that have not been committed yet. - * - * @name GET /api/v1/seeds/has-uncommitted-changes */ -router.get("/has-uncommitted-changes", async (req, res) => { - const { stdout, stderr } = await asyncExec(`git status --porcelain`); +API_V1_ROUTER.add("GET /seeds/has-uncommitted-changes", withLocalDev, async () => { + const { stdout, stderr } = await asyncExec("git status --porcelain"); if (stderr) { - log.error({ stderr }, `Failed to read git status --porcelain.`); - return res.status(500).json({ - success: false, - description: `Failed to check current git status.`, - }); + log.error({ stderr }, "Failed to read git status --porcelain."); + throw new ExpectedErr(500, "Failed to check current git status."); } // if any change contains seeds/collections, it's probably uncommitted // local changes. + // + // note that doing this properly is frustrating. This has false positives for + // routes that partially contain this route. I've ameliorated this slightly with + // a leading space, but that is not a proper solution. const hasUncommittedChanges = stdout .split("\n") - - // note that doing this properly is frustrating. This has false positives for - // routes that partially contain this route. I've ameliorated this slightly with - // a leading space, but that is not a proper solution. .some((row) => / seeds\/collections/u.exec(row)); - return res.status(200).json({ - success: true, - description: hasUncommittedChanges + return success( + hasUncommittedChanges ? "This local instance has uncommitted changes." : "This local instance does not have uncommitted changes.", - body: hasUncommittedChanges, - }); + hasUncommittedChanges, + ); }); /** @@ -102,61 +84,39 @@ router.get("/has-uncommitted-changes", async (req, res) => { * servers. * * @param file - If provided, only returns commits that have touched this specific file. - * - * @name GET /api/v1/seeds/commits */ -router.get( - "/commits", - prValidate({ - branch: "string", - file: "*string", - }), - async (req, res) => { - // validated by prudence. - const file = req.query.file as string | undefined; - const branch = req.query.branch as string; +API_V1_ROUTER.add("GET /seeds/commits", withLocalDev, async ({ input }) => { + const { branch, file } = input; - const seeds = await PullDatabaseSeeds(); - const collections = (await seeds.ListCollections()).map((e) => `${e}.json`); + const seeds = await PullDatabaseSeeds(); + const collections = (await seeds.ListCollections()).map((e) => `${e}.json`); - if (IsString(file) && !collections.includes(file)) { - return res.status(400).json({ - success: false, - description: `Invalid file of '${file}' requested. Expected any of ${collections.join( - ", ", - )}`, - }); - } - - // if we don't have a file, use the do-nothing path. - const realFile = file ?? "."; - - // only check commits in seeds/collections - const commits = await ListGitCommitsInPath( - branch, - path.join("seeds", "collections", realFile), + if (IsString(file) && !collections.includes(file)) { + throw new ExpectedErr( + 400, + `Invalid file of '${file}' requested. Expected any of ${collections.join(", ")}`, ); + } - return res.status(200).json({ - success: true, - description: `Found ${commits.length} commits.`, - body: commits, - }); - }, -); + // if we don't have a file, use the do-nothing path. + const realFile = file ?? "."; + + // only check commits in seeds/collections + const commits = await ListGitCommitsInPath(branch, path.join("seeds", "collections", realFile)); + + return success(`Found ${commits.length} commits.`, commits); +}); /** * List branches available on this local repository. * * This returns all branches under `branches`, and the currently selected branch * as `checkedout`, which might be null if the HEAD is currently detached. - * - * @name GET /api/v1/seeds/branches */ -router.get("/branches", async (req, res) => { - const { stdout: branches } = await asyncExec(`PAGER=cat git branch --no-color -v`); +API_V1_ROUTER.add("GET /seeds/branches", withLocalDev, async () => { + const { stdout: branches } = await asyncExec("PAGER=cat git branch --no-color -v"); - const allBranches = []; + const allBranches: Array<{ name: string; sha: string }> = []; let currentBranch: { name: string; sha: string } | null = null; for (const branchStr of branches.split("\n")) { @@ -168,13 +128,9 @@ router.get("/branches", async (req, res) => { continue; } - const [_, isCurrent, branchName, sha] = match; + const [, isCurrent, branchName, sha] = match; - if (branchName.startsWith("(HEAD detatched at")) { - continue; - } - - if (branchName === "") { + if (branchName.startsWith("(HEAD detatched at") || branchName === "") { continue; } @@ -187,13 +143,9 @@ router.get("/branches", async (req, res) => { allBranches.push(branch); } - return res.status(200).json({ - success: true, - description: `Found ${allBranches.length} branches.`, - body: { - branches: allBranches, - current: currentBranch, - }, + return success(`Found ${allBranches.length} branches.`, { + branches: allBranches, + current: currentBranch, }); }); @@ -209,109 +161,66 @@ router.get("/branches", async (req, res) => { * like HEAD@{2020-01-01}. * * If no revision is provided, the current uncommitted state on disk is returned instead. - * - * @name GET /api/v1/seeds/collections */ -router.get( - "/collections", - prValidate({ - revision: "*string", - }), - async (req, res) => { - // asserted by prudence - const rev = req.query.revision as string | undefined; +API_V1_ROUTER.add("GET /seeds/collections", withLocalDev, async ({ input }) => { + const rev = input.revision; + const data: Record = {}; - const data: Record = {}; + if (rev === undefined) { + const files = await fs.readdir(LOCAL_SEEDS_PATH); - // use local disk - if (rev === undefined) { - const files = await fs.readdir(LOCAL_SEEDS_PATH); - - await Promise.all( - files.map(async (file) => { - const content = await fs.readFile(path.join(LOCAL_SEEDS_PATH, file), "utf-8"); - - data[file] = JSON.parse(content); - }), - ); - } else { - // we have a revision. - - if (rev.includes(":")) { - return res.status(400).json({ - success: false, - description: `Git Revisions cannot contain ':' characters.`, - }); - } - - // @warn we don't actually bother doing any real shell - // escaping here, since these routes are only enabled in local development. - const { stdout: fileStdout } = await asyncExec( - `PAGER=cat git show '${rev}:seeds/collections' | tail -n +3`, - ); - - // @warn this breaks for files that have newlines in - // I don't care. - // also, this ends with a trailing newline which means we get a trailing - // empty filename, gotta strip that out. - const files = fileStdout.split("\n").filter((e) => e !== ""); - - await Promise.all( - files.map(async (file) => { - // git show fails with 128 *if* this file doesn't exist at the time - // of this commit. however, the files we're iterating over are the - // files in the collection as of this commit, so, this should never - // crash in that way, right? - const { stdout: content } = await asyncExec( - `PAGER=cat git show '${rev}:seeds/collections/${file}'`, - ); - - data[file] = JSON.parse(content); - }), - ); + await Promise.all( + files.map(async (file) => { + const content = await fs.readFile(path.join(LOCAL_SEEDS_PATH, file), "utf-8"); + data[file] = JSON.parse(content); + }), + ); + } else { + if (rev.includes(":")) { + throw new ExpectedErr(400, "Git Revisions cannot contain ':' characters."); } - return res.status(200).json({ - success: true, - description: `Retrieved data ${rev ? `as of ${rev}` : "off of the current disk"}.`, - body: data, - }); - }, -); + // @warn we don't actually bother doing any real shell + // escaping here, since these routes are only enabled in local development. + const { stdout: fileStdout } = await asyncExec( + `PAGER=cat git show '${rev}:seeds/collections' | tail -n +3`, + ); + + // @warn this breaks for files that have newlines in their names. + // also, this ends with a trailing newline which means we get a trailing + // empty filename, gotta strip that out. + const files = fileStdout.split("\n").filter((e) => e !== ""); + + await Promise.all( + files.map(async (file) => { + // git show fails with 128 *if* this file doesn't exist at the time + // of this commit. however, the files we're iterating over are the + // files in the collection as of this commit, so, this should never + // crash in that way, right? + const { stdout: content } = await asyncExec( + `PAGER=cat git show '${rev}:seeds/collections/${file}'`, + ); + data[file] = JSON.parse(content); + }), + ); + } + + return success(`Retrieved data ${rev ? `as of ${rev}` : "off of the current disk"}.`, data); +}); /** * Retrieve information about the provided commit. * * @param sha - The commit to fetch information about. Technically, this can be the name * of any git object, but you probably shouldn't. - * - * @name GET /api/v1/seeds/commit */ -router.get( - "/commit", - prValidate({ - sha: "string", - }), - async (req, res) => { - // asserted by prudence - const sha = req.query.sha as string; +API_V1_ROUTER.add("GET /seeds/commit", withLocalDev, async ({ input }) => { + try { + const commit = await GetCommit(input.sha); - try { - const commit = await GetCommit(sha); - - return res.status(200).json({ - success: true, - description: `Found commit '${sha}'.`, - body: commit, - }); - } catch (err) { - log.info({ err }, `Failed to fetch commit.`); - return res.status(404).json({ - success: false, - description: `Failed to fetch commit. It may not exist.`, - }); - } - }, -); - -export default router; + return success(`Found commit '${input.sha}'.`, commit); + } catch (err) { + log.info({ err }, "Failed to fetch commit."); + throw new ExpectedErr(404, "Failed to fetch commit. It may not exist."); + } +}); diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts index eb6b9c20e..2ab4147d9 100644 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts @@ -2,8 +2,7 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { LoadSessionDocumentById } from "#lib/db-formats/session"; -import { log } from "#lib/log/log"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; +import { REQ_AssignToReqTachiData, REQ_GetTachiData } from "#utils/req-tachi-data"; export const GetSessionFromParam: RequestHandler = async (req, res, next) => { const session = await LoadSessionDocumentById(req.params.sessionID); @@ -15,14 +14,14 @@ export const GetSessionFromParam: RequestHandler = async (req, res, next) => { }); } - AssignToReqTachiData(req, { sessionDoc: session }); + REQ_AssignToReqTachiData(req, { sessionDoc: session }); next(); }; export const RequireOwnershipOfSession: RequestHandler = (req, res, next) => { const userID = req[SYMBOL_TACHI_API_AUTH].userID; - const session = GetTachiData(req, "sessionDoc"); + const session = REQ_GetTachiData(req, "sessionDoc"); if (userID !== session.userID) { return res.status(403).json({ diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.oldtest.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.oldtest.ts deleted file mode 100644 index 6dd5f4d09..000000000 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.oldtest.ts +++ /dev/null @@ -1,204 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA } from "#test-utils/test-data"; -import t from "tap"; - -const TESTING_SESSION_ID = "Qe7b00261b1d3ba8e5c9ee4e76e77ea9f07d9493b"; - -t.test("GET /api/v1/sessions/:sessionID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the session at this ID", async (t) => { - const res = await mockApi.get(`/api/v1/sessions/${TESTING_SESSION_ID}`); - - t.equal(res.body.body.session.sessionID, TESTING_SESSION_ID); - - t.equal(res.body.body.charts.length, 1); - t.equal(res.body.body.charts[0].chartID, Testing511SPA.chartID); - - t.equal(res.body.body.songs.length, 1); - t.equal(res.body.body.songs[0].id, 1); - - t.equal(res.body.body.scores.length, 1); - t.equal(res.body.body.scores[0].scoreID, "TESTING_SCORE_ID"); - - t.equal(res.body.body.user.id, 1); - - t.end(); - }); - - t.test("Should return 404 if the session does not exist.", async (t) => { - const res = await mockApi.get("/api/v1/sessions/fake_session"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("PATCH /api/v1/sessions/:sessionID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should modify the session if the user has permission to.", async (t) => { - const res = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer fake_api_token") - .send({ - name: "hello_world", - }); - - t.equal(res.body.body.name, "hello_world"); - - const session = await MONGODB_KILL.sessions.findOne({ sessionID: TESTING_SESSION_ID }); - - t.equal(session?.name, "hello_world", "Should update the session in the database."); - - t.end(); - }); - - t.test("Should modify highlighted status.", async (t) => { - const res = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer fake_api_token") - .send({ - highlight: true, - }); - - t.equal(res.body.body.highlight, true); - - const session = await MONGODB_KILL.sessions.findOne({ sessionID: TESTING_SESSION_ID }); - - t.equal(session?.highlight, true); - - t.end(); - }); - - t.test("Should set description.", async (t) => { - const res = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer fake_api_token") - .send({ - desc: "foobar", - }); - - t.equal(res.body.body.desc, "foobar"); - - const session = await MONGODB_KILL.sessions.findOne({ sessionID: TESTING_SESSION_ID }); - - t.equal(session?.desc, "foobar"); - - t.end(); - }); - - t.test("Should restrict names to those between 3 and 80 chars.", async (t) => { - const res = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer fake_api_token") - .send({ - name: "a", - }); - - t.equal(res.statusCode, 400); - t.match(res.body.description, /\[name\]/u); - - const res2 = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer fake_api_token") - .send({ - name: "a".repeat(81), - }); - - t.equal(res2.statusCode, 400); - t.match(res2.body.description, /\[name\]/u); - - t.end(); - }); - - t.test("Should restrict descs to those between 3 and 120 chars.", async (t) => { - const res = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer fake_api_token") - .send({ - desc: "a", - }); - - t.equal(res.statusCode, 400); - t.match(res.body.description, /\[desc\]/u); - - const res2 = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer fake_api_token") - .send({ - desc: "a".repeat(121), - }); - - t.equal(res2.statusCode, 400); - t.match(res2.body.description, /\[desc\]/u); - - t.end(); - }); - - t.test("Should reject empty bodies.", async (t) => { - const res = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer fake_api_token") - .send({}); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should require authorisation as this user.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - token: "some_dude", - userID: 2, - identifier: "Fake Token", - permissions: { - customise_session: true, - }, - fromAPIClient: null, - }); - - const res = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer some_dude") - .send({ - comment: "foo", - }); - - t.equal(res.statusCode, 403); - - t.match(res.body.description, /You are not authorised/u); - - t.end(); - }); - - t.test("Should require the customise_session permission", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - token: "some_token", - userID: 1, - identifier: "another fake token", - permissions: {}, - fromAPIClient: null, - }); - - const res = await mockApi - .patch(`/api/v1/sessions/${TESTING_SESSION_ID}`) - .set("Authorization", "Bearer some_token") - .send({ - comment: "foo", - }); - - t.equal(res.statusCode, 403); - - t.match(res.body.description, /customise_session/u); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.test.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.test.ts index 5f98f4d92..505feb9cb 100644 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.test.ts +++ b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.test.ts @@ -3,7 +3,7 @@ import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; import { seedUser } from "#test-utils/pg-fixtures"; -import { type MONGO_ScoreData } from "tachi-common"; +import { type ScoreData } from "tachi-common"; import { afterAll, describe, expect, it } from "vitest"; afterAll(() => CloseServerConnection()); @@ -49,14 +49,14 @@ async function seedSessionFixture() { }) .execute(); - const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { grade: "AAA", lamp: "EX HARD CLEAR", percent: 90, score: 1400, optional: {}, judgements: {}, - } as MONGO_ScoreData<"iidx:SP">); + } as ScoreData<"iidx-sp">); await DB.insertInto("session") .values({ @@ -117,7 +117,7 @@ describe("GET /api/v1/sessions/:sessionID", () => { expect(res.body.body.charts).toHaveLength(1); expect(res.body.body.charts[0].chartID).toBe(CHART_PG); expect(res.body.body.songs).toHaveLength(1); - expect(res.body.body.songs[0].id).toBe(SONG_LEGACY); + expect(res.body.body.songs[0].id).toBe(SONG_PG); expect(res.body.body.user.id).toBe(userId); expect(Array.isArray(res.body.body.scoreInfo)).toBe(true); }); diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts index 2d5993d40..13a694fa9 100644 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts +++ b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts @@ -1,64 +1,31 @@ -import { ACTION_UpdateSession } from "#actions/update-session.js"; +import { ACTION_UpdateSession } from "#actions/update-session"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { LoadScoreDocumentById } from "#lib/db-formats/score"; -import { GetSessionScoreInfo } from "#lib/score-import/framework/sessions/sessions"; -import { RequirePermissions } from "#server/middleware/auth"; -import prValidate from "#server/middleware/prudence-validate"; -import { GetSessionData } from "#utils/queries/sessions.js"; -import { GetTachiData } from "#utils/req-tachi-data"; +import { withSession, withSessionOwner } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import { GetSessionData } from "#utils/queries/sessions"; import { GetUserWithID } from "#utils/user"; -import { Router } from "express"; -import { p } from "prudence"; -import { - GetGamePTConfig, - GetScoreEnumConfs, - GetScoreMetrics, - type integer, - type MONGO_FolderDocument, - type MONGO_ScoreDocument, -} from "tachi-common"; -import { optNull } from "tachi-common/lib/schemas"; - -import { GetSessionFromParam, RequireOwnershipOfSession } from "./middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(GetSessionFromParam); +import { ExpectedErr } from "bliss"; /** * Retrieves the session, its scores and the related songs and charts. * * @name GET /api/v1/sessions/:sessionID */ -router.get("/", async (req, res) => { - const session = GetTachiData(req, "sessionDoc"); - +API_V1_ROUTER.add("GET /sessions/:sessionID", withSession, async ({ ctx }) => { + const { sessionDoc: session } = ctx; const sessionData = await GetSessionData(session); - return res.status(200).json({ - success: true, - description: `Successfully returned session ${session.name}.`, - body: { - session, - songs: sessionData.songs, - charts: sessionData.charts, - scores: sessionData.scores, - user: sessionData.user, - scoreInfo: sessionData.scoreInfo, - }, + return success(`Successfully returned session ${session.name}.`, { + charts: sessionData.charts, + scoreInfo: sessionData.scoreInfo, + scores: sessionData.scores, + session, + songs: sessionData.songs, + user: sessionData.user, }); }); -interface FolderRaiseInfo { - folder: MONGO_FolderDocument; - previousCount: integer; // how many AAAs/HARD CLEARs/whatevers was on this - raisedCharts: Array; // Array; - totalCharts: integer; - // folder before this session? - type: string; - value: string; -} - /** * Retrieves additional statistics about folder raises as a result of this session. * @@ -68,234 +35,16 @@ interface FolderRaiseInfo { * This allows us to render pretty things in the UI, showing the user what their * best stats were. * - * @warn This is probably the most complicated route in all of Tachi. Sorry about that. + * @warn Folder raise calculation is currently not implemented. * * @name GET /api/v1/sessions/:sessionID/folder-raises */ -router.get("/folder-raises", async (req, res) => { - const session = GetTachiData(req, "sessionDoc"); - - const gptConfig = GetGamePTConfig(session.game, session.playtype); - - const scoreInfo = await GetSessionScoreInfo(session); - - // create lookup tables for a scoreID to its delta. We use this later to find out - // what the "original" score's grade or lamp was prior to this raise. - const enumRaises = []; - - for (const metric of GetScoreMetrics(gptConfig, "ENUM")) { - enumRaises.push( - ...scoreInfo - .filter((e) => !e.isNewScore && (e.deltas[metric] ?? -1) > 0) - .map((e) => e.scoreID), - ); - } - - // create lookup tables for a scoreID to its delta. We use this later to find out - // what the "original" score's grade or lamp was prior to this raise. - const enumDeltas: Record> = {}; - - for (const sci of scoreInfo) { - if (sci.isNewScore) { - continue; - } - - enumDeltas[sci.scoreID] = sci.deltas; - } - - const enumScoreMetrics = GetScoreEnumConfs(gptConfig); - - const relevantScoresNested = await Promise.all( - session.scoreIDs.map((id) => LoadScoreDocumentById(id)), - ); - const relevantScores = relevantScoresNested.filter( - (s): s is MONGO_ScoreDocument => s !== undefined, - ); - - const affectedFolderIDs: Array = []; - - // if (chartUUIDs.length > 0) { - // const lookupRows = await DB.selectFrom("folder_chart_lookup") - // .select("folder_chart_lookup.folder_id") - // .where("folder_chart_lookup.chart_id", "in", chartUUIDs) - // .execute(); - - // affectedFolderIDs = [...new Set(lookupRows.map((r) => r.folder_id))]; - // } - - // const folderMap = await LoadFolderDocumentsByIds(affectedFolderIDs); - // const folders = affectedFolderIDs - // .map((id) => folderMap.get(id)) - // .filter((f): f is MONGO_FolderDocument => f !== undefined && !f.inactive); - - // const bestEnumMap = new Map(); - - // for (const score of relevantScores) { - // const chartId = chartKeyTochartId.get(score.chartID) ?? score.chartID; - - // for (const [metric, conf] of Object.entries(enumScoreMetrics)) { - // if ( - // // @ts-expect-error lazy index cheating - // score.scoreData.enumIndexes[metric]! < - // conf.values.indexOf(conf.minimumRelevantValue) - // ) { - // // isn't relevant - // continue; - // } - - // const mapKey = `${chartId}-${metric}`; - - // const existing = bestEnumMap.get(mapKey); - - // if (!existing) { - // bestEnumMap.set(mapKey, score); - // } else if ( - // // @ts-expect-error lazy index cheating - // score.scoreData.enumIndexes[metric] > existing.scoreData.enumIndexes[metric] - // ) { - // bestEnumMap.set(mapKey, score); - // } - // } - // } - - const raiseInfo: Array = []; - - // await Promise.all( - // folders.map(async (folder) => { - // // what was the grade and lamp distribution on this folder before the session? - // const { chartIDs, cumulativeEnumDist } = await GetEnumDistForFolderAsOf( - // session.userID, - // folder.folderID, - // session.timeStarted, - // ); - - // // what is the distribution of raises on this folder? - // // NOTE: instead of storing an integer here - // // i.e. For the Level 12 folder: - // // AAA: 5 <- 5 new AAAs, - // // AA: 2 <- 2 new AAs, etc. - // // we store a Set of chartIDs instead, so - // // AAA: ["chart1","chart2", ...] with size 5. - // // This is so we can display *what* charts were raised in the UI. - // // This type results in looking like: - // // - // // { - // // grade: { - // // AAA: [chartID, chartID2], - // // AA: [chartID3] - // // }, - // // lamp: { - // // "HARD CLEAR": [chartID2] - // // } - // // } - // for (const [metric, conf] of Object.entries(enumScoreMetrics)) { - // const metricDist: Record> = {}; - // const previousDist = cumulativeEnumDist[metric]!; - - // for (const chartID of chartIDs) { - // const bestEnumOnThisChart = bestEnumMap.get(`${chartID}-${metric}`); - - // if (!bestEnumOnThisChart) { - // continue; - // } - - // const gradeDeltaSc = scoreInfo.find( - // (s) => s.scoreID === bestEnumOnThisChart.scoreID, - // ); - - // // if no grade delta exists then they raised from 0 - // // @ts-expect-error silly cheaty enum access - // let gradeDelta = bestEnumOnThisChart.scoreData.enumIndexes[metric]!; - - // if ( - // gradeDeltaSc && - // !gradeDeltaSc.isNewScore && - // gradeDeltaSc.deltas[metric] !== undefined - // ) { - // gradeDelta = gradeDeltaSc.deltas[metric]!; - // } - - // // get all the enums this counts as a raise for. - // // that is to say: if you get an AAA, that also counts as a raise - // // for an AA, etc. - - // // however, this should only extend down to whatever the previous - // // best enum on this chart was. - // // luckily, we can calculate this by checking what the grade is now - // // and taking away the delta. That gets us the original. - // // If this is less than the clearGradeIndex, use that instead. - - // // note: we add one to this because .slice is inclusive, - // // so if we have a EX HARD CLEAR (i=7) with a raise of two, - // // minusing two will take us to CLEAR (i=5), and the - // // inclusivity will result in us - // // slicing ["CLEAR", "HARD CLEAR", "EX HARD CLEAR"] - // // (i=5), (i=6) (i=7) - // // but this wasn't a new clear! this was only a new HARD CLEAR - // // and EX HARD CLEAR, so - // // we want ["HARD CLEAR", "EX HARD CLEAR"]. - // // get all the enums this counts as a raise for. - // // that is to say: if you get an AAA, that also counts as a raise - // // for an AA, etc. - - // // however, this should only extend down to whatever the previous - // // best enum on this chart was. - // // luckily, we can calculate this by checking what the grade is now - // // and taking away the delta. That gets us the original. - // // If this is less than the clearGradeIndex, use that instead. - // const originalIndex = - // // @ts-expect-error silly cheaty enum access - // bestEnumOnThisChart.scoreData.enumIndexes[metric]! - gradeDelta + 1; - - // // lowerbound the original grade at the minimum-relevant enum. - // const minimumGrade = Math.max( - // conf.values.indexOf(conf.minimumRelevantValue), - // originalIndex, - // ); - - // for (const grade of conf.values.slice( - // minimumGrade, - // // @ts-expect-error silly cheaty enum access (2) - // bestEnumOnThisChart.scoreData.enumIndexes[metric]! + 1, - // )) { - // AddToSetInRecord(grade, metricDist, chartID); - // } - // } - - // for (const [enumVal, raisedCharts] of Object.entries(metricDist)) { - // raiseInfo.push({ - // folder, - - // previousCount: previousDist[enumVal] ?? 0, - - // raisedCharts: Array.from(raisedCharts), - // type: metric, - // value: enumVal, - // totalCharts: chartIDs.length, - // }); - // } - // } - - // // now that we know what we've raised, and what was there at the start - // // we can push that. - - // // now that we know what we've raised, and what was there at the start - // // we can push that. - // }), - // ); - - return res.status(200).json({ - success: true, - description: `Retrieved folder raises.`, - body: raiseInfo, - }); -}); - -interface ModifiableSessionProps { - name?: string; - desc?: string | null; - highlight?: boolean; -} +API_V1_ROUTER.add( + "GET /sessions/:sessionID/folder-raises", + withSession, + // Folder raise calculation is currently not implemented. + () => success("Retrieved folder raises.", []), +); /** * Modifies a session. @@ -309,82 +58,42 @@ interface ModifiableSessionProps { * * @name PATCH /api/v1/sessions/:sessionID */ -router.patch( - "/", - RequireOwnershipOfSession, - RequirePermissions("customise_session"), - prValidate( - { - name: p.optional(p.isBoundedString(3, 80)), - desc: optNull(p.isBoundedString(3, 120)), - highlight: "*boolean", - }, - {}, - { allowExcessKeys: true }, - ), - async (req, res) => { - const session = GetTachiData(req, "sessionDoc"); +API_V1_ROUTER.add( + "PATCH /sessions/:sessionID", + withSession, + withSessionOwner, + async ({ input, ctx, req }) => { + const { sessionDoc: session } = ctx; - const updateExp: ModifiableSessionProps = {}; + const hasChanges = + input.name !== undefined || input.desc !== undefined || input.highlight !== undefined; - const body = req.safeBody as { - desc?: string | null; - highlight?: boolean; - name?: string; - }; - - if (body.name) { - updateExp.name = body.name; - } - - if (body.desc !== undefined) { - updateExp.desc = body.desc; - } - - if (typeof body.highlight === "boolean") { - updateExp.highlight = body.highlight; - } - - if (Object.keys(updateExp).length === 0) { - return res.status(400).json({ - success: false, - description: `This request modifies nothing about this session.`, - }); + if (!hasChanges) { + throw new ExpectedErr(400, "This request modifies nothing about this session."); } const auth = req[SYMBOL_TACHI_API_AUTH]; if (auth.userID === null) { - return res.status(401).json({ - success: false, - description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, - }); + throw new ExpectedErr(401, "Authentication is required."); } const user = await GetUserWithID(auth.userID); if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, - }); + throw new ExpectedErr(401, "Authentication is required."); } - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + await ACTION_UpdateSession( + { acct: { id: user.id, username: user.username }, ip: req.ip }, + { + desc: input.desc, + highlight: input.highlight, + name: input.name, + sessionID: session.sessionID, + }, + ); - await ACTION_UpdateSession(taker, { - sessionID: session.sessionID, - name: updateExp.name, - desc: updateExp.desc, - highlight: updateExp.highlight, - }); - - return res.status(200).json({ - success: true, - description: `Updated Session.`, - body: {}, - }); + return success("Updated Session.", {}); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/sessions/router.ts b/typescript/server/src/server/router/api/v1/sessions/router.ts index 674cb8723..18a2b9655 100644 --- a/typescript/server/src/server/router/api/v1/sessions/router.ts +++ b/typescript/server/src/server/router/api/v1/sessions/router.ts @@ -1,16 +1,7 @@ -import { Router } from "express"; - -import sessionIDRouter from "./_sessionID/router"; - -const router: Router = Router({ mergeParams: true }); - /** * nothing, yet. * * @name GET /api/v1/sessions */ // router.get("/", async (req, res) => {}); - -router.use("/:sessionID", sessionIDRouter); - -export default router; +export {}; diff --git a/typescript/server/src/server/router/api/v1/spec.ts b/typescript/server/src/server/router/api/v1/spec.ts new file mode 100644 index 000000000..cfbf768a5 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/spec.ts @@ -0,0 +1,1945 @@ +import type { GetRecentActivity } from "#lib/activity/activity"; +import type { ActionRow } from "#lib/admin/admin-queries"; +import type { SessionCalendarDocument } from "#lib/db-formats/session"; +import type { GetEnumDistForFolder } from "#lib/folders/folders"; +import type { AnyRouterSpec } from "#lib/router/typed-router"; +import type { TachiServerConfig } from "#lib/setup/config"; +import type { EvaluateUsersStatsShowcase } from "#lib/showcase/get-stats"; +import type { GitCommit } from "#utils/git"; +import type { + APITokenDocument, + CGCardInfo, + ChartDocument, + FervidexSettingsDocument, + FolderDocument, + GameConfig, + GameGroupConfig, + GoalDocument, + GoalSubscriptionDocument, + ImportDocument, + ImportTrackerDocument, + integer, + InviteCodeDocument, + KaiAuthDocument, + KsHookSettingsDocument, + MytCardInfo, + NotificationDocument, + PBScoreDocument, + QuestDocument, + QuestlineDocument, + QuestSubscriptionDocument, + RecentlyViewedFolderDocument, + ScoreDocument, + SessionDocument, + SessionScoreInfo, + SongDocument, + TableDocument, + TachiAPIClientDocument, + UGPTSettingsDocument, + UserDocument, + UserGameStats, + UserGameStatsSnapshotDocument, + UserSettingsDocument, +} from "tachi-common"; +import type { CronTask, CronTaskExecution, JobQueue } from "tachi-db"; + +import { z } from "zod"; + +type TachiInstanceConfig = TachiServerConfig["TACHI_CONFIG"]; +type ActivityPayload = Awaited>; +type FolderEnumStatRow = Awaited>; +type ShowcaseEvalRow = Awaited>[number]; +type UserGameStatsWithRanking = { + __rankingData?: Record; +} & UserGameStats; +type SearchUserRow = { __isRival: boolean } & UserDocument; +type UserRankingPosition = { outOf: integer; ranking: integer }; +type UGPTHistorySnapshot = Omit; +type AdminJobQueueFilters = { job_kind?: string; scope?: string; status?: number }; +type AdminActionFilters = { kind?: string; username?: string }; + +// ─── Output schema helpers ──────────────────────────────────────────────────── +// Opaque MongoDB document — validates the value is a record but does not +// constrain individual fields. Pass `T` so `z.infer` and handler output types +// match the API document type (runtime validation stays shallow). +function doc>(): z.ZodType { + return z.record(z.string(), z.unknown()) as z.ZodType; +} + +function docArray>(): z.ZodType { + return z.array(z.record(z.string(), z.unknown())) as z.ZodType; +} + +// Empty success body — mutation endpoints that carry no return payload. +const empty = z.object({}); + +export const API_V1_SPEC = { + // ──────────────────────────────────────────────── + // Status + // ──────────────────────────────────────────────── + + "GET /status": { + description: "Server status, version, auth identity, and optional echo.", + input: z.object({ echo: z.string().optional() }), + output: z.strictObject({ + serverTime: z.number(), + startTime: z.number(), + version: z.string(), + whoami: z.number().nullable(), + permissions: z.array(z.string()), + echo: z.string().optional(), + }), + }, + + "POST /status": { + description: "Same as GET /status but via POST for clients that cannot send query params.", + input: z.object({ echo: z.string().optional() }), + output: z.strictObject({ + serverTime: z.number(), + startTime: z.number(), + version: z.string(), + whoami: z.number().nullable(), + permissions: z.array(z.string()), + echo: z.string().optional(), + }), + }, + + // ──────────────────────────────────────────────── + // Auth + // ──────────────────────────────────────────────── + + "POST /auth/login": { + description: "Session login with username + password.", + input: z.object({ + username: z.string(), + "!password": z.string(), + captcha: z.string().optional(), + }), + output: z.strictObject({ userID: z.number() }), + }, + + "POST /auth/register": { + description: "Register a new account.", + input: z.object({ + username: z.string(), + "!password": z.string(), + email: z.string(), + inviteCode: z.string().optional(), + captcha: z.string().optional(), + }), + output: doc(), + }, + + "POST /auth/verify-email": { + description: "Verify email address with a code.", + input: z.object({ code: z.string() }), + output: empty, + }, + + "POST /auth/resend-verify-email": { + description: "Resend email verification code.", + input: z.object({}), + output: empty, + }, + + "POST /auth/logout": { + description: "Destroy the current session.", + input: z.object({}), + output: empty, + }, + + "POST /auth/forgot-password": { + description: "Request a password reset email.", + input: z.object({ email: z.string() }), + output: empty, + }, + + "POST /auth/reset-password": { + description: "Reset password using a one-time code.", + input: z.object({ code: z.string(), "!password": z.string() }), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Config + // ──────────────────────────────────────────────── + + "GET /config": { + description: "Returns this instance's TachiConfig.", + input: z.object({}), + output: doc(), + }, + + "GET /config/beatoraja-queue-size": { + description: "Returns the beatoraja import queue size (Bokutachi only).", + input: z.object({}), + output: z.number(), + }, + + "GET /config/max-rivals": { + description: "Returns the maximum number of rivals a user may have.", + input: z.object({}), + output: z.number(), + }, + + // ──────────────────────────────────────────────── + // Activity (global) + // ──────────────────────────────────────────────── + + "GET /activity": { + description: "Global recent activity across all games.", + input: z.object({ startTime: z.coerce.number().optional() }), + output: z.record(z.string(), doc()), + }, + + // ──────────────────────────────────────────────── + // Search + // ──────────────────────────────────────────────── + + "GET /search": { + description: "Search users, charts, and folders.", + input: z.object({ search: z.string() }), + output: z.strictObject({ + charts: docArray(), + folders: docArray(), + users: docArray(), + }), + }, + + "GET /search/chart-hash": { + description: "Search charts by hash.", + input: z.object({ search: z.string() }), + output: z.strictObject({ charts: docArray() }), + }, + + // ──────────────────────────────────────────────── + // Users (top-level) + // ──────────────────────────────────────────────── + + "GET /users": { + description: "List users, optionally filtered by search query or online status.", + input: z.object({ + search: z.string().optional(), + online: z.string().optional(), + }), + output: docArray(), + }, + + // ──────────────────────────────────────────────── + // Users /:userID + // ──────────────────────────────────────────────── + + "GET /users/:userID": { + description: "Retrieve the public profile of a user.", + input: z.object({}), + output: doc(), + }, + + "PATCH /users/:userID": { + description: "Update the authenticated user's public profile fields.", + input: z.object({ + about: z.string().optional(), + status: z.string().nullable().optional(), + discord: z.string().nullable().optional(), + twitter: z.string().nullable().optional(), + github: z.string().nullable().optional(), + steam: z.string().nullable().optional(), + youtube: z.string().nullable().optional(), + twitch: z.string().nullable().optional(), + }), + output: doc(), + }, + + "GET /users/:userID/game-stats": { + description: "All game stats and rankings for a user.", + input: z.object({}), + output: docArray(), + }, + + "GET /users/:userID/recent-summary": { + description: "Dashboard summary of recent activity for a user.", + input: z.object({}), + output: z.strictObject({ + recentPlaycount: z.number(), + recentSessions: docArray(), + recentFolders: docArray(), + recentFolderStats: docArray(), + recentAchievedGoals: docArray(), + recentImprovedGoals: docArray(), + recentGoals: docArray(), + }), + }, + + "GET /users/:userID/is-email-verified": { + description: "Whether the authenticated user's email is verified (self only).", + input: z.object({}), + output: z.boolean(), + }, + + "GET /users/:userID/email": { + description: "The authenticated user's email address (self only).", + input: z.object({}), + output: z.string(), + }, + + "POST /users/:userID/change-email": { + description: "Change the authenticated user's email address.", + input: z.object({ email: z.string(), "!password": z.string() }), + output: z.null(), + }, + + "POST /users/:userID/change-password": { + description: "Change the authenticated user's password.", + input: z.object({ "!password": z.string(), "!oldPassword": z.string() }), + output: empty, + }, + + "POST /users/:userID/change-username": { + description: "Change the authenticated user's username.", + input: z.object({ newUsername: z.string(), "!password": z.string() }), + output: doc(), + }, + + "GET /users/:userID/last-username-change": { + description: "Date of last username change, to enforce change rate-limiting.", + input: z.object({}), + output: z.strictObject({ + canChange: z.boolean(), + nextChange: z.number().nullable(), + }), + }, + + "GET /users/:userID/recent-imports": { + description: "Recent import types used by the user.", + input: z.object({}), + output: z.array( + z.strictObject({ + importType: z.string(), + count: z.number(), + }), + ), + }, + + "GET /users/:userID/stats": { + description: "Total score and session counts for the user.", + input: z.object({}), + output: z.strictObject({ + scores: z.number(), + sessions: z.number(), + }), + }, + + "GET /users/:userID/activity": { + description: "Per-GPT activity feed for the user.", + input: z.object({ + startTime: z.coerce.number().optional(), + includeRivals: z.string().optional(), + includeFollowers: z.string().optional(), + }), + output: z.record(z.string(), doc()), + }, + + // ──────────────────────────────────────────────── + // Users /:userID/pfp and /banner + // (PUT variants handled by separate Express+Multer routes) + // ──────────────────────────────────────────────── + + "GET /users/:userID/pfp": { + description: "Redirect to the user's profile picture in the CDN.", + input: z.object({}), + output: empty, + }, + + "DELETE /users/:userID/pfp": { + description: "Remove the authenticated user's profile picture.", + input: z.object({}), + output: empty, + }, + + "GET /users/:userID/banner": { + description: "Redirect to the user's banner image in the CDN.", + input: z.object({}), + output: empty, + }, + + "DELETE /users/:userID/banner": { + description: "Remove the authenticated user's banner image.", + input: z.object({}), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Users /:userID/settings + // ──────────────────────────────────────────────── + + "GET /users/:userID/settings": { + description: "Public account settings for the user.", + input: z.object({}), + output: doc(), + }, + + "PATCH /users/:userID/settings": { + description: "Update the authenticated user's account settings.", + input: z.object({ + invisible: z.boolean().optional(), + developerMode: z.boolean().optional(), + contentiousContent: z.boolean().optional(), + advancedMode: z.boolean().optional(), + deletableScores: z.boolean().optional(), + }), + output: doc(), + }, + + // ──────────────────────────────────────────────── + // Users /:userID/api-tokens + // ──────────────────────────────────────────────── + + "GET /users/:userID/api-tokens": { + description: "List all API tokens belonging to the authenticated user.", + input: z.object({}), + output: docArray(), + }, + + "POST /users/:userID/api-tokens/create": { + description: "Create a new API token.", + input: z.object({ + clientID: z.string().optional(), + identifier: z.string().optional(), + permissions: z.record(z.string(), z.boolean()).optional(), + }), + output: doc(), + }, + + "DELETE /users/:userID/api-tokens/:token": { + description: "Delete a specific API token.", + input: z.object({}), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Users /:userID/invites + // ──────────────────────────────────────────────── + + "GET /users/:userID/invites": { + description: "List invite codes belonging to the authenticated user.", + input: z.object({}), + output: z.strictObject({ + invites: docArray(), + consumers: docArray(), + }), + }, + + "GET /users/:userID/invites/limit": { + description: "The invite quota for the authenticated user.", + input: z.object({}), + output: z.strictObject({ invites: z.number(), limit: z.number() }), + }, + + "POST /users/:userID/invites/create": { + description: "Create a new invite code.", + input: z.object({}), + output: doc(), + }, + + // ──────────────────────────────────────────────── + // Users /:userID/following + // ──────────────────────────────────────────────── + + "GET /users/:userID/following": { + description: "List users that this user is following.", + input: z.object({}), + output: z.strictObject({ friends: docArray() }), + }, + + "POST /users/:userID/following/add": { + description: "Follow a user.", + input: z.object({ userID: z.number() }), + output: empty, + }, + + "POST /users/:userID/following/remove": { + description: "Unfollow a user.", + input: z.object({ userID: z.number() }), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Users /:userID/notifications + // ──────────────────────────────────────────────── + + "GET /users/:userID/notifications": { + description: "List notifications for the authenticated user.", + input: z.object({}), + output: docArray(), + }, + + "POST /users/:userID/notifications/mark-all-read": { + description: "Mark all notifications as read.", + input: z.object({}), + output: empty, + }, + + "POST /users/:userID/notifications/delete-all": { + description: "Delete all notifications.", + input: z.object({}), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Users /:userID/sessions + // ──────────────────────────────────────────────── + + "GET /users/:userID/sessions/calendar": { + description: "Lightweight session calendar data across all games.", + input: z.object({}), + output: docArray(), + }, + + // ──────────────────────────────────────────────── + // Users /:userID/imports + // ──────────────────────────────────────────────── + + "GET /users/:userID/imports": { + description: "List recent imports for the user.", + input: z.object({ + importType: z.string().optional(), + userIntent: z.string().optional(), + }), + output: docArray(), + }, + + "GET /users/:userID/imports/failed": { + description: "List failed import trackers for the user.", + input: z.object({ + importType: z.string().optional(), + userIntent: z.string().optional(), + }), + output: docArray(), + }, + + // ──────────────────────────────────────────────── + // Users /:userID/integrations/myt (Kamaitachi only) + // ──────────────────────────────────────────────── + + "GET /users/:userID/integrations/myt": { + description: "Get MYT card info for the user.", + input: z.object({}), + output: doc(), + }, + + "PUT /users/:userID/integrations/myt": { + description: "Set MYT card access code.", + input: z.object({ cardAccessCode: z.string() }), + output: empty, + }, + + "DELETE /users/:userID/integrations/myt": { + description: "Clear MYT card access code.", + input: z.object({}), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Users /:userID/integrations/kai/:kaiType (Kamaitachi only) + // ──────────────────────────────────────────────── + + "GET /users/:userID/integrations/kai/:kaiType": { + description: "Get Kai auth status for the given Kai service.", + input: z.object({}), + output: doc(), + }, + + "DELETE /users/:userID/integrations/kai/:kaiType": { + description: "Revoke Kai OAuth token for the given service.", + input: z.object({}), + output: empty, + }, + + "POST /users/:userID/integrations/kai/:kaiType/oauth2callback": { + description: "Handle OAuth callback and store Kai token.", + input: z.object({ code: z.string() }), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Users /:userID/integrations/cg/:cgType + // ──────────────────────────────────────────────── + + "GET /users/:userID/integrations/cg/:cgType": { + description: "Get CG card info for the given CG type.", + input: z.object({}), + output: doc(), + }, + + "PUT /users/:userID/integrations/cg/:cgType": { + description: "Set CG card credentials.", + input: z.object({ cardID: z.string(), pin: z.string() }), + output: empty, + }, + + "DELETE /users/:userID/integrations/cg/:cgType": { + description: "Clear CG card credentials.", + input: z.object({}), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Users /:userID/integrations/fervidex (Kamaitachi only) + // ──────────────────────────────────────────────── + + "GET /users/:userID/integrations/fervidex/settings": { + description: "Get fervidex integration settings.", + input: z.object({}), + output: doc(), + }, + + "PATCH /users/:userID/integrations/fervidex/settings": { + description: "Update fervidex integration settings.", + input: z.object({ + cards: z.array(z.string()).nullable().optional(), + forceStaticImport: z.boolean().nullable().optional(), + }), + output: doc(), + }, + + // ──────────────────────────────────────────────── + // Users /:userID/integrations/kshook-sv6c (Kamaitachi only) + // ──────────────────────────────────────────────── + + "GET /users/:userID/integrations/kshook-sv6c/settings": { + description: "Get kshook-sv6c integration settings.", + input: z.object({}), + output: doc(), + }, + + "PATCH /users/:userID/integrations/kshook-sv6c/settings": { + description: "Update kshook-sv6c integration settings.", + input: z.object({ forceStaticImport: z.boolean().optional() }), + output: doc(), + }, + + // ──────────────────────────────────────────────── + // Users /:userID/games/:game (UGPT) + // ──────────────────────────────────────────────── + + // User games — literal gameGroup segments (game-specific) + "GET /users/:userID/games/:game/custom-tables/:tableUrlName": { + description: + "HTML stub for a user-specific custom BMS table; things like their rivals, etc.", + input: z.object({}), + output: z.object({}), + }, + + "GET /users/:userID/games/:game/custom-tables/:tableUrlName/header.json": { + description: "header.json for a user-specific custom BMS table.", + input: z.object({}), + output: z.object({}), + }, + + "GET /users/:userID/games/:game/custom-tables/:tableUrlName/body.json": { + description: "body.json for a user-specific custom BMS table.", + input: z.object({}), + output: z.object({}), + }, + + "GET /users/:userID/games/:game/best-score/:checksum": { + description: "User PB on a BMS chart identified by MD5 or SHA256 hash.", + input: z.object({}), + output: z.nullable(doc()), + }, + + "GET /users/:userID/games/:game/playlists/:playlistID": { + description: "IIDX user-specific playlist - things like their rivals, etc..", + input: z.object({}), + output: z.unknown(), + }, + + "GET /users/:userID/games/:game/jubility": { + description: "PBs contributing to this user's Jubility ranking.", + input: z.object({}), + output: z.strictObject({ + songs: docArray(), + charts: docArray(), + pickUp: docArray(), + other: docArray(), + }), + }, + + "GET /users/:userID/games/:game": { + description: "UGPT overview (game stats, rankings).", + input: z.object({}), + output: z.strictObject({ + gameStats: doc(), + totalScores: z.number(), + playtime: z.number(), + rankingData: z.record(z.string(), doc()), + firstScore: z.nullable(doc()), + mostRecentScore: z.nullable(doc()), + }), + }, + + "GET /users/:userID/games/:game/history": { + description: "Stats snapshots over time.", + input: z.object({ + duration: z.enum(["week", "month", "3mo", "year", "all"]).optional(), + }), + output: docArray(), + }, + + "GET /users/:userID/games/:game/most-played": { + description: "Most played charts for the user on this GPT.", + input: z.object({}), + output: z.strictObject({ + charts: docArray(), + songs: docArray(), + pbs: docArray(), + }), + }, + + "GET /users/:userID/games/:game/leaderboard-adjacent": { + description: "Nearby players on the profile leaderboard.", + input: z.object({ alg: z.string().optional() }), + output: z.strictObject({ + thisUsersStats: doc(), + thisUsersRanking: doc(), + above: docArray(), + below: docArray(), + users: docArray(), + }), + }, + + "GET /users/:userID/games/:game/activity": { + description: "Activity feed for this user+GPT.", + input: z.object({ + sessions: z.coerce.number().min(10).max(100).optional(), + startTime: z.coerce.number().optional(), + }), + output: z.record(z.string(), doc()), + }, + + "DELETE /users/:userID/games/:game": { + description: "Delete all data for the user on this game+playtype.", + input: z.object({ "!password": z.string() }), + output: empty, + }, + + // ──────────────────────────────────────────────── + // UGPT PBs + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/pbs": { + description: "Search user PBs for the given game.", + input: z.object({ search: z.string() }), + output: z.strictObject({ + pbs: docArray(), + charts: docArray(), + songs: docArray(), + }), + }, + + "GET /users/:userID/games/:game/pbs/all": { + description: "All user PBs on primary charts (expensive).", + input: z.object({}), + output: z.strictObject({ + pbs: docArray(), + charts: docArray(), + songs: docArray(), + }), + }, + + "GET /users/:userID/games/:game/pbs/best": { + description: "Top 100 PBs sorted by algorithm.", + input: z.object({ alg: z.string().optional() }), + output: z.strictObject({ + pbs: docArray(), + charts: docArray(), + songs: docArray(), + }), + }, + + "POST /users/:userID/games/:game/pbs/resolve": { + description: "Resolve a chart using the tachi resolver engine and return the user's PB.", + input: z.object({ + matchType: z.string(), + identifier: z.string(), + version: z.string().optional(), + artist: z.string().optional(), + difficulty: z.string().optional(), + }), + output: z.strictObject({ + chart: doc(), + song: doc(), + pb: doc(), + }), + }, + + "GET /users/:userID/games/:game/pbs/:chartID": { + description: "User's PB on a specific chart.", + input: z.object({ getComposition: z.string().optional() }), + output: z.strictObject({ + chart: doc(), + pb: doc(), + scores: docArray().optional(), + }), + }, + + "GET /users/:userID/games/:game/pbs/:chartID/rivals": { + description: "User's PB on a chart plus all rival PBs.", + input: z.object({}), + output: z.strictObject({ + pbs: docArray(), + rivals: docArray(), + }), + }, + + "GET /users/:userID/games/:game/pbs/:chartID/leaderboard-adjacent": { + description: "User's PB plus ~5 players above/below on the chart leaderboard.", + input: z.object({}), + output: z.strictObject({ + chart: doc(), + pb: doc(), + adjacentAbove: docArray(), + adjacentBelow: docArray(), + users: docArray(), + }), + }, + + // ──────────────────────────────────────────────── + // UGPT Scores + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/scores": { + description: "Search user scores.", + input: z.object({ search: z.string() }), + output: z.strictObject({ + scores: docArray(), + charts: docArray(), + songs: docArray(), + }), + }, + + "GET /users/:userID/games/:game/scores/all": { + description: "All user scores (expensive).", + input: z.object({}), + output: z.strictObject({ + scores: docArray(), + charts: docArray(), + songs: docArray(), + }), + }, + + "GET /users/:userID/games/:game/scores/recent": { + description: "Most recent scores.", + input: z.object({}), + output: z.strictObject({ + scores: docArray(), + charts: docArray(), + songs: docArray(), + }), + }, + + "GET /users/:userID/games/:game/scores/:chartID": { + description: "All user scores on a specific chart.", + input: z.object({}), + output: docArray(), + }, + + // ──────────────────────────────────────────────── + // UGPT Sessions + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/sessions": { + description: "Search user sessions.", + input: z.object({ search: z.string() }), + output: docArray(), + }, + + "GET /users/:userID/games/:game/sessions/best": { + description: "Best sessions sorted by algorithm.", + input: z.object({ alg: z.string().optional() }), + output: docArray(), + }, + + "GET /users/:userID/games/:game/sessions/highlighted": { + description: "Highlighted sessions.", + input: z.object({}), + output: docArray(), + }, + + "GET /users/:userID/games/:game/sessions/recent": { + description: "Most recent sessions.", + input: z.object({}), + output: docArray(), + }, + + "GET /users/:userID/games/:game/sessions/last": { + description: "Single most recent session.", + input: z.object({}), + output: z.strictObject({ + session: doc(), + scoreInfo: docArray(), + }), + }, + + "GET /users/:userID/games/:game/sessions/calendar": { + description: "Minimal session data for calendar display.", + input: z.object({}), + output: docArray(), + }, + + // ──────────────────────────────────────────────── + // UGPT Tables + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/tables/:tableID": { + description: "User's enum-distribution stats across folders in a table.", + input: z.object({}), + output: z.strictObject({ + table: doc(), + folders: docArray(), + stats: docArray(), + }), + }, + + // ──────────────────────────────────────────────── + // UGPT Showcase + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/showcase": { + description: "Evaluate configured showcase stats for the user.", + input: z.object({ projectUser: z.string().optional() }), + output: docArray(), + }, + + "GET /users/:userID/games/:game/showcase/custom": { + description: "Evaluate a single custom folder or chart stat.", + input: z.object({ + mode: z.enum(["folder", "chart"]), + metric: z.string().optional(), + folderSlug: z.string().optional(), + gte: z.coerce.number().optional(), + chartID: z.string().optional(), + }), + output: doc(), + }, + + "PUT /users/:userID/games/:game/showcase": { + description: "Replace the user's stat showcase configuration.", + input: z.object({ + showcase: z.array(z.unknown()).max(6), + }), + output: z.nullable(doc()), + }, + + // ──────────────────────────────────────────────── + // UGPT Settings + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/settings": { + description: "Get this user's UGPT settings.", + input: z.object({}), + output: z.nullable(doc()), + }, + + "PATCH /users/:userID/games/:game/settings": { + description: "Patch UGPT settings preferences.", + input: z.object({ + preferredScoreAlg: z.string().nullable().optional(), + preferredSessionAlg: z.string().nullable().optional(), + preferredProfileAlg: z.string().nullable().optional(), + defaultTable: z.string().nullable().optional(), + preferredRanking: z.string().nullable().optional(), + gameSpecific: z.record(z.string(), z.unknown()).optional(), + preferredDefaultEnum: z.string().nullable().optional(), + }), + output: doc(), + }, + + // ──────────────────────────────────────────────── + // UGPT Folders + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/folders": { + description: "Search folders with user stats.", + input: z.object({ + search: z.string(), + inactive: z.string().optional(), + }), + output: z.strictObject({ + folders: docArray(), + stats: docArray(), + }), + }, + + "GET /users/:userID/games/:game/folders/recent": { + description: "Recently viewed folders for the user.", + input: z.object({}), + output: z.strictObject({ + folders: docArray(), + stats: docArray(), + views: docArray(), + }), + }, + + "GET /users/:userID/games/:game/folders/:folderSlug": { + description: "User's PBs on charts in a folder.", + input: z.object({}), + output: z.strictObject({ + folder: doc(), + charts: docArray(), + songs: docArray(), + stats: z.unknown(), + }), + }, + + "GET /users/:userID/games/:game/folders/:folderSlug/stats": { + description: "Aggregated stats for the user on a folder.", + input: z.object({}), + output: doc<{ folder: FolderDocument; stats: FolderEnumStatRow }>(), + }, + + "POST /users/:userID/games/:game/folders/:folderSlug/viewed": { + description: "Record that the user viewed this folder.", + input: z.object({}), + output: empty, + }, + + "GET /users/:userID/games/:game/folders/:folderSlug/timeline": { + description: "Timeline of score improvements on folder charts by criteria.", + input: z.object({ + criteriaType: z.string(), + criteriaValue: z.string(), + }), + output: docArray(), + }, + + // ──────────────────────────────────────────────── + // UGPT Targets + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/targets/recently-achieved": { + description: "Recently achieved goals and quests.", + input: z.object({}), + output: z.strictObject({ + goals: docArray(), + goalSubs: docArray(), + quests: docArray(), + questSubs: docArray(), + user: doc(), + }), + }, + + "GET /users/:userID/games/:game/targets/recently-raised": { + description: "Recently interacted goals and quests.", + input: z.object({}), + output: z.strictObject({ + goals: docArray(), + goalSubs: docArray(), + quests: docArray(), + questSubs: docArray(), + user: doc(), + }), + }, + + "GET /users/:userID/games/:game/targets/on-chart/:chartID": { + description: "Goals and quests related to a specific chart.", + input: z.object({}), + output: z.strictObject({ + goals: docArray(), + goalSubs: docArray(), + quests: docArray(), + questSubs: docArray(), + }), + }, + + "GET /users/:userID/games/:game/targets/on-folder/:folderSlug": { + description: "Goals and quests related to a specific folder.", + input: z.object({}), + output: z.strictObject({ + folder: doc(), + goals: docArray(), + goalSubs: docArray(), + }), + }, + + "GET /users/:userID/games/:game/targets/all-subs": { + description: "All goal and quest subscriptions for the user on this GPT.", + input: z.object({}), + output: z.strictObject({ + goalSubs: docArray(), + goals: docArray(), + questSubs: docArray(), + quests: docArray(), + }), + }, + + // UGPT Goals + "GET /users/:userID/games/:game/targets/goals": { + description: "User's goal subscriptions with related quest info.", + input: z.object({}), + output: z.strictObject({ + goalSubs: docArray(), + goals: docArray(), + questSubs: docArray(), + quests: docArray(), + }), + }, + + "POST /users/:userID/games/:game/targets/goals/add-goal": { + description: "Create or subscribe to a goal.", + input: z.object({ + criteria: z.unknown(), + charts: z.unknown(), + }), + output: z.strictObject({ + goal: doc(), + goalSub: doc(), + }), + }, + + "GET /users/:userID/games/:game/targets/goals/:goalID": { + description: "Goal subscription detail.", + input: z.object({}), + output: z.strictObject({ + goal: doc(), + goalSub: doc(), + quests: docArray(), + user: doc(), + }), + }, + + "DELETE /users/:userID/games/:game/targets/goals/:goalID": { + description: "Unsubscribe from a goal.", + input: z.object({}), + output: empty, + }, + + // UGPT Quests + "GET /users/:userID/games/:game/targets/quests": { + description: "Subscribed quests for the user.", + input: z.object({}), + output: z.strictObject({ + questSubs: docArray(), + quests: docArray(), + goals: z.record(z.string(), docArray()), + }), + }, + + "GET /users/:userID/games/:game/targets/quests/:questID": { + description: "Quest progress detail.", + input: z.object({}), + output: z.strictObject({ + quest: doc(), + questSub: doc(), + goals: docArray(), + results: docArray(), + }), + }, + + "PUT /users/:userID/games/:game/targets/quests/:questID": { + description: "Subscribe to a quest.", + input: z.object({}), + output: z.strictObject({ + quest: doc(), + questSub: doc(), + goals: docArray(), + goalResults: docArray(), + }), + }, + + "DELETE /users/:userID/games/:game/targets/quests/:questID": { + description: "Unsubscribe from a quest.", + input: z.object({}), + output: z.strictObject({ quest: doc() }), + }, + + // ──────────────────────────────────────────────── + // UGPT Rivals + // ──────────────────────────────────────────────── + + "GET /users/:userID/games/:game/rivals": { + description: "List the user's rivals.", + input: z.object({}), + output: docArray(), + }, + + "PUT /users/:userID/games/:game/rivals": { + description: "Replace the user's rival list.", + input: z.object({ rivalIDs: z.array(z.number()) }), + output: empty, + }, + + "GET /users/:userID/games/:game/rivals/challengers": { + description: "Users who have this user as a rival.", + input: z.object({}), + output: docArray(), + }, + + "GET /users/:userID/games/:game/rivals/pb-leaderboard": { + description: "PB leaderboard including the user and their rivals.", + input: z.object({ alg: z.string().optional() }), + output: z.strictObject({ + pbs: docArray(), + charts: docArray(), + songs: docArray(), + users: docArray(), + }), + }, + + "GET /users/:userID/games/:game/rivals/activity": { + description: "Activity feed for rivals.", + input: z.object({ + sessions: z.coerce.number().min(10).max(100).optional(), + startTime: z.coerce.number().optional(), + }), + output: z.record(z.string(), doc()), + }, + + // ──────────────────────────────────────────────── + // OAuth + // ──────────────────────────────────────────────── + + "POST /oauth/token": { + description: "Exchange an OAuth2 authorization code for an API token.", + input: z.object({ + client_id: z.string(), + client_secret: z.string(), + grant_type: z.literal("authorization_code"), + redirect_uri: z.string(), + code: z.string(), + }), + output: z.strictObject({ + userID: z.number(), + token: z.string(), + identifier: z.string().nullable(), + permissions: z.record(z.string(), z.boolean()), + fromAPIClient: z.string().nullable(), + }), + }, + + "POST /oauth/create-code": { + description: "Create an OAuth2 authorization code (session auth required).", + input: z.object({}), + output: z.strictObject({ + code: z.string(), + userID: z.number(), + createdOn: z.number(), + }), + }, + + // ──────────────────────────────────────────────── + // Games + // ──────────────────────────────────────────────── + + "GET /games": { + description: "List all supported games and their configurations.", + input: z.object({}), + output: z.strictObject({ + configs: z.record(z.string(), doc()), + supportedGames: z.array(z.string()), + }), + }, + + "GET /games/:gameGroup": { + description: "Configuration for a specific game group.", + input: z.object({}), + output: doc(), + }, + + "GET /games/:game": { + description: "Info and counts for a specific game+playtype.", + input: z.object({}), + output: z.strictObject({ + chartCount: z.number(), + playerCount: z.number(), + scoreCount: z.number(), + config: doc(), + }), + }, + + // Games — BMS / IIDX literal paths (game-specific) + "GET /games/:game/custom-tables": { + description: "List custom BMS tables available.", + input: z.object({}), + output: z.array( + z.strictObject({ + description: z.string(), + forSpecificUser: z.boolean(), + symbol: z.string(), + tableName: z.string(), + urlName: z.string(), + }), + ), + }, + + "GET /games/:game/custom-tables/:tableUrlName": { + description: "HTML stub for a custom BMS table (bmstable meta).", + input: z.object({}), + output: z.object({}), + }, + + "GET /games/:game/custom-tables/:tableUrlName/header.json": { + description: "header.json for a custom BMS table.", + input: z.object({}), + output: z.object({}), + }, + + "GET /games/:game/custom-tables/:tableUrlName/body.json": { + description: "body.json for a custom BMS table.", + input: z.object({}), + output: z.object({}), + }, + + "GET /games/:game/sieglinde-charts": { + description: "Charts with Sieglinde ratings for this BMS playtype.", + input: z.object({}), + output: z.strictObject({ + songs: docArray(), + charts: docArray(), + }), + }, + + "GET /games/:game/playlists": { + description: "List IIDX playlists available for SP or DP.", + input: z.object({}), + output: z.array( + z.strictObject({ + description: z.string(), + forSpecificUser: z.boolean().optional(), + playlistName: z.string(), + urlName: z.string(), + }), + ), + }, + + "GET /games/:game/playlists/:playlistID": { + description: "IIDX playlist data.", + input: z.object({}), + output: z.unknown(), + }, + + "GET /games/:game/leaderboard": { + description: "Profile leaderboard for a GPT.", + input: z.object({ + alg: z.string().optional(), + limit: z.coerce.number().max(500).optional(), + }), + output: z.strictObject({ + gameStats: docArray(), + users: docArray(), + }), + }, + + "GET /games/:game/pb-leaderboard": { + description: "PB-based leaderboard for a GPT.", + input: z.object({ + alg: z.string().optional(), + limit: z.coerce.number().max(50).optional(), + }), + output: z.strictObject({ + pbs: docArray(), + charts: docArray(), + songs: docArray(), + users: docArray(), + }), + }, + + "GET /games/:game/players": { + description: "Search for players who have played this GPT.", + input: z.object({ search: z.string() }), + output: docArray(), + }, + + "GET /games/:game/activity": { + description: "Activity feed for a GPT.", + input: z.object({ + sessions: z.coerce.number().min(10).max(100).optional(), + startTime: z.coerce.number().optional(), + }), + output: z.record(z.string(), doc()), + }, + + // Games Charts + "GET /games/:game/charts": { + description: "List or search charts for a GPT.", + input: z.object({ + search: z.string().optional(), + noIntelligentOmit: z.string().optional(), + requesterHasPlayed: z.string().optional(), + }), + output: z.strictObject({ + charts: docArray(), + songs: docArray(), + }), + }, + + "POST /games/:game/charts/resolve": { + description: "Resolve a chart using identifiers from an external source.", + input: z.object({ + matchType: z.string(), + identifier: z.string(), + version: z.string().optional(), + artist: z.string().optional(), + difficulty: z.string().optional(), + }), + output: z.strictObject({ + chart: doc(), + song: doc(), + }), + }, + + "GET /games/:game/charts/:chartID": { + description: "Chart details plus its song.", + input: z.object({}), + output: z.strictObject({ + chart: doc(), + song: doc(), + }), + }, + + "GET /games/:game/charts/:chartID/folders": { + description: "Folders that contain a specific chart.", + input: z.object({ inactive: z.string().optional() }), + output: docArray(), + }, + + "GET /games/:game/charts/:chartID/playcount": { + description: "Number of unique players on a chart.", + input: z.object({}), + output: z.strictObject({ count: z.number() }), + }, + + "GET /games/:game/charts/:chartID/pbs": { + description: "Top PBs on a chart by ranking page.", + input: z.object({ startRanking: z.coerce.number().optional() }), + output: z.strictObject({ + pbs: docArray(), + users: docArray(), + }), + }, + + "GET /games/:game/charts/:chartID/pbs/search": { + description: "Search PBs on a chart by username.", + input: z.object({ search: z.string() }), + output: z.strictObject({ + pbs: docArray(), + users: docArray(), + }), + }, + + // Games Songs + "GET /games/:game/songs/:songID": { + description: "Song document and all its charts for a GPT.", + input: z.object({}), + output: z.strictObject({ + song: doc(), + charts: docArray(), + }), + }, + + // Games Folders + "GET /games/:game/folders": { + description: "Search folders for a GPT.", + input: z.object({ + search: z.string(), + inactive: z.string().optional(), + }), + output: docArray(), + }, + + "GET /games/:game/folders/:folderSlug": { + description: "Folder with its songs and charts.", + input: z.object({}), + output: z.strictObject({ + folder: doc(), + charts: docArray(), + songs: docArray(), + }), + }, + + // Games Tables + "GET /games/:game/tables": { + description: "List tables for a GPT.", + input: z.object({ showInactive: z.string().optional() }), + output: docArray(), + }, + + "GET /games/:game/tables/:tableID": { + description: "Table document with folder list.", + input: z.object({}), + output: z.strictObject({ + table: doc(), + folders: docArray(), + }), + }, + + // Games Targets + "GET /games/:game/targets/recently-achieved": { + description: "Recently achieved goals/quests across all users for this GPT.", + input: z.object({}), + output: z.strictObject({ + goals: docArray(), + goalSubs: docArray(), + quests: docArray(), + questSubs: docArray(), + }), + }, + + "GET /games/:game/targets/recently-raised": { + description: "Recently interacted goals/quests across all users for this GPT.", + input: z.object({}), + output: z.strictObject({ + goals: docArray(), + goalSubs: docArray(), + quests: docArray(), + questSubs: docArray(), + }), + }, + + "GET /games/:game/targets/goals/popular": { + description: "Get the most popular goals for this GPT.", + input: z.object({}), + output: docArray(), + }, + + "POST /games/:game/targets/goals/format": { + description: "Format a goal description from its charts/criteria.", + input: z.object({ + charts: z.record(z.string(), z.unknown()), + criteria: z.record(z.string(), z.unknown()), + }), + output: z.string(), + }, + + "GET /games/:game/targets/goals/:goalID": { + description: "Retrieve information about this goal and who is subscribed to it.", + input: z.object({}), + output: z.strictObject({ + goal: doc(), + goalSubs: docArray(), + users: docArray(), + parentQuests: docArray(), + }), + }, + + "GET /games/:game/targets/quests": { + description: "Search quests for this GPT.", + input: z.object({ search: z.string() }), + output: z.strictObject({ + quests: docArray(), + goals: z.record(z.string(), docArray()), + }), + }, + + "GET /games/:game/targets/quests/:questID": { + description: "Retrieve information about this quest and who is subscribed to it.", + input: z.object({}), + output: z.strictObject({ + quest: doc(), + questSubs: docArray(), + users: docArray(), + goals: docArray(), + parentQuestlines: docArray(), + }), + }, + + "GET /games/:game/targets/questlines": { + description: "Retrieve all questlines for this GPT.", + input: z.object({}), + output: z.strictObject({ + questlines: docArray(), + standalone: docArray(), + standaloneGoals: z.record(z.string(), docArray()), + }), + }, + + "GET /games/:game/targets/questlines/:questlineID": { + description: "Retrieve a specific questline.", + input: z.object({}), + output: z.strictObject({ + questline: doc(), + quests: docArray(), + goals: z.record(z.string(), docArray()), + }), + }, + + // ──────────────────────────────────────────────── + // Scores + // ──────────────────────────────────────────────── + + "GET /scores/:scoreID": { + description: "Retrieve a score document.", + input: z.object({ getRelated: z.string().optional() }), + output: z.strictObject({ + score: doc(), + chart: doc().optional(), + song: doc().optional(), + user: doc().optional(), + }), + }, + + "PATCH /scores/:scoreID": { + description: "Edit a score's comment or highlight status.", + input: z.object({ + comment: z.string().nullable().optional(), + highlight: z.boolean().optional(), + }), + output: doc(), + }, + + "DELETE /scores/:scoreID": { + description: "Delete a score.", + input: z.object({ blacklist: z.boolean().optional() }), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Sessions + // ──────────────────────────────────────────────── + + "GET /sessions/:sessionID": { + description: "Retrieve a session and its related data.", + input: z.object({}), + output: z.strictObject({ + session: doc(), + scores: docArray(), + charts: docArray(), + songs: docArray(), + scoreInfo: docArray(), + user: doc(), + }), + }, + + "PATCH /sessions/:sessionID": { + description: "Update a session's name, description, or highlight status.", + input: z.object({ + name: z.string().optional(), + desc: z.string().nullable().optional(), + highlight: z.boolean().optional(), + }), + output: empty, + }, + + "GET /sessions/:sessionID/folder-raises": { + description: "Folder raise summary for a session.", + input: z.object({}), + output: z.array(z.unknown()), + }, + + // ──────────────────────────────────────────────── + // Imports (admin-facing view + revert) + // ──────────────────────────────────────────────── + + "GET /imports": { + description: "List recent imports (admin).", + input: z.object({ + importType: z.string().optional(), + userIntent: z.string().optional(), + }), + output: z.strictObject({ + imports: docArray(), + users: docArray(), + }), + }, + + "GET /imports/failed": { + description: "List failed import trackers (admin).", + input: z.object({ + importType: z.string().optional(), + userIntent: z.string().optional(), + }), + output: z.strictObject({ + failedImports: docArray(), + users: docArray(), + }), + }, + + "GET /imports/:importID": { + description: "Get an import document.", + input: z.object({}), + output: z.strictObject({ + import: doc(), + scores: docArray(), + songs: docArray(), + charts: docArray(), + sessions: docArray(), + user: doc(), + }), + }, + + "POST /imports/:importID/revert": { + description: "Revert an import, deleting all its scores.", + input: z.object({}), + output: empty, + }, + + "GET /imports/:importID/poll-status": { + description: "Poll the status of an in-progress import job.", + input: z.object({}), + // Response shape varies based on import state; validated as opaque object. + output: doc>(), + }, + + // ──────────────────────────────────────────────── + // Import (user-facing submission) + // NOTE: POST /import/file uses Multer and is registered separately + // ──────────────────────────────────────────────── + + "POST /import/from-api": { + description: "Trigger an API-pull import.", + input: z.object({ importType: z.string() }), + // Response is either a 202 job handle or an inline import result. + output: doc>(), + }, + + "POST /import/orphans": { + description: "Reprocess orphaned scores.", + input: z.object({}), + output: z.strictObject({ + processed: z.number(), + removed: z.number(), + failed: z.number(), + /** Count of orphans successfully converted to scores (not a boolean). */ + success: z.number(), + }), + }, + + // ──────────────────────────────────────────────── + // Clients (OAuth2 clients) + // ──────────────────────────────────────────────── + + "GET /clients": { + description: "List OAuth2 clients owned by the authenticated user.", + input: z.object({}), + output: docArray(), + }, + + "POST /clients/create": { + description: "Create a new OAuth2 client.", + input: z.object({ + name: z.string(), + redirectUri: z.string().nullish(), + webhookUri: z.string().nullish(), + apiKeyTemplate: z.string().nullable().optional(), + apiKeyFilename: z.string().nullable().optional(), + permissions: z.array(z.string()).optional(), + }), + output: doc(), + }, + + "GET /clients/:clientID": { + description: "Get public info for an OAuth2 client.", + input: z.object({}), + output: doc(), + }, + + "PATCH /clients/:clientID": { + description: "Update an OAuth2 client.", + input: z.object({ + name: z.string().optional(), + redirectUri: z.string().nullable().optional(), + webhookUri: z.string().nullable().optional(), + apiKeyTemplate: z.string().nullable().optional(), + apiKeyFilename: z.string().nullable().optional(), + permissions: z.array(z.string()).optional(), + }), + output: doc(), + }, + + "POST /clients/:clientID/reset-secret": { + description: "Reset the secret for an OAuth2 client.", + input: z.object({}), + output: doc(), + }, + + "DELETE /clients/:clientID": { + description: "Delete an OAuth2 client.", + input: z.object({}), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Admin + // ──────────────────────────────────────────────── + + "GET /admin/job-queue": { + description: "View the job queue (admin only).", + input: z.object({ + page: z.coerce.number().optional(), + status: z.string().optional(), + job_kind: z.string().optional(), + scope: z.string().optional(), + }), + output: z.strictObject({ + activeJobs: docArray(), + filters: doc(), + jobQueue: z.strictObject({ + items: docArray(), + page: z.number(), + pageSize: z.number(), + total: z.number(), + }), + }), + }, + + "GET /admin/actions": { + description: "View the admin action log.", + input: z.object({ + page: z.coerce.number().optional(), + kind: z.string().optional(), + username: z.string().optional(), + }), + output: z.strictObject({ + actions: z.strictObject({ + items: docArray(), + page: z.number(), + pageSize: z.number(), + total: z.number(), + }), + filters: doc(), + }), + }, + + "GET /admin/cron-tasks": { + description: "View cron task history.", + input: z.object({}), + output: z.strictObject({ + tasks: docArray(), + executions: docArray(), + }), + }, + + "POST /admin/resync-pbs": { + description: "Resync PBs (stub; not yet implemented).", + input: z.object({ + userIDs: z.array(z.number()).optional(), + filter: z.unknown().optional(), + }), + output: empty, + }, + + "POST /admin/delete-score": { + description: "Admin delete a score.", + input: z.object({ scoreID: z.string() }), + output: empty, + }, + + "POST /admin/delete-session": { + description: "Admin delete a session.", + input: z.object({ sessionID: z.string() }), + output: empty, + }, + + "POST /admin/destroy-ugpt": { + description: "Destroy all data for a user on a game+playtype.", + input: z.object({ + userID: z.number(), + game: z.string(), + playtype: z.string(), + }), + output: empty, + }, + + "POST /admin/recalc": { + description: "Recalculate ratings (stub; not yet implemented).", + input: z.object({}), + output: empty, + }, + + "POST /admin/announcement": { + description: "Create a site announcement.", + input: z.object({ + title: z.string(), + game: z.string().optional(), + }), + output: empty, + }, + + "POST /admin/supporter/:userID": { + description: "Grant supporter status to a user.", + input: z.object({}), + output: empty, + }, + + "DELETE /admin/supporter/:userID": { + description: "Revoke supporter status from a user.", + input: z.object({}), + output: empty, + }, + + "POST /admin/rebuild-folder-chart-lookup": { + description: "Rebuild the folder-chart lookup cache.", + input: z.object({ folderId: z.string().optional() }), + output: z.strictObject({ folderCount: z.number(), rowCount: z.number() }), + }, + + "POST /admin/reprocess-all-goals": { + description: "Reprocess all goals (stub; not yet implemented).", + input: z.object({}), + output: empty, + }, + + // ──────────────────────────────────────────────── + // Seeds (local dev only) + // ──────────────────────────────────────────────── + + "GET /seeds": { + description: "Feature probe for the seeds API.", + input: z.object({}), + output: empty, + }, + + "GET /seeds/has-uncommitted-changes": { + description: "Whether the seeds git repo has uncommitted changes.", + input: z.object({}), + output: z.boolean(), + }, + + "GET /seeds/commits": { + description: "List commits in the seeds repo.", + input: z.object({ + branch: z.string(), + file: z.string().optional(), + }), + output: docArray(), + }, + + "GET /seeds/branches": { + description: "List branches in the seeds repo.", + input: z.object({}), + output: z.strictObject({ + branches: z.array(z.strictObject({ name: z.string(), sha: z.string() })), + current: z.nullable(z.strictObject({ name: z.string(), sha: z.string() })), + }), + }, + + "GET /seeds/collections": { + description: "Retrieve seed JSON at an optional revision.", + input: z.object({ revision: z.string().optional() }), + output: z.record(z.string(), z.unknown()), + }, + + "GET /seeds/commit": { + description: "Get metadata for a specific commit in the seeds repo.", + input: z.object({ sha: z.string() }), + output: doc(), + }, + + // ──────────────────────────────────────────────── + // Local dev + // ──────────────────────────────────────────────── + + "GET /localdev/song-seed-status": { + description: "Whether the song table is empty (for seeding detection).", + input: z.object({}), + output: z.strictObject({ missingSongSeeds: z.boolean() }), + }, + + "GET /localdev/first-admin-login": { + description: "Dev hint for first admin login.", + input: z.object({}), + output: z.strictObject({ username: z.string(), password: z.string() }), + }, +} as const satisfies AnyRouterSpec; + +export type APIv1Spec = typeof API_V1_SPEC; diff --git a/typescript/server/src/server/router/api/v1/status/router.oldtest.ts b/typescript/server/src/server/router/api/v1/status/router.oldtest.ts deleted file mode 100644 index 8ba315728..000000000 --- a/typescript/server/src/server/router/api/v1/status/router.oldtest.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { VERSION_PRETTY } from "#lib/constants/version"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import t from "tap"; - -t.test("GET /api/v1/status", async (t) => { - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return the current time and the server version.", async (t) => { - const res = await mockApi.get("/api/v1/status").set("Cookie", cookie); - - t.equal(res.statusCode, 200); - t.equal(res.body.success, true); - t.ok( - Math.abs(Date.now() - res.body.body.serverTime) < 5_000, - "Should be roughly the current time (5 seconds lenience)", - ); - t.type(res.body.body.startTime, "number", "Should return a number for startTime."); - t.equal(res.body.body.version, VERSION_PRETTY); - t.equal(res.body.body.whoami, 1); - - t.end(); - }); - - t.test("Should echo the provided echo param.", async (t) => { - const res = await mockApi.get("/api/v1/status?echo=foobar").set("Cookie", cookie); - - t.equal(res.statusCode, 200); - t.equal(res.body.success, true); - t.equal(res.body.body.echo, "foobar"); - t.ok( - Math.abs(Date.now() - res.body.body.serverTime) < 5_000, - "Should be roughly the current time (5 seconds lenience)", - ); - t.type(res.body.body.startTime, "number", "Should return a number for startTime."); - t.equal(res.body.body.version, VERSION_PRETTY); - t.equal(res.body.body.whoami, 1); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/status", async (t) => { - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return the current time and the server version.", async (t) => { - const res = await mockApi.post("/api/v1/status").set("Cookie", cookie); - - t.equal(res.statusCode, 200); - t.equal(res.body.success, true); - t.ok( - Math.abs(Date.now() - res.body.body.serverTime) < 5_000, - "Should be roughly the current time (5 seconds lenience)", - ); - t.equal(res.body.body.version, VERSION_PRETTY); - t.equal(res.body.body.whoami, 1); - t.type(res.body.body.startTime, "number", "Should return a number for startTime."); - - t.end(); - }); - - t.test("Should echo the provided echo param.", async (t) => { - const res = await mockApi.post("/api/v1/status").set("Cookie", cookie).send({ - echo: "foobar", - }); - - t.equal(res.statusCode, 200); - t.equal(res.body.success, true); - t.equal(res.body.body.echo, "foobar"); - t.ok( - Math.abs(Date.now() - res.body.body.serverTime) < 5_000, - "Should be roughly the current time (5 seconds lenience)", - ); - t.equal(res.body.body.version, VERSION_PRETTY); - t.equal(res.body.body.whoami, 1); - t.type(res.body.body.startTime, "number", "Should return a number for startTime."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/status/router.test.ts b/typescript/server/src/server/router/api/v1/status/router.test.ts new file mode 100644 index 000000000..42ddee42c --- /dev/null +++ b/typescript/server/src/server/router/api/v1/status/router.test.ts @@ -0,0 +1,91 @@ +import { VERSION_PRETTY } from "#lib/constants/version"; +import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter"; +import mockApi from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +describe("GET /api/v1/status", () => { + let cookie: string[]; + let userId: number; + + beforeEach(async () => { + ClearTestingRateLimitCache(); + ({ id: userId } = await seedUser({ + username: "status_tester", + withCredential: true, + withSettings: true, + })); + cookie = await loginAs("status_tester"); + }); + + it("returns server time, version, and authenticated user", async () => { + const res = await mockApi.get("/api/v1/status").set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Math.abs(Date.now() - res.body.body.serverTime)).toBeLessThan(5000); + expect(typeof res.body.body.startTime).toBe("number"); + expect(res.body.body.version).toBe(VERSION_PRETTY); + expect(res.body.body.whoami).toBe(userId); + expect(Array.isArray(res.body.body.permissions)).toBe(true); + }); + + it("echoes the query echo param when provided", async () => { + const res = await mockApi.get("/api/v1/status?echo=foobar").set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body.echo).toBe("foobar"); + expect(res.body.body.version).toBe(VERSION_PRETTY); + expect(res.body.body.whoami).toBe(userId); + }); +}); + +describe("POST /api/v1/status", () => { + let cookie: string[]; + let userId: number; + + beforeEach(async () => { + ClearTestingRateLimitCache(); + ({ id: userId } = await seedUser({ + username: "status_post_tester", + withCredential: true, + withSettings: true, + })); + cookie = await loginAs("status_post_tester"); + }); + + it("returns server time, version, and authenticated user", async () => { + const res = await mockApi.post("/api/v1/status").set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Math.abs(Date.now() - res.body.body.serverTime)).toBeLessThan(5000); + expect(res.body.body.version).toBe(VERSION_PRETTY); + expect(res.body.body.whoami).toBe(userId); + expect(typeof res.body.body.startTime).toBe("number"); + }); + + it("echoes echo in the JSON body when provided", async () => { + const res = await mockApi + .post("/api/v1/status") + .set("Cookie", cookie) + .send({ echo: "foobar" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body.echo).toBe("foobar"); + expect(res.body.body.version).toBe(VERSION_PRETTY); + expect(res.body.body.whoami).toBe(userId); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/status/router.ts b/typescript/server/src/server/router/api/v1/status/router.ts index 3190b37e2..178ca2c26 100644 --- a/typescript/server/src/server/router/api/v1/status/router.ts +++ b/typescript/server/src/server/router/api/v1/status/router.ts @@ -1,24 +1,21 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { VERSION_PRETTY } from "#lib/constants/version"; -import { Router } from "express"; -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; const startTime = Date.now(); /** * Returns the current status of the Tachi Server. - * - * @name GET /api/v1/status */ -router.get("/", (req, res) => { +API_V1_ROUTER.add("GET /status", ({ input, req }) => { let echo; - if (typeof req.query.echo === "string") { - echo = req.query.echo; + if (typeof input.echo === "string") { + echo = input.echo; } - return res.status(200).json({ + return { success: true, description: "Status check successful.", body: { @@ -33,7 +30,7 @@ router.get("/", (req, res) => { .map((e) => e[0]), echo, }, - }); + }; }); /** @@ -42,14 +39,14 @@ router.get("/", (req, res) => { * * @name POST /api/v1/status */ -router.post("/", (req, res) => { +API_V1_ROUTER.add("POST /status", ({ input, req }) => { let echo; - if (typeof req.safeBody.echo === "string") { - echo = req.safeBody.echo; + if (typeof input.echo === "string") { + echo = input.echo; } - return res.status(200).json({ + return { success: true, description: "Status check successful.", body: { @@ -64,7 +61,5 @@ router.post("/", (req, res) => { .map((e) => e[0]), echo, }, - }); + }; }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.oldtest.ts deleted file mode 100644 index a07420790..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.oldtest.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/api-tokens", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL["api-tokens"].remove({}); - await MONGODB_KILL["api-tokens"].insert([ - { - userID: 1, - identifier: "foo", - permissions: {}, - token: "tfoo", - fromAPIClient: null, - }, - { - userID: 1, - identifier: "bar", - permissions: {}, - token: "tbar", - fromAPIClient: null, - }, - { - userID: 2, - identifier: "baz", - permissions: {}, - token: "tbaz", - fromAPIClient: null, - }, - ]); - }); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return this users tokens alone.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/api-tokens").set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - // sort these alphabetically so that strictsame can work properly - t.strictSame( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - res.body.body.sort((a: any, b: any) => a.identifier - b.identifier), - [ - { - userID: 1, - identifier: "foo", - permissions: {}, - token: "tfoo", - fromAPIClient: null, - }, - { - userID: 1, - identifier: "bar", - permissions: {}, - token: "tbar", - fromAPIClient: null, - }, - ], - ); - - t.end(); - }); - - t.test("Should require authentication as that user.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/api-tokens"); - - t.equal(res.statusCode, 401); - - await MONGODB_KILL.users.insert({ - username: "test", - usernameLowercase: "test", - id: 2, - } as UserDocument); - - const res2 = await mockApi.get("/api/v1/users/2/api-tokens").set("Cookie", cookie); - - t.equal(res2.statusCode, 403); - - t.end(); - }); - - t.end(); -}); - -t.test("DELETE /api/v1/users/:userID/api-tokens/:token", async (t) => { - t.beforeEach(ResetDBState); - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should delete the cookie at that token.", async (t) => { - const res = await mockApi - .delete("/api/v1/users/1/api-tokens/fake_api_token") - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - const dbRes = await MONGODB_KILL["api-tokens"].findOne({ - token: "fake_api_token", - }); - - t.equal(dbRes, null, "Should delete the token in the database"); - - t.end(); - }); - - t.test("Returns 404 if token doesn't exist.", async (t) => { - const res = await mockApi - .delete("/api/v1/users/1/api-tokens/non_exist_token") - .set("Cookie", cookie); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Must return 404 if the token belongs to another user.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - identifier: "foo", - permissions: {}, - token: "foo", - userID: 2, - fromAPIClient: null, - }); - - const res = await mockApi.delete("/api/v1/users/1/api-tokens/foo").set("Cookie", cookie); - - // so as not to reveal that this token exists. - t.equal(res.statusCode, 404); - - const dbRes = await MONGODB_KILL["api-tokens"].findOne({ token: "foo" }); - - t.not(dbRes, null, "Should not have deleted the token."); - - t.end(); - }); - - t.test("Must require authentication as that user.", async (t) => { - const res = await mockApi.delete("/api/v1/users/1/api-tokens/foo"); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/users/:userID/api-tokens/create", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should create a new API Key for this user with provided permissions.", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/api-tokens/create") - .set("Cookie", cookie) - .send({ - identifier: "Hello World", - permissions: ["submit_score", "customise_profile"], - }); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.hasStrict( - res.body.body, - { - identifier: "Hello World", - permissions: { submit_score: true, customise_profile: true }, - userID: 1, - fromAPIClient: null, - }, - "Should return a conforming API Token.", - ); - - const dbRes = await MONGODB_KILL["api-tokens"].findOne({ - identifier: "Hello World", - }); - - t.hasStrict( - dbRes, - { - identifier: "Hello World", - permissions: { submit_score: true, customise_profile: true }, - userID: 1, - fromAPIClient: null, - }, - "Should insert a conforming API Token into the database.", - ); - - t.end(); - }); - - t.test( - "Should create a new API Key for this user according to an existing clientID.", - async (t) => { - const res = await mockApi - .post("/api/v1/users/1/api-tokens/create") - .set("Cookie", cookie) - .send({ - clientID: "OAUTH2_CLIENT_ID", - }); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.hasStrict( - res.body.body, - { - identifier: "Test_Service", - permissions: { customise_profile: true }, - userID: 1, - fromAPIClient: "OAUTH2_CLIENT_ID", - }, - "Should return a conforming API Token, with the permissions from that client.", - ); - - const dbRes = await MONGODB_KILL["api-tokens"].findOne({ - identifier: "Test_Service", - }); - - t.hasStrict( - dbRes, - { - identifier: "Test_Service", - permissions: { customise_profile: true }, - userID: 1, - fromAPIClient: "OAUTH2_CLIENT_ID", - }, - "Should insert a conforming API Token into the database.", - ); - - t.end(); - }, - ); - - t.test("Should reject requests that use both provided permissions and clientID.", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/api-tokens/create") - .set("Cookie", cookie) - .send({ - identifier: "Hello World", - permissions: ["submit_score", "customise_profile"], - clientID: "OAUTH2_CLIENT_ID", - }); - - t.equal(res.statusCode, 400, "Should return 400."); - t.match( - res.body.description, - /clientID creation and permissions creation at the same time/iu, - ); - - t.end(); - }); - - t.test("Should reject requests with no provided permissions or clientID.", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/api-tokens/create") - .set("Cookie", cookie) - .send({ - identifier: "Hello World", - }); - - t.equal(res.statusCode, 400, "Should return 400."); - t.match(res.body.description, /must specify either clientID or permissions/iu); - - t.end(); - }); - - t.test("Should reject invalid permissions.", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/api-tokens/create") - .set("Cookie", cookie) - .send({ - identifier: "Hello World", - permissions: ["submit_score", "invalid_permission"], - }); - - t.equal(res.statusCode, 400, "Should return 400."); - t.match(res.body.description, /invalid_permission/iu); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.test.ts new file mode 100644 index 000000000..eaecef34e --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.test.ts @@ -0,0 +1,238 @@ +import { getApiToken, seedApiClient, seedApiToken } from "#actions/test-utils/api-tokens"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +describe("GET /api/v1/users/:userID/api-tokens", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "tok_u1", withCredential: true, withSettings: true }); + await seedUser({ username: "tok_u2" }); + + await DB.deleteFrom("priv_api_token").execute(); + + await seedApiToken({ token: "tfoo", userId: 1, identifier: "foo" }); + await seedApiToken({ token: "tbar", userId: 1, identifier: "bar" }); + await seedApiToken({ token: "tbaz", userId: 2, identifier: "baz" }); + + cookie = await loginAs("tok_u1"); + }); + + it("returns only this user's tokens", async () => { + const res = await mockApi.get("/api/v1/users/1/api-tokens").set("Cookie", cookie); + + expect(res.status).toBe(200); + + const body = res.body.body.sort((a: { identifier: string }, b: { identifier: string }) => + a.identifier.localeCompare(b.identifier), + ); + + expect(body).toEqual([ + { + userID: 1, + identifier: "bar", + permissions: {}, + token: "tbar", + fromAPIClient: null, + }, + { + userID: 1, + identifier: "foo", + permissions: {}, + token: "tfoo", + fromAPIClient: null, + }, + ]); + }); + + it("requires authentication as that user", async () => { + const res = await mockApi.get("/api/v1/users/1/api-tokens"); + + expect(res.status).toBe(401); + + const res2 = await mockApi.get("/api/v1/users/2/api-tokens").set("Cookie", cookie); + + expect(res2.status).toBe(403); + }); +}); + +describe("DELETE /api/v1/users/:userID/api-tokens/:token", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "del_tok_u1", withCredential: true, withSettings: true }); + await seedApiToken({ token: "fake_api_token", userId: 1, identifier: "session" }); + cookie = await loginAs("del_tok_u1"); + }); + + it("deletes the token row", async () => { + const res = await mockApi + .delete("/api/v1/users/1/api-tokens/fake_api_token") + .set("Cookie", cookie); + + expect(res.status).toBe(200); + + const row = await getApiToken("fake_api_token"); + expect(row).toBeUndefined(); + }); + + it("returns 404 when the token does not exist", async () => { + const res = await mockApi + .delete("/api/v1/users/1/api-tokens/non_exist_token") + .set("Cookie", cookie); + + expect(res.status).toBe(404); + }); + + it("returns 404 when the token belongs to another user (without revealing existence)", async () => { + await seedUser({ username: "del_tok_u2" }); + await seedApiToken({ token: "foo", userId: 2, identifier: "other" }); + + const res = await mockApi.delete("/api/v1/users/1/api-tokens/foo").set("Cookie", cookie); + + expect(res.status).toBe(404); + + const row = await getApiToken("foo"); + expect(row).toBeDefined(); + }); + + it("returns 401 without authentication", async () => { + const res = await mockApi.delete("/api/v1/users/1/api-tokens/foo"); + + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/v1/users/:userID/api-tokens/create", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "crt_tok_u1", withCredential: true, withSettings: true }); + cookie = await loginAs("crt_tok_u1"); + }); + + it("creates a token with explicit permission flags", async () => { + const res = await mockApi + .post("/api/v1/users/1/api-tokens/create") + .set("Cookie", cookie) + .send({ + identifier: "Hello World", + permissions: { submit_score: true, customise_profile: true }, + }); + + expect(res.status).toBe(200); + + expect(res.body.body).toMatchObject({ + identifier: "Hello World", + permissions: { submit_score: true, customise_profile: true }, + userID: 1, + fromAPIClient: null, + }); + + const row = await DB.selectFrom("priv_api_token") + .selectAll() + .where("identifier", "=", "Hello World") + .executeTakeFirst(); + + expect(row).toMatchObject({ + identifier: "Hello World", + user_id: 1, + from_oauth2_client: null, + pm_submit_score: true, + pm_customise_profile: true, + }); + }); + + it("creates a token from an OAuth2 client id", async () => { + await seedApiClient({ + clientId: "OAUTH2_CLIENT_ID", + authorId: 1, + name: "Test_Service", + clientSecret: "OAUTH2_CLIENT_SECRET", + customiseProfile: true, + redirectUri: "https://example.com/callback", + }); + + const res = await mockApi + .post("/api/v1/users/1/api-tokens/create") + .set("Cookie", cookie) + .send({ + clientID: "OAUTH2_CLIENT_ID", + }); + + expect(res.status).toBe(200); + + expect(res.body.body).toMatchObject({ + identifier: "Test_Service", + permissions: { customise_profile: true }, + userID: 1, + fromAPIClient: "OAUTH2_CLIENT_ID", + }); + + const row = await DB.selectFrom("priv_api_token") + .selectAll() + .where("identifier", "=", "Test_Service") + .executeTakeFirst(); + + expect(row).toMatchObject({ + identifier: "Test_Service", + user_id: 1, + from_oauth2_client: "OAUTH2_CLIENT_ID", + pm_customise_profile: true, + }); + }); + + it("rejects clientID together with permissions", async () => { + const res = await mockApi + .post("/api/v1/users/1/api-tokens/create") + .set("Cookie", cookie) + .send({ + identifier: "Hello World", + permissions: { submit_score: true, customise_profile: true }, + clientID: "OAUTH2_CLIENT_ID", + }); + + expect(res.status).toBe(400); + expect(String(res.body.description)).toMatch(/clientID/iu); + expect(String(res.body.description)).toMatch(/permissions/iu); + }); + + it("rejects requests with neither clientID nor permissions", async () => { + const res = await mockApi + .post("/api/v1/users/1/api-tokens/create") + .set("Cookie", cookie) + .send({ + identifier: "Hello World", + }); + + expect(res.status).toBe(400); + expect(String(res.body.description)).toMatch(/clientID|permissions/iu); + }); + + it("rejects unknown permission keys", async () => { + const res = await mockApi + .post("/api/v1/users/1/api-tokens/create") + .set("Cookie", cookie) + .send({ + identifier: "Hello World", + permissions: { submit_score: true, invalid_permission: true }, + }); + + expect(res.status).toBe(400); + expect(String(res.body.description)).toMatch(/invalid_permission/iu); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts index b19d3ab14..e800ddeb1 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts @@ -1,15 +1,10 @@ -import { ACTION_CreateApiToken } from "#actions/create-api-token.js"; -import { ACTION_DeleteApiToken } from "#actions/delete-api-token.js"; +import { ACTION_CreateApiToken } from "#actions/create-api-token"; +import { ACTION_DeleteApiToken } from "#actions/delete-api-token"; import { SELECT_API_TOKEN, ToAPITokenDocument } from "#lib/db-formats/api-token"; +import { withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; - -import { RequireSelfRequestFromUser } from "../middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(RequireSelfRequestFromUser); /** * Retrieve this user's API tokens. @@ -17,19 +12,13 @@ router.use(RequireSelfRequestFromUser); * * @name GET /api/v1/users/:userID/api-tokens */ -router.get("/", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - - const keys = await DB.selectFrom("priv_api_token") +API_V1_ROUTER.add("GET /users/:userID/api-tokens", withRequestedUser, withSelf, async ({ ctx }) => { + const rows = await DB.selectFrom("priv_api_token") .select(SELECT_API_TOKEN) - .where("user_id", "=", user.id) + .where("priv_api_token.user_id", "=", ctx.requestedUser.id) .execute(); - return res.status(200).json({ - success: true, - description: `Returned ${keys.length} keys.`, - body: keys.map(ToAPITokenDocument), - }); + return success(`Returned ${rows.length} keys.`, rows.map(ToAPITokenDocument)); }); /** @@ -42,66 +31,53 @@ router.get("/", async (req, res) => { * * @name POST /api/v1/users/:userID/api-tokens/create */ -router.post("/create", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "POST /users/:userID/api-tokens/create", + withRequestedUser, + withSelf, + async ({ input, ctx, req }) => { + const { requestedUser: user } = ctx; - const body = req.body as { - clientID?: string; - identifier?: string; - permissions?: Array; - }; - - const { token, wasExisting } = await ACTION_CreateApiToken( - { - acct: { - id: user.id, - username: user.username, + const { token, wasExisting } = await ACTION_CreateApiToken( + { acct: { id: user.id, username: user.username }, ip: req.ip }, + { + clientID: input.clientID, + identifier: input.identifier, + permissions: input.permissions + ? Object.keys(input.permissions).filter((k) => input.permissions![k]) + : undefined, }, - ip: req.ip, - }, - { - clientID: body.clientID, - permissions: body.permissions, - identifier: body.identifier, - }, - ); + ); - const tokenRow = await DB.selectFrom("priv_api_token") - .select(SELECT_API_TOKEN) - .where("token", "=", token) - .executeTakeFirstOrThrow(); + const tokenRow = await DB.selectFrom("priv_api_token") + .select(SELECT_API_TOKEN) + .where("priv_api_token.token", "=", token) + .executeTakeFirstOrThrow(); - return res.status(200).json({ - success: true, - description: wasExisting ? "Returned existing key." : "Successfully created new API Token.", - body: ToAPITokenDocument(tokenRow), - }); -}); + return success( + wasExisting ? "Returned existing key." : "Successfully created new API Token.", + ToAPITokenDocument(tokenRow), + ); + }, +); /** * Delete this token. * * @name DELETE /api/v1/users/:userID/api-tokens/:token */ -router.delete("/:token", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "DELETE /users/:userID/api-tokens/:token", + withRequestedUser, + withSelf, + async ({ params, ctx, req }) => { + const { requestedUser: user } = ctx; - await ACTION_DeleteApiToken( - { - acct: { - id: user.id, - username: user.username, - }, - ip: req.ip, - }, - { token: req.params.token }, - ); + await ACTION_DeleteApiToken( + { acct: { id: user.id, username: user.username }, ip: req.ip }, + { token: params.token }, + ); - return res.status(200).json({ - success: true, - description: `Removed Token.`, - body: {}, - }); -}); - -export default router; + return success("Removed Token.", {}); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.oldtest.ts deleted file mode 100644 index 2ccafec86..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.oldtest.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; -import { GetProfileBannerURL } from "#lib/cdn/url-format"; -import { log } from "#lib/log/log.js"; -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { GetKTDataBuffer } from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/banner", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the default profile banner if user has no custom banner", async (t) => { - await CDNStoreOrOverwrite("/users/default/banner", "test"); - const res = await mockApi.get("/api/v1/users/1/banner").redirects(1); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.equal(res.body.toString(), "test"); - - t.end(); - }); - - t.test("Should return a custom profile banner if one is set", async (t) => { - await CDNStoreOrOverwrite(GetProfileBannerURL(1, "checksum"), "foo"); - await MONGODB_KILL.users.update({ id: 1 }, { $set: { customBannerLocation: "checksum" } }); - const res = await mockApi.get("/api/v1/users/1/banner").redirects(1); - - t.equal(res.statusCode, 200, "Should return 200."); - - if (res.statusCode !== 200) { - log.info( - { - body: res.body, - }, - "Unexpected non-200 in CDN tests, received this as a body.", - ); - } - - t.equal(res.body.toString(), "foo"); - - t.end(); - }); - - t.end(); -}); - -t.test("PUT /api/v1/users/:userID/banner", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should set a profile banner if user has no custom banner", async (t) => { - const img = GetKTDataBuffer("/images/acorn.png"); - - const res = await mockApi - .put("/api/v1/users/1/banner") - .set("Authorization", "Bearer fake_api_token") - .attach("banner", img, "file.jpg"); - - t.equal(res.statusCode, 200); - - const get = await mockApi.get(res.body.body.get).redirects(1); - - t.strictSame(img, get.body, "Profile banner should be stored."); - - t.end(); - }); - - t.test("Should set a profile banner if user has custom banner", async (t) => { - await MONGODB_KILL.users.update({ id: 1 }, { $set: { customBannerLocation: "checksum" } }); - - const img = GetKTDataBuffer("/images/acorn.png"); - - const res = await mockApi - .put("/api/v1/users/1/banner") - .set("Authorization", "Bearer fake_api_token") - .attach("banner", img, "file.jpg"); - - t.equal(res.statusCode, 200); - - const get = await mockApi.get(res.body.body.get).redirects(1); - - t.strictSame(img, get.body, "Profile banner should be stored."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.test.ts new file mode 100644 index 000000000..ec66288de --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.test.ts @@ -0,0 +1,83 @@ +import { seedApiToken } from "#actions/test-utils/api-tokens"; +import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { GetProfileBannerURL } from "#lib/cdn/url-format"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { GetKTDataBuffer } from "#test-utils/test-data"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +describe("GET /api/v1/users/:userID/banner", () => { + beforeEach(async () => { + await seedUser({ username: "banner_get_user" }); + }); + + it("returns the default banner when the user has no custom banner", async () => { + await CDNStoreOrOverwrite("/users/default/banner", "test"); + const res = await mockApi.get("/api/v1/users/1/banner").redirects(1); + + expect(res.status).toBe(200); + expect(res.body.toString()).toBe("test"); + }); + + it("returns a custom banner when one is set", async () => { + await CDNStoreOrOverwrite(GetProfileBannerURL(1, "checksum"), "foo"); + await DB.updateTable("account") + .set({ custom_banner_location: "checksum" }) + .where("account.id", "=", 1) + .execute(); + + const res = await mockApi.get("/api/v1/users/1/banner").redirects(1); + + expect(res.status).toBe(200); + expect(res.body.toString()).toBe("foo"); + }); +}); + +describe("PUT /api/v1/users/:userID/banner", () => { + beforeEach(async () => { + await seedUser({ username: "banner_put_user" }); + await seedApiToken({ token: "fake_api_token", userId: 1, identifier: "bnr_tok" }); + await DB.updateTable("priv_api_token") + .set({ pm_customise_profile: true }) + .where("priv_api_token.token", "=", "fake_api_token") + .execute(); + }); + + it("stores a banner when the user had no custom banner", async () => { + const img = GetKTDataBuffer("/images/acorn.png"); + + const res = await mockApi + .put("/api/v1/users/1/banner") + .set("Authorization", "Bearer fake_api_token") + .attach("banner", img, "file.jpg"); + + expect(res.status).toBe(200); + + const get = await mockApi.get(res.body.body.get).redirects(1); + + expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toStrictEqual(img); + }); + + it("stores a banner when the user already had a custom banner", async () => { + await DB.updateTable("account") + .set({ custom_banner_location: "checksum" }) + .where("account.id", "=", 1) + .execute(); + + const img = GetKTDataBuffer("/images/acorn.png"); + + const res = await mockApi + .put("/api/v1/users/1/banner") + .set("Authorization", "Bearer fake_api_token") + .attach("banner", img, "file.jpg"); + + expect(res.status).toBe(200); + + const get = await mockApi.get(res.body.body.get).redirects(1); + + expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toStrictEqual(img); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts index 5dfd14f0d..05f0489b6 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts @@ -1,16 +1,16 @@ -import { ACTION_ChangeBanner } from "#actions/change-banner.js"; -import { ACTION_DeleteBanner } from "#actions/delete-banner.js"; +import { ACTION_ChangeBanner } from "#actions/change-banner"; +import { ACTION_DeleteBanner } from "#actions/delete-banner"; import { CDNRedirect } from "#lib/cdn/cdn"; import { GetProfileBannerURL } from "#lib/cdn/url-format"; import { ONE_MEGABYTE } from "#lib/constants/filesize"; +import { withAuthedAsUser, withPermission, withRequestedUser } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { RequirePermissions } from "#server/middleware/auth"; import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import { REQ_GetTachiData } from "#utils/req-tachi-data"; -import { RequireAuthedAsUser } from "../middleware"; - -const router: Router = Router({ mergeParams: true }); +import { GetUserFromParam, RequireAuthedAsUser } from "../middleware"; /** * Sets a profile banner. @@ -21,13 +21,15 @@ const router: Router = Router({ mergeParams: true }); * * @name PUT /api/v1/users/:userID/banner */ -router.put( - "/", +API_V1_ROUTER.rawAdd( + "PUT", + "/users/:userID/banner", + GetUserFromParam, RequireAuthedAsUser, RequirePermissions("customise_profile"), CreateMulterSingleUploadMiddleware("banner", ONE_MEGABYTE), async (req, res) => { - const user = GetTachiData(req, "requestedUser"); + const user = REQ_GetTachiData(req, "requestedUser"); if (!req.file) { return res.status(400).json({ @@ -70,17 +72,18 @@ router.put( * * @name GET /api/v1/users/:userID/banner */ -router.get("/", (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add("GET /users/:userID/banner", withRequestedUser, ({ ctx, res }) => { + const { requestedUser: user } = ctx; if (!user.customBannerLocation) { res.setHeader("Content-Type", "image/png"); CDNRedirect(res, "/users/default/banner"); - return; + return success("Redirected to default profile banner.", {}); } // express sniffs whether this is a png or jpg **and** browsers dont care either. CDNRedirect(res, GetProfileBannerURL(user.id, user.customBannerLocation)); + return success("Redirected to profile banner.", {}); }); /** @@ -88,12 +91,13 @@ router.get("/", (req, res) => { * * @name DELETE /api/v1/users/:userID/banner */ -router.delete( - "/", - RequireAuthedAsUser, - RequirePermissions("customise_profile"), - async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "DELETE /users/:userID/banner", + withRequestedUser, + withAuthedAsUser, + withPermission("customise_profile"), + async ({ ctx, req }) => { + const { requestedUser: user } = ctx; await ACTION_DeleteBanner( { @@ -110,12 +114,6 @@ router.delete( req.session.tachi.user.customBannerLocation = null; } - return res.status(200).json({ - success: true, - description: `Removed custom profile banner.`, - body: {}, - }); + return success("Removed custom profile banner.", {}); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts index fb9b0e736..8327b5225 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts @@ -1,13 +1,10 @@ import { ACTION_FollowUser } from "#actions/follow-user"; import { ACTION_UnfollowUser } from "#actions/unfollow-user"; +import { withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { GetFollowingForUser } from "#utils/queries/settings"; -import { GetUser } from "#utils/req-tachi-data"; import { GetUsersWithIDs } from "#utils/user"; -import { Router } from "express"; - -import { RequireSelfRequestFromUser } from "../middleware"; - -const router: Router = Router({ mergeParams: true }); /** * Retrieve who this user is following. @@ -16,16 +13,12 @@ const router: Router = Router({ mergeParams: true }); * * @name GET /api/v1/users/:userID/following */ -router.get("/", async (req, res) => { - const user = GetUser(req); - - const followingIDs = await GetFollowingForUser(user.id); +API_V1_ROUTER.add("GET /users/:userID/following", withRequestedUser, async ({ ctx }) => { + const followingIDs = await GetFollowingForUser(ctx.requestedUser.id); const friends = await GetUsersWithIDs(followingIDs); - return res.status(200).json({ - success: true, - description: `Found ${friends.length} friend${friends.length !== 1 ? "s" : ""}.`, - body: { friends }, + return success(`Found ${friends.length} friend${friends.length !== 1 ? "s" : ""}.`, { + friends, }); }); @@ -36,18 +29,19 @@ router.get("/", async (req, res) => { * * @name POST /api/v1/users/:userID/following/add */ -router.post("/add", RequireSelfRequestFromUser, async (req, res) => { - const user = req.session.tachi!.user; - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; +API_V1_ROUTER.add( + "POST /users/:userID/following/add", + withRequestedUser, + withSelf, + async ({ input, req }) => { + const user = req.session.tachi!.user; + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; - const result = await ACTION_FollowUser(taker, { userID: req.body.userID }); + const result = await ACTION_FollowUser(taker, { userID: input.userID }); - return res.status(200).json({ - success: true, - description: `Added ${result.username}.`, - body: {}, - }); -}); + return success(`Added ${result.username}.`, {}); + }, +); /** * Unfollow a user. @@ -56,17 +50,16 @@ router.post("/add", RequireSelfRequestFromUser, async (req, res) => { * * @name POST /api/v1/users/:userID/following/remove */ -router.post("/remove", RequireSelfRequestFromUser, async (req, res) => { - const user = req.session.tachi!.user; - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; +API_V1_ROUTER.add( + "POST /users/:userID/following/remove", + withRequestedUser, + withSelf, + async ({ input, req }) => { + const user = req.session.tachi!.user; + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; - const result = await ACTION_UnfollowUser(taker, { userID: req.body.userID }); + const result = await ACTION_UnfollowUser(taker, { userID: input.userID }); - return res.status(200).json({ - success: true, - description: `Unfollowed ${result.username}.`, - body: {}, - }); -}); - -export default router; + return success(`Unfollowed ${result.username}.`, {}); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.test.ts index 7b629106b..3b537b57a 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.test.ts @@ -3,7 +3,7 @@ import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; import { seedUser } from "#test-utils/pg-fixtures"; import { TestingBMS7KScore } from "#test-utils/test-data"; -import { type MONGO_ScoreData } from "tachi-common"; +import { type ScoreData } from "tachi-common"; import { afterAll, describe, expect, it } from "vitest"; afterAll(() => CloseServerConnection()); @@ -66,8 +66,8 @@ async function seedBmsChartWithHashes(opts: { async function insertBmsPb(userId: number, chartPgId: string) { const { data, derived, judgements } = mongoScoreDataToPg( - "bms:7K", - TestingBMS7KScore.scoreData as MONGO_ScoreData<"bms:7K">, + "bms-7k", + TestingBMS7KScore.scoreData as ScoreData<"bms-7k">, ); const now = new Date().toISOString(); @@ -93,7 +93,7 @@ async function insertBmsPb(userId: number, chartPgId: string) { .execute(); } -describe("GET /api/v1/users/:userID/games/bms/:playtype/best-score/:checksum", () => { +describe("GET /api/v1/users/:userID/games/:game/best-score/:checksum", () => { const md5 = "a1b2c3d4e5f6789012345678abcdef01"; const sha256 = "b".repeat(64); @@ -101,7 +101,7 @@ describe("GET /api/v1/users/:userID/games/bms/:playtype/best-score/:checksum", ( const { id } = await seedUser({ username: "bms_bs_hex" }); const res = await mockApi.get( - `/api/v1/users/${id}/games/bms/7K/best-score/gggggggggggggggggggggggggggggg`, + `/api/v1/users/${id}/games/bms-7k/best-score/gggggggggggggggggggggggggggggg`, ); expect(res.status).toBe(400); @@ -113,38 +113,38 @@ describe("GET /api/v1/users/:userID/games/bms/:playtype/best-score/:checksum", ( const { id } = await seedUser({ username: "bms_bs_len" }); const res = await mockApi.get( - `/api/v1/users/${id}/games/bms/7K/best-score/${"f".repeat(31)}`, + `/api/v1/users/${id}/games/bms-7k/best-score/${"f".repeat(31)}`, ); expect(res.status).toBe(400); expect(res.body.description).toContain("length"); }); - it("returns 400 for an unsupported playtype", async () => { + it("returns 400 for an invalid game", async () => { const { id } = await seedUser({ username: "bms_bs_pt" }); - const res = await mockApi.get(`/api/v1/users/${id}/games/bms/99K/best-score/${md5}`); + const res = await mockApi.get(`/api/v1/users/${id}/games/bms-99k/best-score/${md5}`); expect(res.status).toBe(400); - expect(res.body.description).toContain("playtype"); + expect(res.body.description).toContain("game"); }); - it("returns 404 when no chart matches the checksum for this playtype", async () => { + it("returns 404 when no chart matches the checksum for this game", async () => { const { id } = await seedUser({ username: "bms_bs_missing" }); const res = await mockApi.get( - `/api/v1/users/${id}/games/bms/7K/best-score/${"e".repeat(32)}`, + `/api/v1/users/${id}/games/bms-7k/best-score/${"e".repeat(32)}`, ); expect(res.status).toBe(404); expect(res.body.description).toContain("No chart found"); }); - it("returns 404 when the chart exists for the other BMS playtype", async () => { + it("returns 404 when the chart exists for the other BMS game", async () => { const { id } = await seedUser({ username: "bms_bs_wrongpt" }); await seedBmsChartWithHashes({ md5, sha256, game: "bms-7k" }); - const res = await mockApi.get(`/api/v1/users/${id}/games/bms/14K/best-score/${md5}`); + const res = await mockApi.get(`/api/v1/users/${id}/games/bms-14k/best-score/${md5}`); expect(res.status).toBe(404); expect(res.body.description).toContain("No chart found"); @@ -155,7 +155,7 @@ describe("GET /api/v1/users/:userID/games/bms/:playtype/best-score/:checksum", ( const upperMd5 = md5.toUpperCase(); const { chartPgId } = await seedBmsChartWithHashes({ md5, sha256, game: "bms-7k" }); - const res = await mockApi.get(`/api/v1/users/${id}/games/bms/7K/best-score/${upperMd5}`); + const res = await mockApi.get(`/api/v1/users/${id}/games/bms-7k/best-score/${upperMd5}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -164,7 +164,7 @@ describe("GET /api/v1/users/:userID/games/bms/:playtype/best-score/:checksum", ( await insertBmsPb(id, chartPgId); - const res2 = await mockApi.get(`/api/v1/users/${id}/games/bms/7K/best-score/${md5}`); + const res2 = await mockApi.get(`/api/v1/users/${id}/games/bms-7k/best-score/${md5}`); expect(res2.status).toBe(200); expect(res2.body.description).toBe("Best score found."); @@ -177,7 +177,7 @@ describe("GET /api/v1/users/:userID/games/bms/:playtype/best-score/:checksum", ( const { id } = await seedUser({ username: "bms_bs_sha256" }); await seedBmsChartWithHashes({ md5, sha256, game: "bms-7k" }); - const res = await mockApi.get(`/api/v1/users/${id}/games/bms/7K/best-score/${sha256}`); + const res = await mockApi.get(`/api/v1/users/${id}/games/bms-7k/best-score/${sha256}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts index 24c11459c..8c921d88a 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts @@ -1,50 +1,45 @@ -import { LoadPbByUserAndChartPgId } from "#lib/db-formats/pb"; +import { GetPBOnChart } from "#lib/db-formats/pb"; import { CUSTOM_TACHI_BMS_TABLES, HandleBMSTableBodyRequest, HandleBMSTableHeaderRequest, HandleBMSTableHTMLRequest, + type TachiBMSTable, } from "#lib/game-specific/custom-bms-tables"; -import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware"; +import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { FindBMSChartOnHashInGame } from "#utils/queries/charts"; -import { AssignToReqTachiData, GetTachiData, GetUser } from "#utils/req-tachi-data"; -import { type RequestHandler, Router } from "express"; -import { GamePTToV3, type Playtypes } from "tachi-common"; -import { type Game } from "tachi-db"; +import { REQ_GetUser } from "#utils/req-tachi-data"; +import { ExpectedErr } from "bliss"; +import { type GamesForGroup, GameToGameGroup } from "tachi-common"; -const router: Router = Router({ mergeParams: true }); - -const FindCustomBMSTable: RequestHandler = (req, res, next) => { - const { playtype, tableUrlName } = req.params; - - // find the table +function resolveUserCustomBMSTableOrThrow( + tableUrlName: string, + game: GamesForGroup["bms"], +): TachiBMSTable { const customTable = CUSTOM_TACHI_BMS_TABLES.find((t) => t.urlName === tableUrlName); if (!customTable) { - return res.status(404).json({ - success: false, - description: `No such table with the ID '${tableUrlName}' exists.`, - }); + throw new ExpectedErr(404, `No such table with the ID '${tableUrlName}' exists.`); } - if (customTable.playtype && customTable.playtype !== playtype) { - return res.status(404).json({ - success: false, - description: `The table '${tableUrlName}' exists, but is for ${customTable.playtype}, not ${playtype}.`, - }); + if (customTable.game && customTable.game !== game) { + throw new ExpectedErr( + 404, + `The table '${tableUrlName}' exists, but is for ${customTable.game}, not ${game}.`, + ); } if (customTable.forSpecificUser !== true) { - return res.status(404).json({ - success: false, - description: `The table '${tableUrlName}' exists, but isn't user specific. You should be fetching this table from /api/v1/games instead of /api/v1/users/:userID.`, - }); + throw new ExpectedErr( + 404, + `The table '${tableUrlName}' exists, but isn't user specific. You should be fetching this table from /api/v1/games instead of /api/v1/users/:userID.`, + ); } - AssignToReqTachiData(req, { customBMSTable: customTable }); - - next(); -}; + return customTable; +} /** * Return some HTML for this custom table. @@ -52,51 +47,66 @@ const FindCustomBMSTable: RequestHandler = (req, res, next) => { * @note Since this is the UGPT route, trying to fetch GPT custom tables * will result in a 404. This applies for all subsequent :tableUrlName routes. * - * @name GET /api/v1/users/:userID/games/bms/:playtype/custom-tables/:tableUrlName + * @name GET /api/v1/users/:userID/games/:game/custom-tables/:tableUrlName */ -router.get( - "/:playtype/custom-tables/:tableUrlName", - ValidatePlaytypeFromParamFor("bms"), - FindCustomBMSTable, - (req, res) => { - const customTable = GetTachiData(req, "customBMSTable"); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/custom-tables/:tableUrlName", + withRequestedUserAndReqData, + withGame, + ({ ctx, params, req, res }) => { + const game = ctx.game as GamesForGroup["bms"]; - // This handles returning a response for us. - return HandleBMSTableHTMLRequest(customTable, req, res); + if (GameToGameGroup(game) !== "bms") { + throw new ExpectedErr(404, `No custom tables exist for ${game}.`); + } + + const customTable = resolveUserCustomBMSTableOrThrow(params.tableUrlName, game); + HandleBMSTableHTMLRequest(customTable, req, res); + return success("stub", {}); }, ); /** * Return the header.json for this custom table. * - * @name GET /api/v1/users/:userID/games/bms/:playtype/custom-tables/:tableUrlName/header.json + * @name GET /api/v1/users/:userID/games/:game/custom-tables/:tableUrlName/header.json */ -router.get( - "/:playtype/custom-tables/:tableUrlName/header.json", - ValidatePlaytypeFromParamFor("bms"), - FindCustomBMSTable, - (req, res) => { - const customTable = GetTachiData(req, "customBMSTable"); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/custom-tables/:tableUrlName/header.json", + withRequestedUserAndReqData, + withGame, + async ({ ctx, params, req, res }) => { + const game = ctx.game as GamesForGroup["bms"]; - // This handles returning a response for us. - return HandleBMSTableHeaderRequest(customTable, req, res); + if (GameToGameGroup(game) !== "bms") { + throw new ExpectedErr(404, `No custom tables exist for ${game}.`); + } + + const customTable = resolveUserCustomBMSTableOrThrow(params.tableUrlName, game); + await HandleBMSTableHeaderRequest(customTable, req, res); + return success("stub", {}); }, ); /** * Return the body.json for this custom table. * - * @name GET /api/v1/users/:userID/games/bms/:playtype/custom-tables/:tableUrlName/body.json + * @name GET /api/v1/users/:userID/games/:game/custom-tables/:tableUrlName/body.json */ -router.get( - "/:playtype/custom-tables/:tableUrlName/body.json", - ValidatePlaytypeFromParamFor("bms"), - FindCustomBMSTable, - (req, res) => { - const customTable = GetTachiData(req, "customBMSTable"); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/custom-tables/:tableUrlName/body.json", + withRequestedUserAndReqData, + withGame, + async ({ ctx, params, req, res }) => { + const game = ctx.game as GamesForGroup["bms"]; - // This handles returning a response for us. - return HandleBMSTableBodyRequest(customTable, req, res); + if (GameToGameGroup(game) !== "bms") { + throw new ExpectedErr(404, `No custom tables exist for ${game}.`); + } + + const customTable = resolveUserCustomBMSTableOrThrow(params.tableUrlName, game); + await HandleBMSTableBodyRequest(customTable, req, res); + return success("stub", {}); }, ); @@ -107,50 +117,48 @@ const SHA256_CHECKSUM_LENGTH = "87428fc522803d31065e7bce3cf03fe475096631e5e07bbd /** * Get this user's best chart on the given chart MD5 or SHA256. * - * @name GET /api/v1/users/:userID/games/bms/:playtype/best-score/:checksum + * @name GET /api/v1/users/:userID/games/:game/best-score/:checksum */ -router.get( - "/:playtype/best-score/:checksum", - ValidatePlaytypeFromParamFor("bms"), - async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/best-score/:checksum", + withRequestedUserAndReqData, + withGame, + async ({ ctx, params, req }) => { + const user = REQ_GetUser(req); - if (!req.params.checksum) { - return res.status(400).json({ success: false, description: "No checksum provided." }); + const checksumRaw = params.checksum; + if (!checksumRaw) { + throw new ExpectedErr(400, "No checksum provided."); } - const checksum = req.params.checksum.toLowerCase(); + const checksum = checksumRaw.toLowerCase(); if (!/^[0-9a-f]+$/u.exec(checksum)) { - return res.status(400).json({ - success: false, - description: "Invalid checksum (Was not a MD5 or SHA256 checksum).", - }); + throw new ExpectedErr(400, "Invalid checksum (Was not a MD5 or SHA256 checksum)."); } if (checksum.length !== MD5_CHECKSUM_LENGTH && checksum.length !== SHA256_CHECKSUM_LENGTH) { - return res.status(400).json({ - success: false, - description: "Invalid checksum length (Was not a MD5 or SHA256 checksum).", - }); + throw new ExpectedErr( + 400, + "Invalid checksum length (Was not a MD5 or SHA256 checksum).", + ); } - const v3Game = GamePTToV3("bms", req.params.playtype as Playtypes["bms"]) as Game; + const v3Game = ctx.game; + + if (GameToGameGroup(v3Game) !== "bms") { + throw new ExpectedErr(404, `No BMS charts exist for ${v3Game}.`); + } const chart = await FindBMSChartOnHashInGame(checksum, v3Game); if (!chart) { - return res - .status(404) - .json({ success: false, description: "No chart found with the given checksum." }); + throw new ExpectedErr(404, "No chart found with the given checksum."); } - const pb = await LoadPbByUserAndChartPgId(user.id, chart.chartID); - + const pb = await GetPBOnChart(user.id, chart.chartID); const description = pb ? "Best score found." : "Player has not played this chart."; - return res.status(200).json({ success: true, description, body: pb }); + return success(description, pb); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts index 77061e22d..9a940c8b7 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts @@ -1,33 +1,36 @@ +import type { RequestHandler } from "express"; import type { GetEnumValue } from "tachi-common/types/metrics"; -import { SELECT_CHART } from "#lib/db-formats/chart.js"; -import { SELECT_SONG_DOCUMENT } from "#lib/db-formats/song.js"; +import { SELECT_CHART } from "#lib/db-formats/chart"; +import { SELECT_SONG_DOCUMENT } from "#lib/db-formats/song"; import { CUSTOM_TACHI_IIDX_PLAYLISTS, type TachiIIDXPlaylist, } from "#lib/game-specific/iidx-playlists"; +import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { EAM_VERSION_NAMES } from "#lib/score-import/import-types/common/eamusement-iidx-csv/parser"; import { AggressiveRateLimitMiddleware } from "#server/middleware/rate-limiter"; -import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware"; -import DB from "#services/pg/db.js"; -import { GetUser } from "#utils/req-tachi-data"; -import { ISO8601ToUnixMilliseconds } from "#utils/time.js"; -import { Router } from "express"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import { GetUserFromParam } from "#server/router/api/v1/users/_userID/middleware"; +import DB from "#services/pg/db"; +import { REQ_AssignToReqTachiData, REQ_GetUser } from "#utils/req-tachi-data"; +import { ISO8601ToUnixMilliseconds } from "#utils/time"; +import { ExpectedErr } from "bliss"; import _ from "lodash"; import { EnumIndexToValue, - GamePTToV3, + type GamesForGroup, + GameToGameGroup, + IsValidGame, type PgScoreData, - type Playtypes, type SongDocumentData, + type V3Game, } from "tachi-common"; -const router: Router = Router({ mergeParams: true }); - const EAMUSEMENT_CSV_HEADER = `バージョン,タイトル,ジャンル,アーティスト,プレー回数,BEGINNER 難易度,BEGINNER スコア,BEGINNER PGreat,BEGINNER Great,BEGINNER ミスカウント,BEGINNER クリアタイプ,BEGINNER DJ LEVEL,NORMAL 難易度,NORMAL スコア,NORMAL PGreat,NORMAL Great,NORMAL ミスカウント,NORMAL クリアタイプ,NORMAL DJ LEVEL,HYPER 難易度,HYPER スコア,HYPER PGreat,HYPER Great,HYPER ミスカウント,HYPER クリアタイプ,HYPER DJ LEVEL,ANOTHER 難易度,ANOTHER スコア,ANOTHER PGreat,ANOTHER Great,ANOTHER ミスカウント,ANOTHER クリアタイプ,ANOTHER DJ LEVEL,LEGGENDARIA 難易度,LEGGENDARIA スコア,LEGGENDARIA PGreat,LEGGENDARIA Great,LEGGENDARIA ミスカウント,LEGGENDARIA クリアタイプ,LEGGENDARIA DJ LEVEL,最終プレー日時`; -function ConvertEamGrade(grade: GetEnumValue<"iidx:DP" | "iidx:SP", "grade">) { - // eamusement has no concept of max or max-. +function ConvertEamGrade(grade: GetEnumValue) { if (grade === "MAX" || grade === "MAX-") { return "AAA"; } @@ -35,187 +38,205 @@ function ConvertEamGrade(grade: GetEnumValue<"iidx:DP" | "iidx:SP", "grade">) { return grade; } -function ConvertEamLamp(lamp: GetEnumValue<"iidx:DP" | "iidx:SP", "lamp">) { +function ConvertEamLamp(lamp: GetEnumValue) { if (lamp === "FULL COMBO") { - return "FULLCOMBO CLEAR"; // weird, but ok + return "FULLCOMBO CLEAR"; } return lamp; } +const handleEamusementCsv: RequestHandler = async (req, res) => { + const gameParam = req.params.game; + if (!gameParam) { + throw new ExpectedErr(400, "No game provided."); + } + if (!IsValidGame(gameParam)) { + throw new ExpectedErr(400, `The game ${gameParam} is not supported.`); + } + if (GameToGameGroup(gameParam as V3Game) !== "iidx") { + throw new ExpectedErr(404, `No e-amusement CSV exists for ${gameParam}.`); + } + + const v3Game = gameParam as "iidx-dp" | "iidx-sp"; + const user = REQ_GetUser(req); + + const rows = [EAMUSEMENT_CSV_HEADER]; + + const playerChartPbs = await DB.selectFrom("chart") + .leftJoin("pb", "pb.chart_id", "chart.id") + .innerJoin("song", "song.id", "chart.song_id") + .select(SELECT_SONG_DOCUMENT) + .select(SELECT_CHART) + .select([ + "pb.data as pb_data", + "pb.derived_data as pb_derived_data", + "pb.calculated_data as pb_calculated_data", + "pb.time_achieved as pb_time_achieved", + "pb.judgements as pb_judgements", + ]) + .where("game", "=", v3Game) + .where("user_id", "=", user.id) + .where( + "chart.difficulty", + // seems bizarre, but this is to exclude out 2dxtra charts. + "in", + ["BEGINNER", "NORMAL", "HYPER", "ANOTHER", "LEGGENDARIA"], + ) + .execute(); + + const groupings = _.groupBy(playerChartPbs, "song_id"); + + for (const [_songId, songPbs] of Object.entries(groupings)) { + let version = "UNKNOWN"; + const firstChart = songPbs[0]; + const songData = firstChart.song_data as SongDocumentData["iidx"]; + const tachiVer = songData.displayVersion; + + if (tachiVer !== null) { + // @ts-expect-error We're abusing enums which already aren't meant + // for this kind of lookup task. Ah well! + version = EAM_VERSION_NAMES[tachiVer] ?? tachiVer; + } + + const songTitle = songData.eamusementCsvTitle ?? firstChart.song_title; + const songArtist = songData.eamusementCsvArtist ?? firstChart.song_artist; + const songGenre = songData.eamusementCsvGenre ?? songData.genre; + + const row = [ + version, + songTitle, + songGenre, + songArtist, + "0", // always 0, who cares? + ]; + + let lastPlayed = 0; + + for (const difficulty of [ + "BEGINNER", + "NORMAL", + "HYPER", + "ANOTHER", + "LEGGENDARIA", + ] as const) { + const pbInfo = songPbs.find((e) => e.chart_difficulty === difficulty); + const pbData = pbInfo?.pb_data as + | PgScoreData<"iidx-dp" | "iidx-sp">["data"] + | null + | undefined; + const pbDerivedData = pbInfo?.pb_derived_data as + | PgScoreData<"iidx-dp" | "iidx-sp">["derived"] + | null + | undefined; + const pbJudgements = pbInfo?.pb_judgements as + | PgScoreData<"iidx-dp" | "iidx-sp">["judgements"] + | null + | undefined; + + if (!pbInfo || !pbData || !pbDerivedData || !pbJudgements) { + row.push( + pbInfo?.chart_level ?? "0", // level + "0", // ex + "0", // pgreat + "0", // great + "---", // BP + "NO PLAY", // lamp + "---", // grade + ); + continue; + } + + row.push( + pbInfo.chart_level ?? "0", + pbData.score.toString(), // ex + pbJudgements?.pgreat?.toString() ?? "0", // pgreat + pbJudgements?.great?.toString() ?? "0", // great + pbData.bp?.toString() ?? "0", // BP + ConvertEamLamp(EnumIndexToValue(v3Game, "lamp", pbData.lamp)), // lamp + ConvertEamGrade(EnumIndexToValue(v3Game, "grade", pbDerivedData.grade)), // grade + ); + + if ( + pbInfo.pb_time_achieved !== null && + lastPlayed < ISO8601ToUnixMilliseconds(pbInfo.pb_time_achieved) + ) { + lastPlayed = ISO8601ToUnixMilliseconds(pbInfo.pb_time_achieved); + } + } + + // last played. This will be 1970-01-01 if this user has never played this chart + // with a timestamp. + row.push(new Date(lastPlayed).toISOString()); + + // IIDX uses a "naive" CSV format. that is to say -- there's no escaping. + // God forbid a song title like "19, november" get output here, because it will + // just break the format. That's what the official site does though. + // bug-for-bug compatibility! + // at the very least, we'll replace , with \,. That should be fine. + rows.push(row.map((e) => e.replace(/,/gu, "\\,")).join(",")); + } + + return res.status(200).json({ + success: true, + description: `Created e-amusement CSV.`, + body: rows.join("\n"), + }); +}; + /** * Retrieve this users PBs in eamusement CSV format. * * @name GET /api/v1/users/:userID/games/iidx/:playtype/eamusement-csv */ -router.get( - "/:playtype/eamusement-csv", - ValidatePlaytypeFromParamFor("iidx"), +API_V1_ROUTER.rawAdd( + "GET", + "/users/:userID/games/:game/eamusement-csv", + GetUserFromParam, AggressiveRateLimitMiddleware, - async (req, res) => { - const playtype = req.params.playtype as Playtypes["iidx"]; - const v3Game = GamePTToV3("iidx", playtype) as "iidx-dp" | "iidx-sp"; - const user = GetUser(req); - - const rows = [EAMUSEMENT_CSV_HEADER]; - - // get all relevant charts - const playerChartPbs = await DB.selectFrom("chart") - .leftJoin("pb", "pb.chart_id", "chart.id") - .innerJoin("song", "song.id", "chart.song_id") - .select(SELECT_SONG_DOCUMENT) - .select(SELECT_CHART) - .select([ - "pb.data as pb_data", - "pb.derived_data as pb_derived_data", - "pb.calculated_data as pb_calculated_data", - "pb.time_achieved as pb_time_achieved", - "pb.judgements as pb_judgements", - ]) - .where("game", "=", v3Game) - .where("user_id", "=", user.id) - - // seems bizarre, but this is to exclude out 2dxtra charts. - .where("chart.difficulty", "in", [ - "BEGINNER", - "NORMAL", - "HYPER", - "ANOTHER", - "LEGGENDARIA", - ]) - .execute(); - - const groupings = _.groupBy(playerChartPbs, "song_id"); - - for (const [_songId, songPbs] of Object.entries(groupings)) { - let version = "UNKNOWN"; - const firstChart = songPbs[0]; - const songData = firstChart.song_data as SongDocumentData["iidx"]; - const tachiVer = songData.displayVersion; - - if (tachiVer !== null) { - // @ts-expect-error We're abusing enums which already aren't meant - // for this kind of lookup task. Ah well! - version = EAM_VERSION_NAMES[tachiVer] ?? tachiVer; - } - - const songTitle = songData.eamusementCsvTitle ?? firstChart.song_title; - const songArtist = songData.eamusementCsvArtist ?? firstChart.song_artist; - const songGenre = songData.eamusementCsvGenre ?? songData.genre; - - const row = [ - version, - songTitle, - songGenre, - songArtist, - "0", // always 0, who cares? - ]; - - let lastPlayed = 0; - - for (const difficulty of [ - "BEGINNER", - "NORMAL", - "HYPER", - "ANOTHER", - "LEGGENDARIA", - ] as const) { - const pbInfo = songPbs.find((e) => e.chart_difficulty === difficulty); - const pbData = pbInfo?.pb_data as - | PgScoreData<"iidx-dp" | "iidx-sp">["data"] - | null - | undefined; - const pbDerivedData = pbInfo?.pb_derived_data as - | PgScoreData<"iidx-dp" | "iidx-sp">["derived"] - | null - | undefined; - const pbJudgements = pbInfo?.pb_judgements as - | PgScoreData<"iidx-dp" | "iidx-sp">["judgements"] - | null - | undefined; - - if (!pbInfo || !pbData || !pbDerivedData || !pbJudgements) { - row.push( - pbInfo?.chart_level ?? "0", // level - "0", // ex - "0", // pgreat - "0", // great - "---", // BP - "NO PLAY", // lamp - "---", // grade - ); - continue; - } - - row.push( - pbInfo.chart_level ?? "0", - pbData.score.toString(), // ex - pbJudgements?.pgreat?.toString() ?? "0", // pgreat - pbJudgements?.great?.toString() ?? "0", // great - pbData.bp?.toString() ?? "0", // BP - ConvertEamLamp(EnumIndexToValue(v3Game, "lamp", pbData.lamp)), // lamp - ConvertEamGrade(EnumIndexToValue(v3Game, "grade", pbDerivedData.grade)), // grade - ); - - if ( - pbInfo.pb_time_achieved !== null && - lastPlayed < ISO8601ToUnixMilliseconds(pbInfo.pb_time_achieved) - ) { - lastPlayed = ISO8601ToUnixMilliseconds(pbInfo.pb_time_achieved); - } - } - - // last played. This will be 1970-01-01 if this user has never played this chart - // with a timestamp. - row.push(new Date(lastPlayed).toISOString()); - - // IIDX uses a "naive" CSV format. that is to say -- there's no escaping. - // God forbid a song title like "19, november" get output here, because it will - // just break the format. That's what the official site does though. - // bug-for-bug compatibility! - // at the very least, we'll replace , with \,. That should be fine. - rows.push(row.map((e) => e.replace(/,/gu, "\\,")).join(",")); - } - - return res.status(200).json({ - success: true, - description: `Created e-amusement CSV.`, - body: rows.join("\n"), - }); - }, + handleEamusementCsv, ); /** * Retrieve this playlist. * - * @name GET /api/v1/users/:userID/games/iidx/:playtype/playlists/:playlistID + * @name GET /api/v1/users/:userID/games/:game/playlists/:playlistID */ -router.get("/:playtype/playlists/:playlistID", async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/playlists/:playlistID", + withRequestedUserAndReqData, + withGame, + async ({ ctx, params, res, req }) => { + const { game } = ctx; + const user = REQ_GetUser(req); - const playlist: TachiIIDXPlaylist | undefined = CUSTOM_TACHI_IIDX_PLAYLISTS.find( - (e) => - (e.playtype === null || e.playtype === req.params.playtype) && - e.urlName === req.params.playlistID, - ); + if (GameToGameGroup(game) !== "iidx") { + throw new ExpectedErr(404, `No playlists exist for ${game}.`); + } - if (!playlist) { - return res.status(404).json({ - success: false, - description: `No such playlist '${req.params.playlistID}' exists for '${req.params.playtype}'.`, - }); - } + // Maintain backwards-compat for any downstream expecting req tachi data. + REQ_AssignToReqTachiData(req, { game }); - if (playlist.forSpecificUser !== true) { - return res.status(404).json({ - success: false, - description: `This playlist is not for a specific user. Use the /games/:game endpoint instead.`, - }); - } + const playlist: TachiIIDXPlaylist | undefined = CUSTOM_TACHI_IIDX_PLAYLISTS.find( + (e) => (e.game === null || e.game === game) && e.urlName === params.playlistID, + ); - const body = await playlist.getPlaylists(user.id, req.params.playtype as "DP" | "SP"); + if (!playlist) { + throw new ExpectedErr( + 404, + `No such playlist '${params.playlistID}' exists for '${game}'.`, + ); + } - return res.status(200).json(body); -}); + if (playlist.forSpecificUser !== true) { + throw new ExpectedErr( + 404, + `This playlist is not for a specific user. Use the /games/:game endpoint instead.`, + ); + } -export default router; + const body = await playlist.getPlaylists(user.id, game as "iidx-dp" | "iidx-sp"); + res.status(200).json(body); + return success("unused", null); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts index 6cb4223fe..381818f95 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts @@ -1,35 +1,39 @@ import { GetPBsForJubility } from "#game-implementations/games/jubeat"; +import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { GetRelevantSongsAndCharts } from "#utils/db"; -import { GetUser } from "#utils/req-tachi-data"; -import { Router } from "express"; - -const router: Router = Router({ mergeParams: true }); +import { REQ_GetUser } from "#utils/req-tachi-data"; +import { ExpectedErr } from "bliss"; +import { GameToGameGroup } from "tachi-common"; /** * Retrieve the PBs that went into this users jubility ranking. * - * @name GET /api/v1/users/:userID/games/jubeat/Single/jubility + * @name GET /api/v1/users/:userID/games/:game/jubility */ -router.get("/Single/jubility", async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/jubility", + withRequestedUserAndReqData, + withGame, + async ({ req, ctx }) => { + if (GameToGameGroup(ctx.game) !== "jubeat") { + throw new ExpectedErr(404, `No jubility data exists for ${ctx.game}.`); + } - const { bestHotScores, bestScores } = await GetPBsForJubility(user.id); + const user = REQ_GetUser(req); - const { songs, charts } = await GetRelevantSongsAndCharts( - [...bestHotScores, ...bestScores], - "jubeat", - ); + const { bestHotScores, bestScores } = await GetPBsForJubility(user.id); + const { songs, charts } = await GetRelevantSongsAndCharts([ + ...bestHotScores, + ...bestScores, + ]); - return res.status(200).json({ - success: true, - description: `Retrieved scores that went into this users jubility.`, - body: { + return success(`Retrieved scores that went into this users jubility.`, { songs, charts, pickUp: bestHotScores, other: bestScores, - }, - }); -}); - -export default router; + }); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts index 51e91822a..d51f32a1b 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts @@ -2,16 +2,4 @@ // and playtypes. This is for things like - say - eamusement exports, which only make sense // for certain games. -import { Router } from "express"; - -import bmsRouter from "./bms/router"; -import iidxRouter from "./iidx/router"; -import jubeatRouter from "./jubeat/router"; - -const router: Router = Router({ mergeParams: true }); - -router.use("/bms", bmsRouter); -router.use("/iidx", iidxRouter); -router.use("/jubeat", jubeatRouter); - -export default router; +// -- no routes -- diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.oldtest.ts deleted file mode 100644 index 1d70c4e2f..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.oldtest.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { ChartDocument, FolderDocument, ScoreDocument, SongDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - GetKTDataJSON, - Testing511SPA, - TestingIIDXFolderSP10, - TestingIIDXSPScore, - TestingIIDXSPScorePB, -} from "#test-utils/test-data"; -import { CreateFolderChartLookup } from "#utils/folder"; -import deepmerge from "deepmerge"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - const folder = deepmerge(TestingIIDXFolderSP10, { - folderID: "testing_folder", - }) as FolderDocument; - - await MONGODB_KILL.folders.insert(folder); - await CreateFolderChartLookup(folder, true); - await MONGODB_KILL["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})); - }); - - t.test("Should return a user's PBs on a given folder.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/folders/testing_folder"); - - t.equal(res.body.body.songs.length, 1); - t.equal(res.body.body.charts.length, 1); - t.equal(res.body.body.charts[0].chartID, Testing511SPA.chartID); - t.equal(res.body.body.pbs.length, 1); - t.equal(res.body.body.pbs[0].chartID, Testing511SPA.chartID); - t.equal(res.body.body.folder.folderID, "testing_folder"); - - t.end(); - }); - - t.test("Should return 404 if this folder does not exist.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/folders/fake_folder"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeline", (t) => { - const folder = deepmerge(TestingIIDXFolderSP10, { - folderID: "testing_folder", - }) as FolderDocument; - - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL.folders.insert(folder); - await CreateFolderChartLookup(folder, true); - await MONGODB_KILL["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})); - }); - - t.test("Should return the users scores on this folder in timeline order", async (t) => { - // set up a more realistic scenario so we can fire more data at it. - await MONGODB_KILL.songs.iidx.remove({}); - await MONGODB_KILL.charts.iidx.remove({}); - await MONGODB_KILL["folder-chart-lookup"].remove({}); - await MONGODB_KILL.songs.iidx.insert( - GetKTDataJSON("./tachi/tachi-songs-iidx.json") as Array>, - ); - await MONGODB_KILL.charts.iidx.insert( - GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array< - ChartDocument<"iidx:DP" | "iidx:SP"> - >, - ); - - await CreateFolderChartLookup(folder, true); - - await MONGODB_KILL.scores.insert([ - deepmerge(TestingIIDXSPScore, { - scoreID: "OTHER_SCORE_ID", - timeAchieved: 500, - songID: 5, - chartID: "f3e7f84103d68f9f27193f037f35d0bca8c6d607", - }), - deepmerge(TestingIIDXSPScore, { - scoreID: "OTHER_SCORE_ID_2", - timeAchieved: 100, - songID: 5, - chartID: "f3e7f84103d68f9f27193f037f35d0bca8c6d607", - }), - deepmerge(TestingIIDXSPScore, { - scoreID: "OTHER_SCORE_ID_NULL", - timeAchieved: null, - songID: 5, - chartID: "f3e7f84103d68f9f27193f037f35d0bca8c6d607", - }), - deepmerge(TestingIIDXSPScore, { - scoreData: { - lamp: "EASY CLEAR", - enumIndexes: { - lamp: 3, - }, - }, - scoreID: "OTHER_SCORE_ID_3", - timeAchieved: 50, - songID: 5, - chartID: "f3e7f84103d68f9f27193f037f35d0bca8c6d607", - }), - deepmerge(TestingIIDXSPScore, { - scoreID: "OTHER_SCORE_ID_4", - timeAchieved: 200, - songID: 6, - chartID: "3b5fa295d243c131752e2f1ad835998f6dad2e97", - }), - ]); - - const res = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=lamp&criteriaValue=CLEAR", - ); - - t.equal(res.body.body.scores.length, 3); - - t.equal( - res.body.body.scores[0].scoreID, - "TESTING_SCORE_ID", - "First score should be TESTING_SCORE_ID, as it has a timeAchieved of null.", - ); - t.equal( - res.body.body.scores[1].scoreID, - "OTHER_SCORE_ID_2", - "Second score returned should be OTHER_SCORE_ID_2", - ); - t.equal( - res.body.body.scores[2].scoreID, - "OTHER_SCORE_ID_4", - "Third score should be OTHER_SCORE_ID_4, as it was achieved later.", - ); - t.notOk( - res.body.body.scores.some((k: ScoreDocument) => k.scoreID === "OTHER_SCORE_ID_NULL"), - "OTHER_SCORE_ID_NULL should never appear, as it is a null timestamp for a score that has a real timestamp.", - ); - - t.end(); - }); - - t.test("Should reject invalid criteriaTypes", async (t) => { - const res = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=invalid", - ); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should reject invalid indexes for lamps", async (t) => { - const res = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=lamp&criteriaValue=-1", - ); - - t.equal(res.statusCode, 400); - - const res2 = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=lamp&criteriaValue=0.5", - ); - - t.equal(res2.statusCode, 400); - - const res3 = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=lamp&criteriaValue=100", - ); - - t.equal(res3.statusCode, 400); - - t.end(); - }); - - t.test("Should reject invalid indexes for grades", async (t) => { - const res = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=grade&criteriaValue=-1", - ); - - t.equal(res.statusCode, 400); - - const res2 = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=grade&criteriaValue=0.5", - ); - - t.equal(res2.statusCode, 400); - - const res3 = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=grade&criteriaValue=100", - ); - - t.equal(res3.statusCode, 400); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts deleted file mode 100644 index 51dc9f50a..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts +++ /dev/null @@ -1,171 +0,0 @@ -import prValidate from "#server/middleware/prudence-validate"; -import DB from "#services/pg/db"; -import { GetEnumDistForFolder, GetFolderChartsAndSongs, GetPBsOnFolder } from "#utils/folder"; -import { GetFolderTimelineScores } from "#utils/queries/scores"; -import { GetTachiData, GetUGPT } from "#utils/req-tachi-data"; -import { UnixMillisecondsToISO8601 } from "#utils/time"; -import { Router } from "express"; -import { sql } from "kysely"; -import { GetGamePTConfig, GetScoreMetricConf, ValidateMetric } from "tachi-common"; - -import { GetFolderFromParam } from "../../../../../../../games/_game/_playtype/folders/middleware"; -import { RequireSelfRequestFromUser } from "../../../../../middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(GetFolderFromParam); - -/** - * Returns a users pbs on this folder. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID - */ -router.get("/", async (req, res) => { - const { user } = GetUGPT(req); - - const folder = GetTachiData(req, "folderDoc"); - - const { songs, charts, pbs } = await GetPBsOnFolder(user.id, folder); - - return res.status(200).json({ - success: true, - description: `Returned ${pbs.length} pbs.`, - body: { - songs, - charts, - pbs, - folder, - }, - }); -}); - -/** - * Returns a users stats on this folder. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/stats - */ -router.get("/stats", async (req, res) => { - const { user } = GetUGPT(req); - - const folder = GetTachiData(req, "folderDoc"); - - const stats = await GetEnumDistForFolder(user.id, folder); - - return res.status(200).json({ - success: true, - description: `Returned statistics for ${folder.title}.`, - body: { - folder, - stats, - }, - }); -}); - -/** - * Add a folder to the list of recently-viewed folders. This can only - * be performed by a session-level token, to stop rogue API keys from causing - * trouble. Also, it's a post request, to avoid funny SSRF stuff. - * - * @name POST /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/viewed - */ -router.post("/viewed", RequireSelfRequestFromUser, async (req, res) => { - const { user } = GetUGPT(req); - - const folder = GetTachiData(req, "folderDoc"); - - await DB.insertInto("folder_view") - .values({ - user_id: user.id, - folder_id: folder.folderID, - last_viewed: UnixMillisecondsToISO8601(Date.now()), - }) - .onConflict((oc) => - oc.columns(["user_id", "folder_id"]).doUpdateSet({ - last_viewed: sql`excluded.last_viewed`, - }), - ) - .execute(); - - return res.status(200).json({ - success: true, - description: `Recorded a view on ${folder.title}.`, - body: {}, - }); -}); - -// note: this path is disgustingly long. :( -/** - * Returns the users scores in order of when they met this criteria. - * - * @param criteriaType - Any metric for this GPT. - * @param criteriaValue - Any valid value for that metric. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeline - */ -router.get( - "/timeline", - prValidate({ - criteriaType: "string", - criteriaValue: "string", - }), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - - const folder = GetTachiData(req, "folderDoc"); - const gptConfig = GetGamePTConfig(game, playtype); - - // as asserted by prudence. - const metric = req.query.criteriaType as string; - - const conf = GetScoreMetricConf(gptConfig, metric); - - if (!conf || conf.type !== "ENUM") { - return res.status(400).json({ - success: false, - description: `Invalid metric '${metric}' passed. Expected an ENUM for this game.`, - }); - } - - const criteriaValue = conf.values.indexOf(req.query.criteriaValue as string); - - if (criteriaValue === -1) { - return res.status(400).json({ - success: false, - description: `Invalid criteriaValue of ${req.query.criteriaValue} for ${metric}.`, - }); - } - - const { songs, charts } = await GetFolderChartsAndSongs(folder); - - const err = ValidateMetric(gptConfig, metric, criteriaValue); - - if (typeof err === "string") { - return res.status(400).json({ - success: false, - description: err, - }); - } - - const scores = await GetFolderTimelineScores( - user.id, - game, - playtype, - charts.map((e) => e.chartID), - metric, - criteriaValue, - ); - - return res.status(200).json({ - success: true, - description: `Returned ${scores.length} scores for ${charts.length} charts.`, - body: { - songs, - charts, - scores, - folder, - }, - }); - }, -); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts similarity index 91% rename from typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts rename to typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts index 7be30cf06..972c58b00 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts @@ -1,4 +1,4 @@ -import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup.js"; +import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup"; import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; @@ -6,7 +6,7 @@ import { seedUser } from "#test-utils/pg-fixtures"; import { TestingIIDXSPScore } from "#test-utils/test-data"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import deepmerge from "deepmerge"; -import { type MONGO_ScoreData } from "tachi-common"; +import { type ScoreData } from "tachi-common"; import { afterAll, describe, expect, it } from "vitest"; afterAll(() => CloseServerConnection()); @@ -95,7 +95,7 @@ async function seedFolderWithThreeCharts(opts: { folderId: string; folderLegacy: game: "iidx-sp", inactive: false, title: "TL folder", - slug: null, + slug: opts.folderId, where, version_filter: null, search_terms: [], @@ -110,11 +110,11 @@ async function seedFolderWithThreeCharts(opts: { folderId: string; folderLegacy: async function insertIidxSpScore(opts: { chartId: string; id: string; - scoreData: MONGO_ScoreData<"iidx:SP">; + scoreData: ScoreData<"iidx-sp">; timeAchievedMs: number | null; userId: number; }) { - const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", opts.scoreData); + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", opts.scoreData); const now = new Date().toISOString(); await DB.insertInto("score") @@ -141,7 +141,7 @@ async function insertIidxSpScore(opts: { .execute(); } -describe("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeline", () => { +describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", () => { it("returns one earliest qualifying score per chart, then sorted by time (null as 0)", async () => { const { id: userId } = await seedUser({ username: `folder_tl_${Date.now()}` }); await seedIidxSpProfile(userId); @@ -186,7 +186,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/time scoreData: deepmerge(baseSd, { lamp: "EASY CLEAR", enumIndexes: { lamp: 3, grade: baseSd.enumIndexes?.grade ?? 3 }, - }) as MONGO_ScoreData<"iidx:SP">, + }) as ScoreData<"iidx-sp">, timeAchievedMs: 50, }); await insertIidxSpScore({ @@ -207,7 +207,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/time }); const res = await mockApi.get( - `/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=lamp&criteriaValue=CLEAR`, + `/api/v1/users/${userId}/games/iidx-sp/folders/${folderId}/timeline?criteriaType=lamp&criteriaValue=CLEAR`, ); expect(res.status).toBe(200); @@ -223,7 +223,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/time await seedFolderWithThreeCharts({ folderId, folderLegacy: `${folderId}_leg` }); const res = await mockApi.get( - `/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=not_a_metric&criteriaValue=CLEAR`, + `/api/v1/users/${userId}/games/iidx-sp/folders/${folderId}/timeline?criteriaType=not_a_metric&criteriaValue=CLEAR`, ); expect(res.status).toBe(400); @@ -236,7 +236,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/time await seedFolderWithThreeCharts({ folderId, folderLegacy: `${folderId}_leg` }); const res = await mockApi.get( - `/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=lamp&criteriaValue=nope`, + `/api/v1/users/${userId}/games/iidx-sp/folders/${folderId}/timeline?criteriaType=lamp&criteriaValue=nope`, ); expect(res.status).toBe(400); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts new file mode 100644 index 000000000..dc9d1bfd5 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts @@ -0,0 +1,168 @@ +import { LoadFolderDocumentByGameAndSlug } from "#lib/db-formats/folders"; +import { + GetEnumDistForFolders, + GetFolderChartsAndSongs, + GetPBsOnFolder, +} from "#lib/folders/folders"; +import { withSelf, withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import DB from "#services/pg/db"; +import { GetFolderTimelineScores } from "#utils/queries/scores"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import { ExpectedErr } from "bliss"; +import { sql } from "kysely"; +import { GetGameConfig, GetScoreMetricConf, ValidateMetric } from "tachi-common"; + +/** + * Returns user charts/songs/stats for a specific folder. + * + * @name GET /api/v1/users/:userID/games/:game/folders/:folderSlug + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/folders/:folderSlug", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; + + const folder = await LoadFolderDocumentByGameAndSlug(game, params.folderSlug); + + if (!folder) { + throw new ExpectedErr(404, "This folder does not exist."); + } + + const { charts, pbs, songs } = await GetPBsOnFolder(user.id, folder); + const stats = await GetEnumDistForFolders(user.id, [folder]); + + return success(`Returned data for folder ${folder.title}`, { + charts, + folder, + songs, + stats: stats[0] ?? null, + pbs, + }); + }, +); + +/** + * Returns aggregated enum stats for the user on this folder. + * + * @name GET /api/v1/users/:userID/games/:game/folders/:folderSlug/stats + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/folders/:folderSlug/stats", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser, game } = ctx; + + const folder = await LoadFolderDocumentByGameAndSlug(game, params.folderSlug); + + if (!folder || folder.game !== game) { + throw new ExpectedErr(404, "This folder does not exist."); + } + + const stats = await GetEnumDistForFolders(requestedUser.id, [folder]); + + return success(`Returned statistics for ${folder.title}.`, { + folder, + stats: stats[0] ?? null, + }); + }, +); + +/** + * Record that the user viewed this folder. Requires session-level auth as this user. + * + * @name POST /api/v1/users/:userID/games/:game/folders/:folderSlug/viewed + */ +API_V1_ROUTER.add( + "POST /users/:userID/games/:game/folders/:folderSlug/viewed", + withSelf, + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; + + const folder = await LoadFolderDocumentByGameAndSlug(game, params.folderSlug); + + if (!folder || folder.game !== game) { + throw new ExpectedErr(404, "This folder does not exist."); + } + + await DB.insertInto("folder_view") + .values({ + folder_id: folder.folderID, + last_viewed: UnixMillisecondsToISO8601(Date.now()), + user_id: user.id, + }) + .onConflict((oc) => + oc.columns(["user_id", "folder_id"]).doUpdateSet({ + last_viewed: sql`excluded.last_viewed`, + }), + ) + .execute(); + + return success(`Recorded a view on ${folder.title}.`, {}); + }, +); + +/** + * Returns the users scores in order of when they met the given criteria. + * + * @name GET /api/v1/users/:userID/games/:game/folders/:folderSlug/timeline + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/folders/:folderSlug/timeline", + withUserGameProfile, + async ({ ctx, params, input }) => { + const { requestedUser: user, game } = ctx; + + const folder = await LoadFolderDocumentByGameAndSlug(game, params.folderSlug); + + if (!folder) { + throw new ExpectedErr(404, "This folder does not exist."); + } + + const gameConfig = GetGameConfig(game); + const metric = input.criteriaType; + const conf = GetScoreMetricConf(gameConfig, metric); + + if (!conf || conf.type !== "ENUM") { + throw new ExpectedErr( + 400, + `Invalid metric '${metric}' passed. Expected an ENUM for this game.`, + ); + } + + const criteriaValue = conf.values.indexOf(input.criteriaValue); + + if (criteriaValue === -1) { + throw new ExpectedErr( + 400, + `Invalid criteriaValue of ${input.criteriaValue} for ${metric}.`, + ); + } + + const err = ValidateMetric(gameConfig, metric, criteriaValue); + + if (typeof err === "string") { + throw new ExpectedErr(400, err); + } + + const { songs, charts } = await GetFolderChartsAndSongs(folder); + + const scores = await GetFolderTimelineScores( + user.id, + game, + charts.map((e) => e.chartID), + metric, + criteriaValue, + ); + + return success(`Returned ${scores.length} scores for ${charts.length} charts.`, { + charts, + folder, + scores, + songs, + }); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts index c52759a6b..8a7ae829c 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts @@ -1,80 +1,50 @@ -import { SearchFoldersForGameFtsAndTrgm } from "#lib/search/folders.js"; -import { GetEnumDistForFolders, GetRecentlyViewedFolders } from "#utils/folder"; -import { IsString } from "#utils/misc"; -import { GetUGPT } from "#utils/req-tachi-data"; -import { Router } from "express"; - -import folderIDRouter from "./_folderID/router"; - -const router: Router = Router({ mergeParams: true }); +import { GetEnumDistForFolders, GetRecentlyViewedFolders } from "#lib/folders/folders"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { SearchFoldersForGameFtsAndTrgm } from "#lib/search/folders"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; /** - * Search folders, and supplant a users grade+lamp distribution on that - * folder as part of the returns. + * Search folders with user grade+lamp distribution. * - * This is a "beefed-up" version of GPT /folders, but with this users - * stats returned at the same time. - * - * @param search - What to search for. - * @param inactive - Also show inactive folders, such as those on old versions. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/folders + * @name GET /api/v1/users/:userID/games/:game/folders */ -router.get("/", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/folders", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; - if (!IsString(req.query.search)) { - return res.status(400).json({ - success: false, - description: `Invalid value for search.`, + const onlyActiveFolders = input.inactive === undefined; + const folders = await SearchFoldersForGameFtsAndTrgm(game, input.search, { + limit: 20, + onlyActiveFolders, }); - } - const onlyActiveFolders = req.query.inactive === undefined; + const stats = await GetEnumDistForFolders(user.id, folders); - const folders = await SearchFoldersForGameFtsAndTrgm(game, playtype, req.query.search, { - limit: 20, - onlyActiveFolders, - }); - - const stats = await GetEnumDistForFolders(user.id, folders); - - return res.status(200).json({ - success: true, - description: `Returned ${stats.length} folders.`, - body: { - folders, - stats, - }, - }); -}); + return success(`Returned ${stats.length} folders.`, { folders, stats }); + }, +); /** * Get a users most recently interacted with folders. * - * A folder is interacted with if it is directly fetched using a session key. - * This - in UI terms - is when the user clicks on that folder. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/folders/recent + * @name GET /api/v1/users/:userID/games/:game/folders/recent */ -router.get("/recent", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/folders/recent", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const { views, folders } = await GetRecentlyViewedFolders(user.id, game, playtype); + const { views, folders } = await GetRecentlyViewedFolders(user.id, game); + const stats = await GetEnumDistForFolders(user.id, folders); - const stats = await GetEnumDistForFolders(user.id, folders); - - return res.status(200).json({ - success: true, - description: `Returned ${views.length} recently interacted with folders.`, - body: { - views, + return success(`Returned ${views.length} recently interacted with folders.`, { folders, stats, - }, - }); -}); - -router.use("/:folderID", folderIDRouter); - -export default router; + views, + }); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/middleware.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/middleware.ts deleted file mode 100644 index 2f96db93d..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/middleware.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { RequestHandler } from "express"; - -import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js"; -import DB from "#services/pg/db"; -import { IsValidGame, IsValidPlaytype } from "#utils/misc"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; -import { GamePTToV3 } from "tachi-common"; - -export const CheckUserPlayedGamePlaytype: RequestHandler = async (req, res, next) => { - const user = GetTachiData(req, "requestedUser"); - - const game = req.params.game; - const playtype = req.params.playtype; - - if (game === undefined) { - throw new Error( - `Unexpected lack of game parameter when CheckUserPlayedGamePlaytype called on ${req.originalUrl}.`, - ); - } - - if (playtype === undefined) { - throw new Error( - `Unexpected lack of playtype parameter when CheckUserPlayedGamePlaytype called on ${req.originalUrl}.`, - ); - } - - if (!IsValidGame(game)) { - return res.status(400).json({ - success: false, - description: `The game ${req.params.game} is not supported.`, - }); - } - - if (!IsValidPlaytype(game, playtype)) { - return res.status(400).json({ - success: false, - description: `The game ${game} does not have a playtype called ${playtype}.`, - }); - } - - const v3Game = GamePTToV3(game, playtype); - - const row = await DB.selectFrom("game_profile") - .select(SELECT_GAME_PROFILE) - .where("user_id", "=", user.id) - .where("game", "=", v3Game) - .executeTakeFirst(); - - if (!row) { - return res.status(404).json({ - success: false, - description: `The user ${user.username} has not played ${req.params.game} (${req.params.playtype})`, - }); - } - - const stats = ToGameStatsDocument(row); - - AssignToReqTachiData(req, { - requestedUserGameStats: stats, - game, - playtype, - }); - - next(); -}; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.oldtest.ts deleted file mode 100644 index 973989628..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.oldtest.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { ChartDocument, PBScoreDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - GetKTDataJSON, - LoadTachiIIDXData, - Testing511Song, - Testing511SPA, - TestingIIDXSPScorePB, -} from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs/best", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a user's best 100 personal bests.", async (t) => { - const mockPBs: Array = []; - - for (let i = 0; i < 200; i++) { - mockPBs.push({ - userID: 1, - game: "iidx", - playtype: "SP", - isPrimary: true, - - // hack to generate some random chartIDs - chartID: i.toString(), - - songID: Testing511Song.id, - calculatedData: { - ktLampRating: i, - }, - } as PBScoreDocument); - } - - await MONGODB_KILL["personal-bests"].insert(mockPBs); - - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs/best"); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 100 personal bests.", - body: { - pbs: mockPBs.slice(100).reverse(), - songs: [Testing511Song], - charts: [], - }, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should return 400 if no search param is given", async (t) => { - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs"); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.test("Should return 400 if invalid search param is given", async (t) => { - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/pbs?search=foo&search=bar", - ); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - const res2 = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/pbs?search[$where]=process.exit(1)", - ); - - t.equal(res2.statusCode, 400); - t.equal(res2.body.success, false); - - t.end(); - }); - - t.test("Should search a user's personal bests.", async (t) => { - const mockPBs: Array = []; - - const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array; - - for (let i = 0; i < 200; i++) { - const chart = charts[i]; - - if (!chart) { - return t.fail( - `Not enough charts in tachi-charts-iidx.json to mock pb data? Failed at index ${i}.`, - ); - } - - mockPBs.push({ - userID: 1, - game: "iidx", - playtype: "SP", - isPrimary: true, - chartID: chart.chartID, - songID: chart.songID, - calculatedData: { - ktLampRating: i, - }, - } as PBScoreDocument); - } - - await MONGODB_KILL["personal-bests"].insert(mockPBs); - - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs?search=5.1.1."); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 4 personal bests.", - body: { - pbs: [ - { - chartID: "71865a2b6d3581decf076ae83c6621302c4bb271", - }, - { - chartID: "952805894d7d78257e87019426fa1d87aec834a5", - }, - { - chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", - }, - { - chartID: "c641238220d73faf82659513ba03bde71b0b45f0", - }, - ], - songs: [ - { - title: "5.1.1.", - }, - ], - charts: [ - { - chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", - }, - { - chartID: "71865a2b6d3581decf076ae83c6621302c4bb271", - }, - { - chartID: "c641238220d73faf82659513ba03bde71b0b45f0", - }, - { - chartID: "952805894d7d78257e87019426fa1d87aec834a5", - }, - ], - }, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should retrieve the PB at this chart ID.", async (t) => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await mockApi.get(`/api/v1/users/1/games/iidx/SP/pbs/${Testing511SPA.chartID}`); - - t.equal(res.body.body.pb.chartID, Testing511SPA.chartID); - t.equal(res.body.body.chart.chartID, Testing511SPA.chartID); - - t.end(); - }); - - t.test("Should retrieve composed scores if param is set.", async (t) => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/pbs/${Testing511SPA.chartID}?getComposition=true`, - ); - - t.equal(res.body.body.pb.chartID, Testing511SPA.chartID); - t.equal(res.body.body.chart.chartID, Testing511SPA.chartID); - t.equal(res.body.body.scores.length, 1); - t.equal(res.body.body.scores[0].scoreID, "TESTING_SCORE_ID"); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/users/:userID/games/:game/:playtype/pbs/resolve", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test( - "Should resolve a chart and return user's PB using tachiSongID matchType.", - async (t) => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await mockApi.post("/api/v1/users/1/games/iidx/SP/pbs/resolve").send({ - matchType: "tachiSongID", - identifier: "1", - difficulty: "ANOTHER", - }); - - t.equal(res.statusCode, 200); - t.equal(res.body.success, true); - t.equal(res.body.body.pb.chartID, Testing511SPA.chartID); - t.equal(res.body.body.chart.chartID, Testing511SPA.chartID); - t.equal(res.body.body.song.id, 1); - t.equal(res.body.body.song.title, "5.1.1."); - - t.end(); - }, - ); - - t.test("Should resolve a chart and return user's PB using songTitle matchType.", async (t) => { - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await mockApi.post("/api/v1/users/1/games/iidx/SP/pbs/resolve").send({ - matchType: "songTitle", - identifier: "5.1.1.", - difficulty: "ANOTHER", - }); - - t.equal(res.statusCode, 200); - t.equal(res.body.success, true); - t.equal(res.body.body.pb.chartID, Testing511SPA.chartID); - t.equal(res.body.body.chart.chartID, Testing511SPA.chartID); - t.equal(res.body.body.song.id, 1); - - t.end(); - }); - - t.test("Should return 404 when chart cannot be resolved.", async (t) => { - const res = await mockApi.post("/api/v1/users/1/games/iidx/SP/pbs/resolve").send({ - matchType: "tachiSongID", - identifier: "99999", - difficulty: "ANOTHER", - }); - - t.equal(res.statusCode, 404); - t.equal(res.body.success, false); - t.match(res.body.description, /Could not resolve this chart/u); - - t.end(); - }); - - t.test("Should return 404 when user has no PB on resolved chart.", async (t) => { - // Don't insert any PBs - user hasn't played this chart - - const res = await mockApi.post("/api/v1/users/1/games/iidx/SP/pbs/resolve").send({ - matchType: "tachiSongID", - identifier: "1", - difficulty: "ANOTHER", - }); - - t.equal(res.statusCode, 404); - t.equal(res.body.success, false); - t.match(res.body.description, /has not played this chart/u); - - t.end(); - }); - - t.test("Should return 400 for invalid request body.", async (t) => { - const res = await mockApi.post("/api/v1/users/1/games/iidx/SP/pbs/resolve").send({ - matchType: "invalidMatchType", - identifier: "1", - }); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.test("Should return 400 when required fields are missing.", async (t) => { - const res = await mockApi.post("/api/v1/users/1/games/iidx/SP/pbs/resolve").send({ - matchType: "tachiSongID", - // missing identifier - }); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts index b06859900..b3044e61b 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts @@ -2,7 +2,7 @@ import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; import { seedUser } from "#test-utils/pg-fixtures"; -import { type MONGO_ScoreData } from "tachi-common"; +import { type ScoreData } from "tachi-common"; import { afterAll, describe, expect, it } from "vitest"; afterAll(() => CloseServerConnection()); @@ -59,14 +59,14 @@ async function seedIidxChartPb(opts: { userId: number; withComposition?: boolean const now = new Date().toISOString(); if (opts.withComposition) { - const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", { + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", { grade: "AAA", lamp: "EX HARD CLEAR", percent: 90, score: 1400, optional: {}, judgements: {}, - } as MONGO_ScoreData<"iidx:SP">); + } as ScoreData<"iidx-sp">); await DB.insertInto("score") .values({ @@ -163,14 +163,14 @@ async function insertPbOnChart(opts: { .execute(); } -describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/all", () => { +describe("GET /api/v1/users/:userID/games/:game/pbs/all", () => { it("returns primary-chart PBs with songs and charts", async () => { const { id: userId } = await seedUser({ username: "ugpt_pb_all" }); await seedIidxSpProfile(userId); await seedIidxChartPb({ userId }); await seedIidxChartPb({ userId }); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/pbs/all`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/pbs/all`); expect(res.status).toBe(200); expect(res.body.body.pbs.length).toBeGreaterThanOrEqual(2); @@ -179,7 +179,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/all", () => { }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/best", () => { +describe("GET /api/v1/users/:userID/games/:game/pbs/best", () => { it("orders primary PBs by ktLampRating descending by default", async () => { const { id: userId } = await seedUser({ username: "ugpt_pb_best" }); await seedIidxSpProfile(userId); @@ -201,7 +201,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/best", () => { calculatedData: { ktLampRating: 99 }, }); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/pbs/best`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/pbs/best`); expect(res.status).toBe(200); expect(res.body.body.pbs[0].calculatedData.ktLampRating).toBe(99); @@ -209,7 +209,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/best", () => { }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/rivals", () => { +describe("GET /api/v1/users/:userID/games/:game/pbs/:chartID/rivals", () => { it("returns rival PBs and the user PB on the chart", async () => { const { id: mainId } = await seedUser({ username: "ugpt_pb_main" }); const { id: rivalId } = await seedUser({ username: "ugpt_pb_rival" }); @@ -229,7 +229,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/rivals", }); const res = await mockApi.get( - `/api/v1/users/${mainId}/games/iidx/SP/pbs/${chartPg}/rivals`, + `/api/v1/users/${mainId}/games/iidx-sp/pbs/${chartPg}/rivals`, ); expect(res.status).toBe(200); @@ -238,7 +238,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/rivals", }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/leaderboard-adjacent", () => { +describe("GET /api/v1/users/:userID/games/:game/pbs/:chartID/leaderboard-adjacent", () => { it("returns adjacent ladder ranks around the user", async () => { const { id: u1 } = await seedUser({ username: "ugpt_pb_lb1" }); const { id: u2 } = await seedUser({ username: "ugpt_pb_lb2" }); @@ -286,7 +286,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/leaderboa await insertPbOnChart({ userId: u3, chartPg, calculatedData: {}, rankingValue: 100 }); const res = await mockApi.get( - `/api/v1/users/${u2}/games/iidx/SP/pbs/${chartPg}/leaderboard-adjacent`, + `/api/v1/users/${u2}/games/iidx-sp/pbs/${chartPg}/leaderboard-adjacent`, ); expect(res.status).toBe(200); @@ -301,13 +301,13 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/leaderboa }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID", () => { +describe("GET /api/v1/users/:userID/games/:game/pbs/:chartID", () => { it("returns 404 when the chart does not exist", async () => { const { id } = await seedUser({ username: "ugpt_pb_chart_missing" }); await seedIidxSpProfile(id); const res = await mockApi.get( - `/api/v1/users/${id}/games/iidx/SP/pbs/00000000-0000-0000-0000-000000000000`, + `/api/v1/users/${id}/games/iidx-sp/pbs/00000000-0000-0000-0000-000000000000`, ); expect(res.status).toBe(404); @@ -352,7 +352,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID", () => { }) .execute(); - const res = await mockApi.get(`/api/v1/users/${targetId}/games/iidx/SP/pbs/${chartPg}`); + const res = await mockApi.get(`/api/v1/users/${targetId}/games/iidx-sp/pbs/${chartPg}`); expect(res.status).toBe(404); expect(res.body.description).toContain("not played"); @@ -363,7 +363,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID", () => { await seedIidxSpProfile(targetId); const { chartPg } = await seedIidxChartPb({ userId: targetId }); - const res = await mockApi.get(`/api/v1/users/${targetId}/games/iidx/SP/pbs/${chartPg}`); + const res = await mockApi.get(`/api/v1/users/${targetId}/games/iidx-sp/pbs/${chartPg}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -380,7 +380,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID", () => { }); const res = await mockApi.get( - `/api/v1/users/${targetId}/games/iidx/SP/pbs/${chartPg}?getComposition=1`, + `/api/v1/users/${targetId}/games/iidx-sp/pbs/${chartPg}?getComposition=1`, ); expect(res.status).toBe(200); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts index 4d52db2bf..5d1808042 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts @@ -1,319 +1,250 @@ -import { GetChartById } from "#lib/db-formats/chart.js"; +import { GetChartByIdForGame } from "#lib/db-formats/chart"; import { - LoadPbByUserAndChartPgId, + GetPBOnChart, LoadPbDocumentsForUserPrimaryCharts, LoadPbDocumentsForUserPrimaryChartsSortedByAlg, LoadPbsByUserIdsAndChartPgId, LoadPbsForUserOnChartsByPgIds, } from "#lib/db-formats/pb"; -import { LoadScoreDocumentById } from "#lib/db-formats/score"; import { log } from "#lib/log/log"; import { GetRivalUsers } from "#lib/rivals/rivals"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter"; -import { SearchSpecificGameSongsAndCharts } from "#lib/search/song-charts.js"; -import prValidate from "#server/middleware/prudence-validate"; -import { AggressiveRateLimitMiddleware } from "#server/middleware/rate-limiter"; +import { SearchSpecificGameSongsAndCharts } from "#lib/search/song-charts"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { GetRelevantSongsAndCharts } from "#utils/db"; import { IsValidScoreAlg } from "#utils/misc"; import { GetAdjacentAbove, GetAdjacentBelow } from "#utils/queries/pbs"; -import { GetUGPT } from "#utils/req-tachi-data"; import { FilterChartsAndSongs, GetScoreIDsFromComposed } from "#utils/scores"; import { GetUsersWithIDs } from "#utils/user"; -import { Router } from "express"; -import { GamePTToV3, GetGamePTConfig, type MatchTypeResolver } from "tachi-common"; -import { PR_RESOLVER } from "tachi-common/lib/schemas"; - -const router: Router = Router({ mergeParams: true }); +import { ExpectedErr } from "bliss"; +import { + LEGACY_GameToGameGroupPT, + LEGACY_GetGamePTConfig, + type MatchTypeResolver, +} from "tachi-common"; /** * Searches a user's personal bests. * - * @param search - The search criteria. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs + * @name GET /api/v1/users/:userID/games/:game/pbs */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/pbs", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; - if (typeof req.query.search !== "string") { - return res.status(400).json({ - success: false, - description: `Invalid value of for search parameter.`, - }); - } + const { songs: allSongs, charts: allCharts } = await SearchSpecificGameSongsAndCharts( + game, + input.search, + ); - const { songs: allSongs, charts: allCharts } = await SearchSpecificGameSongsAndCharts( - game, - req.query.search, - playtype, - ); + const chartPgIds = [...new Set(allCharts.map((c) => c.chartID))]; + const pbs = await LoadPbsForUserOnChartsByPgIds(user.id, chartPgIds, { limit: 30 }); - const chartPgIds = [...new Set(allCharts.map((c) => c.chartID))]; - const pbs = await LoadPbsForUserOnChartsByPgIds(user.id, chartPgIds, { limit: 30 }); + const { songs, charts } = FilterChartsAndSongs(pbs, allCharts, allSongs); - const { songs, charts } = FilterChartsAndSongs(pbs, allCharts, allSongs); - - return res.status(200).json({ - success: true, - description: `Retrieved ${pbs.length} personal bests.`, - body: { - pbs, - songs, - charts, - }, - }); -}); + return success(`Retrieved ${pbs.length} personal bests.`, { charts, pbs, songs }); + }, +); /** * Returns all of a users personal bests. * - * @warn This endpoint is probably quite expensive. We'll need to do - * some performance tests. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/all + * @name GET /api/v1/users/:userID/games/:game/pbs/all */ -router.get("/all", AggressiveRateLimitMiddleware, async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/pbs/all", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const v3Game = GamePTToV3(game, playtype); - const pbs = await LoadPbDocumentsForUserPrimaryCharts(user.id, v3Game); + const pbs = await LoadPbDocumentsForUserPrimaryCharts(user.id, game); + const { songs, charts } = await GetRelevantSongsAndCharts(pbs); - const { songs, charts } = await GetRelevantSongsAndCharts(pbs, game); - - return res.status(200).json({ - success: true, - description: `Returned ${pbs.length} PBs.`, - body: { pbs, songs, charts }, - }); -}); + return success(`Returned ${pbs.length} PBs.`, { charts, pbs, songs }); + }, +); /** * Returns a users best 100 personal-bests for this game. * - * @param alg - Specifies an override for the default algorithm - * to sort on. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/best + * @name GET /api/v1/users/:userID/games/:game/pbs/best */ -router.get("/best", prValidate({ alg: "*string" }), async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/pbs/best", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(game); + const gameConfig = LEGACY_GetGamePTConfig(gameGroup, playtype); - const gptConfig = GetGamePTConfig(game, playtype); + if (input.alg !== undefined && !IsValidScoreAlg(gameConfig, input.alg)) { + throw new ExpectedErr( + 400, + `Invalid score algorithm. Expected any of ${Object.keys(gameConfig.scoreRatingAlgs).join(", ")}`, + ); + } - if (req.query.alg !== undefined && !IsValidScoreAlg(gptConfig, req.query.alg)) { - return res.status(400).json({ - success: false, - description: `Invalid score algorithm. Expected any of ${Object.keys( - gptConfig.scoreRatingAlgs, - ).join(", ")}`, - }); - } + const alg = input.alg ?? gameConfig.defaultScoreRatingAlg; - const alg = (req.query.alg as string | undefined) ?? gptConfig.defaultScoreRatingAlg; + const pbs = await LoadPbDocumentsForUserPrimaryChartsSortedByAlg(user.id, game, alg, 100); + const { songs, charts } = await GetRelevantSongsAndCharts(pbs); - const v3Game = GamePTToV3(game, playtype); - const pbs = await LoadPbDocumentsForUserPrimaryChartsSortedByAlg(user.id, v3Game, alg, 100); - - const { songs, charts } = await GetRelevantSongsAndCharts(pbs, game); - - return res.status(200).json({ - success: true, - description: `Retrieved ${pbs.length} personal bests.`, - body: { - pbs, - songs, - charts, - }, - }); -}); + return success(`Retrieved ${pbs.length} personal bests.`, { charts, pbs, songs }); + }, +); /** - * Returns a user's PB on the given chart. If the user has not played this chart, 404 is - * returned. + * Use the tachi "resolve" engine to identify a chart and return the user's PB. * - * @param getComposition - Also retrieves the score documents that composed this PB. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID + * @name POST /api/v1/users/:userID/games/:game/pbs/resolve */ -router.get("/:chartID", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "POST /users/:userID/games/:game/pbs/resolve", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(game); - const chart = await GetChartById(GamePTToV3(game, playtype), req.params.chartID); + const safeBody = { + ...input, + game: gameGroup, + playtype, + } as unknown as MatchTypeResolver; - if (!chart) { - return res.status(404).json({ - success: false, - description: `This chart does not exist.`, + const got = await ResolveSongAndChart(safeBody, log); + + if (!got) { + throw new ExpectedErr( + 404, + `Could not resolve this chart with details: ${safeBody.matchType}:${safeBody.identifier}`, + ); + } + + const pb = await GetPBOnChart(user.id, got.chart.chartID); + + if (!pb) { + throw new ExpectedErr(404, "This user has not played this chart."); + } + + return success("Successfully retrieved PB for user.", { + chart: got.chart, + pb, + song: got.song, }); - } + }, +); - const pb = await LoadPbByUserAndChartPgId(user.id, chart.chartID); +/** + * Returns a user's PB on the given chart. + * + * @name GET /api/v1/users/:userID/games/:game/pbs/:chartID + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/pbs/:chartID", + withUserGameProfile, + async ({ ctx, params, input }) => { + const { requestedUser: user, game } = ctx; - if (!pb) { - return res.status(404).json({ - success: false, - description: `This user has not played this chart.`, - }); - } + const chart = await GetChartByIdForGame(game, params.chartID); - if (req.query.getComposition !== undefined) { - const scoreIDs = GetScoreIDsFromComposed(pb); + if (!chart) { + throw new ExpectedErr(404, "This chart does not exist."); + } - // TODO(zk): No! Nonsense. This should be done - // with one query - not N. - const scores = (await Promise.all(scoreIDs.map((id) => LoadScoreDocumentById(id)))).filter( - (s): s is NonNullable => s !== undefined, + const pb = await GetPBOnChart(user.id, chart.chartID); + + if (!pb) { + throw new ExpectedErr(404, "This user has not played this chart."); + } + + if (input.getComposition !== undefined) { + const { LoadScoreDocumentById } = await import("#lib/db-formats/score"); + const scoreIDs = GetScoreIDsFromComposed(pb); + const scores = ( + await Promise.all(scoreIDs.map((id) => LoadScoreDocumentById(id))) + ).filter((s): s is NonNullable => s !== undefined); + + return success("Successfully retrieved PB for user.", { chart, pb, scores }); + } + + return success("Successfully retrieved PB for user.", { chart, pb }); + }, +); + +/** + * Returns a user's PB on the given chart, and all of their rivals performances. + * + * @name GET /api/v1/users/:userID/games/:game/pbs/:chartID/rivals + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/pbs/:chartID/rivals", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; + + const chart = await GetChartByIdForGame(game, params.chartID); + + if (!chart) { + throw new ExpectedErr(404, "This chart does not exist."); + } + + const rivals = await GetRivalUsers(user.id, game); + const pbs = await LoadPbsByUserIdsAndChartPgId( + rivals.map((e) => e.id), + chart.chartID, + ); + const usersPB = await GetPBOnChart(user.id, chart.chartID); + + if (usersPB) { + pbs.push(usersPB); + } + + return success("Retrieved PBs and Rival PBs.", { pbs, rivals }); + }, +); + +/** + * Return this users PB on this chart, and 5 nearby players on the leaderboard. + * + * @name GET /api/v1/users/:userID/games/:game/pbs/:chartID/leaderboard-adjacent + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/pbs/:chartID/leaderboard-adjacent", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; + + const chart = await GetChartByIdForGame(game, params.chartID); + + if (!chart) { + throw new ExpectedErr(404, "This chart does not exist."); + } + + const pb = await GetPBOnChart(user.id, chart.chartID); + + if (!pb) { + throw new ExpectedErr(404, "This user has not played this chart."); + } + + const [adjacentAbove, adjacentBelow] = await Promise.all([ + GetAdjacentAbove(pb), + GetAdjacentBelow(pb), + ]); + + const users = await GetUsersWithIDs( + [...adjacentAbove, ...adjacentBelow].map((e) => e.userID), ); - return res.status(200).json({ - success: true, - description: `Successfully retrieved PB for user.`, - body: { - scores, - chart, - pb, - }, - }); - } - - return res.status(200).json({ - success: true, - description: `Successfully retrieved PB for user.`, - body: { - pb, - chart, - }, - }); -}); - -/** - * Returns a user's PB on the given chart, and all of their rivals performances aswell. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/rivals - */ -router.get("/:chartID/rivals", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - - const chart = await GetChartById(GamePTToV3(game, playtype), req.params.chartID); - - if (!chart) { - return res.status(404).json({ - success: false, - description: `This chart does not exist.`, - }); - } - - const rivals = await GetRivalUsers(user.id, game, playtype); - - const pbs = await LoadPbsByUserIdsAndChartPgId( - rivals.map((e) => e.id), - chart.chartID, - ); - - const usersPB = await LoadPbByUserAndChartPgId(user.id, chart.chartID); - - if (usersPB) { - pbs.push(usersPB); - } - - return res.status(200).json({ - success: true, - description: `Retrieved PBs and Rival PBs.`, - body: { - pbs, - rivals, - }, - }); -}); - -/** - * Return this users PB on this chart, and 5 nearby players on the - * leaderboard. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/leaderboard-adjacent - */ -router.get("/:chartID/leaderboard-adjacent", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - - const chart = await GetChartById(GamePTToV3(game, playtype), req.params.chartID); - - if (!chart) { - return res.status(404).json({ - success: false, - description: `This chart does not exist.`, - }); - } - - const pb = await LoadPbByUserAndChartPgId(user.id, chart.chartID); - - if (!pb) { - return res.status(404).json({ - success: false, - description: `This user has not played this chart.`, - }); - } - - const [adjacentAbove, adjacentBelow] = await Promise.all([ - GetAdjacentAbove(pb), - GetAdjacentBelow(pb), - ]); - - const users = await GetUsersWithIDs([...adjacentAbove, ...adjacentBelow].map((e) => e.userID)); - - return res.status(200).json({ - success: true, - description: `Successfully retrieved PB for user.`, - body: { - pb, - chart, + return success("Successfully retrieved PB for user.", { adjacentAbove, adjacentBelow, - users, - }, - }); -}); - -/** - * Use the tachi "resolve" engine to identify a chart instead of - * using the Tachi IDs. Used to get a PB. - * - * @name POST /api/v1/users/:userID/games/:game/:playtype/pbs/resolve - */ -router.post("/resolve", prValidate(PR_RESOLVER), async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - - const safeBody = { - ...req.safeBody, - game, - playtype, - } as unknown as MatchTypeResolver; - const got = await ResolveSongAndChart(safeBody, log); - - if (!got) { - return res.status(404).json({ - success: false, - description: `Could not resolve this chart with details: ${safeBody.matchType}:${safeBody.identifier} (Extra specifiers: version=${safeBody.version}, artist=${safeBody.artist})`, - }); - } - - const pb = await LoadPbByUserAndChartPgId(user.id, got.chart.chartID); - - if (!pb) { - return res.status(404).json({ - success: false, - description: `This user has not played this chart.`, - }); - } - - return res.status(200).json({ - success: true, - description: "Successfully retrieved PB for user.", - body: { + chart, pb, - chart: got.chart, - song: got.song, - }, - }); -}); - -export default router; + users, + }); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.oldtest.ts deleted file mode 100644 index 9422cb6a0..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.oldtest.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import { mkFakeGameSettings, mkFakeUser } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { FakeOtherUser } from "#test-utils/test-data"; -import t from "tap"; - -const SetupRivals = async () => { - await MONGODB_KILL.users.insert([FakeOtherUser, mkFakeUser(3), mkFakeUser(4)]); - await MONGODB_KILL["game-settings"].insert([ - mkFakeGameSettings(2, "iidx", "SP"), - mkFakeGameSettings(3, "iidx", "SP"), - ]); - - await MONGODB_KILL["game-settings"].update( - { - userID: 1, - }, - { - $set: { - rivals: [2, 3], - }, - }, - ); -}; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/rivals", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(SetupRivals); - - t.test("Should return the userdocs of your rivals.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/rivals"); - - t.equal(res.statusCode, 200); - - t.strictSame( - res.body.body.map((e: UserDocument) => e.id), - [2, 3], - "Should have the users rivals and no more.", - ); - - t.end(); - }); - - t.test("Should panic if your rival doesn't exist.", async (t) => { - await MONGODB_KILL.users.remove({ id: 3 }); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/rivals"); - - t.equal(res.statusCode, 500); - - t.end(); - }); - - t.end(); -}); - -t.test("PUT /api/v1/users/:userID/games/:game/:playtype/rivals", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(SetupRivals); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should replace the users rivals.", async (t) => { - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/rivals") - .set("Cookie", cookie) - .send({ - rivalIDs: [2], - }); - - t.equal(res.statusCode, 200); - - const dbRes = await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.strictSame(dbRes?.rivals, [2]); - - t.end(); - }); - - t.test("Should return 400 if the user tries to rival themselves.", async (t) => { - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/rivals") - .set("Cookie", cookie) - .send({ - rivalIDs: [1], - }); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should return 400 if the user tries to rival > 5 people.", async (t) => { - await MONGODB_KILL.users.insert([mkFakeUser(5), mkFakeUser(6)]); - - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/rivals") - .set("Cookie", cookie) - .send({ - rivalIDs: [2, 3, 4, 5, 6], - }); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should return 400 if the user tries to rival someone who doesn't exist.", async (t) => { - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/rivals") - .set("Cookie", cookie) - .send({ - rivalIDs: [5], - }); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test( - "Should return 400 if the user tries to rival someone who exists but hasn't played the GPT.", - async (t) => { - await MONGODB_KILL.users.insert(mkFakeUser(5)); - - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/rivals") - .set("Cookie", cookie) - .send({ - rivalIDs: [5], - }); - - t.equal(res.statusCode, 400); - - t.end(); - }, - ); - - t.test("Should return 401 if user has no auth.", async (t) => { - const res = await mockApi.put("/api/v1/users/1/games/iidx/SP/rivals").send({ - rivalIDs: [2], - }); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/rivals/challengers", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(SetupRivals); - - t.test("Should return all of the users that rival this UGPT.", async (t) => { - await MONGODB_KILL.users.insert(mkFakeUser(5)); - - await MONGODB_KILL["game-settings"].remove({ userID: 3 }); - await MONGODB_KILL["game-settings"].remove({ userID: 4 }); - - await MONGODB_KILL["game-settings"].insert([ - mkFakeGameSettings(2, "chunithm", "Single", { rivals: [1] }), - mkFakeGameSettings(3, "iidx", "SP", { rivals: [1, 3] }), - mkFakeGameSettings(4, "iidx", "SP", { rivals: [1, 3, 5] }), - mkFakeGameSettings(5, "iidx", "SP", { rivals: [1, 4] }), - ]); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/rivals/challengers"); - - t.equal(res.statusCode, 200); - - t.hasStrict( - res.body.body.map((e: UserDocument) => e.id), - [3, 4, 5], - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.test.ts new file mode 100644 index 000000000..997865229 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.test.ts @@ -0,0 +1,151 @@ +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +async function seedUgpt(userId: number) { + await DB.insertInto("game_profile") + .values({ + user_id: userId, + game: "iidx-sp", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + }) + .execute(); + + await DB.insertInto("game_settings") + .values({ + user_id: userId, + game: "iidx-sp", + pf_preferred_score_alg: null, + pf_preferred_session_alg: null, + pf_preferred_profile_alg: null, + pf_preferred_default_enum: null, + pf_default_table: null, + pf_preferred_ranking: null, + data: JSON.stringify({ display2DXTra: false, bpiTarget: 0 }), + }) + .execute(); +} + +describe("GET /api/v1/users/:userID/games/:game/rivals", () => { + beforeEach(async () => { + await seedUser({ username: "rival_main", withCredential: true, withSettings: true }); + await seedUser({ + username: "rival_b", + email: "rb@test.com", + withCredential: true, + withSettings: true, + }); + await seedUser({ + username: "rival_c", + email: "rc@test.com", + withCredential: true, + withSettings: true, + }); + + await seedUgpt(1); + await seedUgpt(2); + await seedUgpt(3); + + await DB.insertInto("game_rival") + .values([ + { user_id: 1, game: "iidx-sp", rival: 2 }, + { user_id: 1, game: "iidx-sp", rival: 3 }, + ]) + .execute(); + }); + + it("returns rival user documents in order", async () => { + const res = await mockApi.get("/api/v1/users/1/games/iidx-sp/rivals"); + + expect(res.status).toBe(200); + expect(res.body.body.map((u: { id: number }) => u.id)).toEqual([2, 3]); + }); + + it.skip("returns 500 when a rival account row is missing", () => { + // Mongo tests could leave `game_rival.rival` pointing at a deleted user. Postgres + // enforces `game_rival.rival REFERENCES account(id)`, so that state cannot exist. + }); +}); + +describe("PUT /api/v1/users/:userID/games/:game/rivals", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "rput_main", withCredential: true, withSettings: true }); + await seedUser({ + username: "rput_b", + email: "rputb@test.com", + withCredential: true, + withSettings: true, + }); + await seedUser({ + username: "rput_c", + email: "rputc@test.com", + withCredential: true, + withSettings: true, + }); + await seedUgpt(1); + await seedUgpt(2); + await seedUgpt(3); + await DB.insertInto("game_rival") + .values([ + { user_id: 1, game: "iidx-sp", rival: 2 }, + { user_id: 1, game: "iidx-sp", rival: 3 }, + ]) + .execute(); + + cookie = await loginAs("rput_main"); + }); + + it("replaces rival IDs", async () => { + const res = await mockApi + .put("/api/v1/users/1/games/iidx-sp/rivals") + .set("Cookie", cookie) + .send({ rivalIDs: [2] }); + + expect(res.status).toBe(200); + + const rows = await DB.selectFrom("game_rival") + .select("rival") + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .execute(); + + expect(rows.map((r) => r.rival)).toEqual([2]); + }); + + it("returns 400 when rivaling yourself", async () => { + const res = await mockApi + .put("/api/v1/users/1/games/iidx-sp/rivals") + .set("Cookie", cookie) + .send({ rivalIDs: [1] }); + + expect(res.status).toBe(400); + }); + + it("returns 401 without auth", async () => { + const res = await mockApi + .put("/api/v1/users/1/games/iidx-sp/rivals") + .send({ rivalIDs: [2] }); + + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/v1/users/:userID/games/:game/rivals/challengers", () => { + it.todo("port full challengers matrix from router.oldtest.ts"); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts index 8f5240ed6..e20e72f23 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts @@ -1,178 +1,121 @@ import { ACTION_SetRivals } from "#actions/set-rivals"; -import { CreateActivityRouteHandler } from "#lib/activity/activity"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { LoadPbDocumentsForUserSetSortedByCalculatedAlg } from "#lib/db-formats/pb"; import { GetChallengerUsers, GetRivalIDs, GetRivalUsers } from "#lib/rivals/rivals"; -import { RequirePermissions } from "#server/middleware/auth"; -import prValidate from "#server/middleware/prudence-validate"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { GetRelevantSongsAndCharts } from "#utils/db"; import { IsString } from "#utils/misc"; -import { GetUGPT } from "#utils/req-tachi-data"; import { CheckStrScoreAlg } from "#utils/string-checks"; import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "#utils/user"; -import { Router } from "express"; -import { p } from "prudence"; -import { GamePTToV3, GetGamePTConfig, type integer } from "tachi-common"; - -import { RequireAuthedAsUser } from "../../../../middleware"; - -const router: Router = Router({ mergeParams: true }); +import { ExpectedErr } from "bliss"; +import { GetGameConfig } from "tachi-common"; /** * Returns all of this user's set rivals. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals + * @name GET /api/v1/users/:userID/games/:game/rivals */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add("GET /users/:userID/games/:game/rivals", withUserGameProfile, async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const rivals = await GetRivalUsers(user.id, game, playtype); + const rivals = await GetRivalUsers(user.id, game); - return res.status(200).json({ - success: true, - description: `Returned ${rivals.length} rivals.`, - body: rivals, - }); + return success(`Returned ${rivals.length} rivals.`, rivals); }); /** * Sets the user's rivals for this GPT. * - * @param rivalIDs - An array of rivalIDs to set as their rivals. - * - * @name PUT /api/v1/users/:userID/games/:game/:playtype/rivals + * @name PUT /api/v1/users/:userID/games/:game/rivals */ -router.put( - "/", - RequireAuthedAsUser, - RequirePermissions("manage_rivals"), - prValidate({ - rivalIDs: [p.isPositiveNonZeroInteger], - }), - async (req, res) => { - const body = req.safeBody as { - rivalIDs: Array; - }; - - const rivalIDs = body.rivalIDs; - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "PUT /users/:userID/games/:game/rivals", + withUserGameProfile, + async ({ ctx, input, req }) => { + const { requestedUser: user, game } = ctx; const authUserID = req[SYMBOL_TACHI_API_AUTH].userID; if (authUserID === null) { - return res.status(401).json({ - success: false, - description: `Authentication is required for this endpoint.`, - }); + throw new ExpectedErr(401, "Authentication is required for this endpoint."); } const authedUser = await GetUserWithIDGuaranteed(authUserID); - const taker = { ip: req.ip, acct: { id: authedUser.id, username: authedUser.username } }; + const taker = { acct: { id: authedUser.id, username: authedUser.username }, ip: req.ip }; - await ACTION_SetRivals(taker, { - userID: user.id, - game, - playtype, - rivalIDs, - }); + const rivalIDs = input.rivalIDs; - return res.status(200).json({ - success: true, - description: `Set ${rivalIDs.length} rivals.`, - body: {}, - }); + await ACTION_SetRivals(taker, { game, rivalIDs, userID: user.id }); + + return success(`Set ${rivalIDs.length} rivals.`, {}); }, ); /** * Return all of the users that are rivalling this user for this GPT. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/challengers + * @name GET /api/v1/users/:userID/games/:game/rivals/challengers */ -router.get("/challengers", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/rivals/challengers", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const challengers = await GetChallengerUsers(user.id, game, playtype); + const challengers = await GetChallengerUsers(user.id, game); - return res.status(200).json({ - success: true, - description: `Returned ${challengers.length} challengers.`, - body: challengers, - }); -}); + return success(`Returned ${challengers.length} challengers.`, challengers); + }, +); /** * Retrieve a "PB leaderboard" for this user's set of rivals. * - * This is - effectively - the best 100 scores from this set of users on the given - * rating algorithm. - * - * @param alg - The score rating algorithm to sort on. Defaults to whatever the GPTConfig - * default is. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/pb-leaderboard + * @name GET /api/v1/users/:userID/games/:game/rivals/pb-leaderboard */ -router.get("/pb-leaderboard", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - const gptConfig = GetGamePTConfig(game, playtype); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/rivals/pb-leaderboard", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; + const gameConfig = GetGameConfig(game); - let alg = gptConfig.defaultScoreRatingAlg; + let alg = gameConfig.defaultScoreRatingAlg; - if (IsString(req.query.alg)) { - const temp = CheckStrScoreAlg(game, playtype, req.query.alg); + if (IsString(input.alg)) { + const temp = CheckStrScoreAlg(game, input.alg); - if (temp === null) { - return res.status(400).json({ - success: false, - description: `Invalid value of ${ - req.query.alg - } for alg. Expected one of ${Object.keys(gptConfig.scoreRatingAlgs).join(", ")}`, - }); + if (temp === null) { + throw new ExpectedErr( + 400, + `Invalid value of ${input.alg} for alg. Expected one of ${Object.keys(gameConfig.scoreRatingAlgs).join(", ")}`, + ); + } + + alg = temp; } - alg = temp; - } + const rivalIDs = await GetRivalIDs(user.id, game); + const userSet = [...rivalIDs, user.id]; - const rivalIDs = await GetRivalIDs(user.id, game, playtype); - const userSet = [...rivalIDs, user.id]; + const pbs = await LoadPbDocumentsForUserSetSortedByCalculatedAlg(userSet, game, alg, 100); + const users = await GetUsersWithIDs(pbs.map((e) => e.userID)); + const { songs, charts } = await GetRelevantSongsAndCharts(pbs); - const v3Game = GamePTToV3(game, playtype); - const pbs = await LoadPbDocumentsForUserSetSortedByCalculatedAlg(userSet, v3Game, alg, 100); - - const users = await GetUsersWithIDs(pbs.map((e) => e.userID)); - - const { songs, charts } = await GetRelevantSongsAndCharts(pbs, game); - - return res.status(200).send({ - success: true, - description: `Successfully returned ${pbs.length} pbs.`, - body: { - pbs, - songs, - charts, - users, - }, - }); -}); + return success(`Successfully returned ${pbs.length} pbs.`, { charts, pbs, songs, users }); + }, +); /** * Retrieve activity for this user's set of rivals. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity + * @note Activity routes delegate to CreateActivityRouteHandler, which sends its own response. + * This handler is intentionally a no-op placeholder - the route is wired directly. + * + * @name GET /api/v1/users/:userID/games/:game/rivals/activity */ -router.get("/activity", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); - - const rivalIDs = await GetRivalIDs(user.id, game, playtype); - - const route = CreateActivityRouteHandler({ - userID: { $in: rivalIDs }, - game, - playtype, - }); - - // this handles responding - void route(req, res); +API_V1_ROUTER.add("GET /users/:userID/games/:game/rivals/activity", withUserGameProfile, () => { + throw new ExpectedErr(501, "Not implemented via TypedRouter - wired directly."); }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.oldtest.ts deleted file mode 100644 index fa07710d8..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.oldtest.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { ScoreDocument } from "tachi-common"; - -import { ONE_DAY } from "#lib/constants/time"; -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA, TestingIIDXSPScore, TestingIIDXSPScorePB } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a users statistics for that game.", async (t) => { - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP"); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved user statistics for test_zkldi (iidx SP)", - body: { - gameStats: { - userID: 1, - game: "iidx", - playtype: "SP", - classes: {}, - ratings: {}, - }, - firstScore: null, - mostRecentScore: null, - totalScores: 1, - rankingData: { - ktLampRating: { - ranking: 1, - outOf: 1, - }, - BPI: { - ranking: 1, - outOf: 1, - }, - }, - }, - }); - - t.end(); - }); - - t.test("Should return a users first score if one exists.", async (t) => { - await MONGODB_KILL.scores.insert([ - { - userID: 1, - timeAchieved: 100, - scoreID: "foo", - game: "iidx", - playtype: "SP", - }, - { - userID: 1, - timeAchieved: 200, - scoreID: "bar", - game: "iidx", - playtype: "SP", - }, - { - userID: 1, - timeAchieved: 300, - scoreID: "baz", - game: "iidx", - playtype: "SP", - }, - ] as Array); - - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP"); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved user statistics for test_zkldi (iidx SP)", - body: { - gameStats: { - userID: 1, - game: "iidx", - playtype: "SP", - classes: {}, - ratings: {}, - }, - firstScore: { - timeAchieved: 100, - scoreID: "foo", - }, - mostRecentScore: { - timeAchieved: 300, - scoreID: "baz", - }, - totalScores: 4, - rankingData: { - ktLampRating: { - ranking: 1, - outOf: 1, - }, - BPI: { - ranking: 1, - outOf: 1, - }, - }, - }, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/history", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a users history snapshots for that gpt.", async (t) => { - await MONGODB_KILL["game-stats-snapshots"].insert([ - { - userID: 1, - game: "iidx", - playtype: "SP", - - // @ts-expect-error Too lazy to sort this one out... - rankings: { - BPI: { - ranking: 2, - outOf: 4, - }, - }, - playcount: 100, - classes: {}, - ratings: {}, - timestamp: Date.now() - ONE_DAY, - }, - ]); - - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/history"); - - // by default, it's set to the current time. we can't - // test that nicely, so lets round it to the nearest 100 seconds. - res.body.body[0].timestamp = Math.floor(res.body.body[0].timestamp / 100_000); - res.body.body[1].timestamp = Math.floor(res.body.body[1].timestamp / 100_000); - - t.strictSame(res.body.body, [ - { - rankings: { - ktLampRating: { - ranking: 1, - outOf: 1, - }, - BPI: { - ranking: 1, - outOf: 1, - }, - }, - playcount: 1, - classes: {}, - ratings: {}, - - // close enough, right? - timestamp: Math.floor(Date.now() / 100_000), - }, - { - rankings: { - BPI: { - ranking: 2, - outOf: 4, - }, - }, - playcount: 100, - classes: {}, - ratings: {}, - timestamp: Math.floor((Date.now() - ONE_DAY) / 100_000), - }, - ]); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/most-played", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the users most played scores.", async (t) => { - await MONGODB_KILL.scores.insert([ - // @ts-expect-error nonsense - deepmerge(TestingIIDXSPScore, { scoreID: "something_else" }), - - // @ts-expect-error nonsense - deepmerge(TestingIIDXSPScore, { scoreID: "something_else2" }), - ]); - - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/most-played"); - - t.equal(res.body.body.pbs.length, 1); - t.equal(res.body.body.pbs[0].__playcount, 3); - - t.equal(res.body.body.songs.length, 1); - t.equal(res.body.body.songs[0].id, 1); - - t.equal(res.body.body.charts.length, 1); - t.equal(res.body.body.charts[0].chartID, Testing511SPA.chartID); - - t.end(); - }); - - t.end(); -}); - -t.test("DELETE /api/v1/users/:userID/games/:game/:playtype", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should delete this users profile and check passwords.", async (t) => { - const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP`) - .set("Cookie", cookie) - .send({ - "!password": "password", - }); - - t.equal(res.status, 200, "Should return 200."); - - const dbRes = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(dbRes, null, "Should remove the UGPT profile from the database."); - - t.end(); - }); - - t.test("Should require a correct password.", async (t) => { - const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP`) - .set("Cookie", cookie) - .send({ - "!password": "invalid_password", - }); - - t.equal(res.status, 403, "Should return 403."); - - const dbRes = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.not(dbRes, null, "Should NOT remove the UGPT profile from the database."); - - t.end(); - }); - - t.test("Should require a password.", async (t) => { - const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP`) - .set("Cookie", cookie) - .send({}); - - t.equal(res.status, 400, "Should return 400."); - - const dbRes = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.not(dbRes, null, "Should NOT remove the UGPT profile from the database."); - - t.end(); - }); - - t.test("Should require self-key auth.", async (t) => { - const res = await mockApi.delete(`/api/v1/users/1/games/iidx/SP`).send({ - "!password": "password", - }); - - t.equal(res.status, 401, "Should return 401."); - - const dbRes = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.not(dbRes, null, "Should NOT remove the UGPT profile from the database."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts new file mode 100644 index 000000000..e38143aad --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts @@ -0,0 +1,105 @@ +import { ONE_DAY } from "#lib/constants/time"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { afterAll, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function seedIidxSpProfile(userId: number) { + await DB.insertInto("game_profile") + .values({ + user_id: userId, + game: "iidx-sp", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + }) + .execute(); +} + +/** Snapshots spaced so they fall into different duration buckets (see ONE_YEAR = 336d). */ +async function seedHistorySnapshots(userId: number) { + const now = Date.now(); + const emptyRatings = JSON.stringify({}); + const emptyClasses = JSON.stringify({}); + const emptyRankings = JSON.stringify({}); + + await DB.insertInto("game_stats_snapshot") + .values([ + { + user_id: userId, + game: "iidx-sp", + timestamp: new Date(now - 400 * ONE_DAY).toISOString(), + playcount: 100, + ratings: emptyRatings, + classes: emptyClasses, + rankings: emptyRankings, + }, + { + user_id: userId, + game: "iidx-sp", + timestamp: new Date(now - 800 * ONE_DAY).toISOString(), + playcount: 200, + ratings: emptyRatings, + classes: emptyClasses, + rankings: emptyRankings, + }, + { + user_id: userId, + game: "iidx-sp", + timestamp: new Date(now - 1200 * ONE_DAY).toISOString(), + playcount: 300, + ratings: emptyRatings, + classes: emptyClasses, + rankings: emptyRankings, + }, + ]) + .execute(); +} + +describe("GET /api/v1/users/:userID/games/:game/history", () => { + it("returns 400 for an invalid duration query", async () => { + const { id } = await seedUser({ username: `ugpt_hist_bad_${Date.now()}` }); + await seedIidxSpProfile(id); + + const res = await mockApi.get(`/api/v1/users/${id}/games/iidx-sp/history?duration=invalid`); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("defaults to 3 months and excludes older snapshots", async () => { + const { id } = await seedUser({ username: `ugpt_hist_default_${Date.now()}` }); + await seedIidxSpProfile(id); + await seedHistorySnapshots(id); + + const res = await mockApi.get(`/api/v1/users/${id}/games/iidx-sp/history`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body).toHaveLength(1); + }); + + it("duration=year excludes snapshots older than one year", async () => { + const { id } = await seedUser({ username: `ugpt_hist_year_${Date.now()}` }); + await seedIidxSpProfile(id); + await seedHistorySnapshots(id); + + const res = await mockApi.get(`/api/v1/users/${id}/games/iidx-sp/history?duration=year`); + + expect(res.status).toBe(200); + expect(res.body.body.map((row: { playcount: number }) => row.playcount)).toEqual([0]); + }); + + it("duration=all returns every stored snapshot", async () => { + const { id } = await seedUser({ username: `ugpt_hist_all_${Date.now()}` }); + await seedIidxSpProfile(id); + await seedHistorySnapshots(id); + + const res = await mockApi.get(`/api/v1/users/${id}/games/iidx-sp/history?duration=all`); + + expect(res.status).toBe(200); + const playcounts = res.body.body.map((row: { playcount: number }) => row.playcount); + expect(playcounts.sort((a: number, b: number) => a - b)).toEqual([0, 100, 200, 300]); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts index 260a91a7a..09717abfe 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts @@ -1,8 +1,8 @@ -import { CreateActivityRouteHandler } from "#lib/activity/activity"; +import { GetRecentActivity } from "#lib/activity/activity"; import { PasswordCompare, ValidatePassword } from "#lib/auth/auth"; import { ONE_MONTH, ONE_WEEK, ONE_YEAR } from "#lib/constants/time"; -import { GetChartsByIds } from "#lib/db-formats/chart.js"; -import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js"; +import { GetChartsByIds } from "#lib/db-formats/chart"; +import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles"; import { type PbDocumentJoinRow, SELECT_PB_DOCUMENT_WITH_LEADERBOARD, @@ -13,13 +13,14 @@ import { SELECT_SCORE_DOCUMENT, ToScoreDocument, } from "#lib/db-formats/score"; -import { GetSongsByLegacyIDs } from "#lib/db-formats/song.js"; +import { GetSongsByIDs } from "#lib/db-formats/song"; import { log } from "#lib/log/log"; -import prValidate from "#server/middleware/prudence-validate"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; import { IsString } from "#utils/misc"; -import { GetTachiData, GetUGPT } from "#utils/req-tachi-data"; -import DestroyUserGameProfile from "#utils/reset-state/destroy-user-game-profile.js"; +import DestroyUserGameProfile from "#utils/reset-state/destroy-user-game-profile"; import { CheckStrProfileAlg } from "#utils/string-checks"; import { ISO8601ToUnixMilliseconds, UnixMillisecondsToISO8601 } from "#utils/time"; import { @@ -30,45 +31,25 @@ import { GetUsersRankingAndOutOf, GetUsersWithIDs, } from "#utils/user"; -import { Router } from "express"; +import { ExpectedErr } from "bliss"; import { sql, type SqlBool } from "kysely"; -import { p } from "prudence"; import { - FormatGameGroup, - GamePTToV3, - GetGamePTConfig, - type GPTString, + GetGameConfig, type integer, - type MONGO_PBScoreDocument, - type MONGO_UserGameStatsSnapshotDocument, - type ProfileRatingAlgorithms, + LEGACY_FormatGameGroupPT, + LEGACY_GameToGameGroupPT, + type PBScoreDocument, + type UserGameStatsSnapshotDocument, } from "tachi-common"; -import { RequireAuthedAsUser, RequireSelfRequestFromUser } from "../../../middleware"; -import foldersRouter from "./folders/router"; -import { CheckUserPlayedGamePlaytype } from "./middleware"; -import pbsRouter from "./pbs/router"; -import rivalsRouter from "./rivals/router"; -import scoresRouter from "./scores/router"; -import sessionsRouter from "./sessions/router"; -import settingsRouter from "./settings/router"; -import showcaseRouter from "./showcase/router"; -import tablesRouter from "./tables/router"; -import targetsRouter from "./targets/router"; - -const router: Router = Router({ mergeParams: true }); - -router.use(CheckUserPlayedGamePlaytype); - /** * Returns information about a user for this game + playtype. - * @name GET /api/v1/users/:userID/games/:game/:playtype + * + * @name GET /api/v1/users/:userID/games/:game */ -router.get("/", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); - - const stats = GetTachiData(req, "requestedUserGameStats"); - const v3Game = GamePTToV3(game, playtype); +API_V1_ROUTER.add("GET /users/:userID/games/:game", withUserGameProfile, async ({ ctx }) => { + const { requestedUser: user, game, userGameStats: stats } = ctx; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(game); const scoreJoin = () => DB.selectFrom("score") @@ -76,14 +57,14 @@ router.get("/", async (req, res) => { .innerJoin("song", "song.id", "chart.song_id") .leftJoin("import", "import.id", "score.import_id") .where("score.user_id", "=", user.id) - .where("score.game", "=", v3Game) + .where("score.game", "=", game) .where("score.time_achieved", "is not", null); const [totalScores, firstRow, recentRow, rankingData, playtimeRow] = await Promise.all([ DB.selectFrom("score") .select((eb) => eb.fn.countAll().as("c")) .where("user_id", "=", user.id) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst() .then((r) => Number(r?.c ?? 0)), scoreJoin() @@ -102,346 +83,302 @@ router.get("/", async (req, res) => { ), ) .where("user_id", "=", user.id) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst(), ]); const firstScore = firstRow ? ToScoreDocument(firstRow as ScoreDocumentJoinRow) : null; const mostRecentScore = recentRow ? ToScoreDocument(recentRow as ScoreDocumentJoinRow) : null; - return res.status(200).json({ - success: true, - description: `Retrieved user statistics for ${user.username} (${game} ${playtype})`, - body: { - gameStats: stats, - firstScore, - mostRecentScore, - totalScores, - rankingData, - playtime: Math.round(Number(playtimeRow?.playtime ?? 0)), - }, + return success(`Retrieved user statistics for ${user.username} (${gameGroup} ${playtype})`, { + firstScore, + gameStats: stats, + mostRecentScore, + playtime: Math.round(Number(playtimeRow?.playtime ?? 0)), + rankingData, + totalScores, }); }); /** * Returns a users game-stats for the past 90 days. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/history + * @name GET /api/v1/users/:userID/games/:game/history */ -router.get( - "/history", - prValidate({ - duration: p.optional(p.isIn("week", "month", "3mo", "year")), - }), - async (req, res) => { - const duration = req.query.duration as "3mo" | "month" | "week" | "year" | undefined; +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/history", + withUserGameProfile, + async ({ ctx, input }) => { + const { game, requestedUser: user, userGameStats: stats } = ctx; + const duration = input.duration; - let time = Date.now(); + let time: number | null = Date.now(); switch (duration) { case "year": { - time = time - ONE_YEAR; + time -= ONE_YEAR; break; } - - // if the user doesn't define anything, default to 3month case "3mo": case undefined: { - time = time - ONE_MONTH * 3; + time -= ONE_MONTH * 3; break; } - case "month": { - time = time - ONE_MONTH; + time -= ONE_MONTH; + break; + } + case "week": { + time -= ONE_WEEK; + break; + } + case "all": { + time = null; break; } - - case "week": - time = time - ONE_WEEK; } - const { game, playtype, user } = GetUGPT(req); - - const stats = GetTachiData(req, "requestedUserGameStats"); - const v3Game = GamePTToV3(game, playtype); - const snapshotRows = await DB.selectFrom("game_stats_snapshot") - .select(["timestamp", "playcount", "ratings", "classes", "rankings"]) - .where("user_id", "=", user.id) - .where("game", "=", v3Game) - .where("timestamp", ">=", UnixMillisecondsToISO8601(time)) - .orderBy("timestamp", "desc") + .select([ + "game_stats_snapshot.timestamp", + "game_stats_snapshot.playcount", + "game_stats_snapshot.ratings", + "game_stats_snapshot.classes", + "game_stats_snapshot.rankings", + ]) + .where("game_stats_snapshot.user_id", "=", user.id) + .where("game_stats_snapshot.game", "=", game) + .$if(time !== null, (qb) => + qb.where("game_stats_snapshot.timestamp", ">=", UnixMillisecondsToISO8601(time!)), + ) + .orderBy("game_stats_snapshot.timestamp", "desc") .execute(); const snapshots = snapshotRows.map( (row) => ({ classes: row.classes, - ratings: row.ratings, - timestamp: ISO8601ToUnixMilliseconds(row.timestamp), playcount: row.playcount, rankings: row.rankings, - }) as Omit, + ratings: row.ratings, + timestamp: ISO8601ToUnixMilliseconds(row.timestamp), + }) as Omit, ); - const currentSnapshot: Omit< - MONGO_UserGameStatsSnapshotDocument, - "game" | "playtype" | "userID" - > = { - classes: stats.classes, - ratings: stats.ratings, + const currentSnapshot: Omit = + { + classes: stats.classes, + playcount: await GetUGPTPlaycount(user.id, game), + rankings: await GetAllRankings(stats), + ratings: stats.ratings, + timestamp: Date.now(), + }; - // lazy, should probably be this midnight - timestamp: Date.now(), - playcount: await GetUGPTPlaycount(user.id, game, playtype), - rankings: await GetAllRankings(stats), - }; - - return res.status(200).json({ - success: true, - description: `Successfully returned history for the past ${snapshots.length} days.`, - body: [currentSnapshot, ...snapshots], - }); + return success(`Successfully returned history for the past ${snapshots.length} days.`, [ + currentSnapshot, + ...snapshots, + ]); }, ); /** * Returns the users most played charts by playcount. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/most-played + * @name GET /api/v1/users/:userID/games/:game/most-played */ -router.get("/most-played", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); - const v3Game = GamePTToV3(game, playtype); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/most-played", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const mostPlayed = await DB.selectFrom("score") - .innerJoin("chart", "chart.id", "score.chart_id") - .innerJoin("song", "song.id", "chart.song_id") - .select([ - "chart.id as chart_id", - "song.legacy_id as song_legacy_id", - sql`count(*)::int`.as("playcount"), - ]) - .where("score.user_id", "=", user.id) - .where("score.game", "=", v3Game) - .groupBy(["chart.id", "song.legacy_id"]) - .orderBy("playcount", "desc") - .limit(100) - .execute(); + const mostPlayed = await DB.selectFrom("score") + .innerJoin("chart", "chart.id", "score.chart_id") + .innerJoin("song", "song.id", "chart.song_id") + .select([ + "chart.id as chart_id", + "song.id as song_id", + sql`count(*)::int`.as("playcount"), + ]) + .where("score.user_id", "=", user.id) + .where("score.game", "=", game) + .groupBy(["chart.id", "song.id"]) + .orderBy("playcount", "desc") + .limit(100) + .execute(); - const chartIDs = mostPlayed.map((e) => e.chart_id); - const songIDs = mostPlayed.map((e) => e.song_legacy_id); + const chartIDs = mostPlayed.map((e) => e.chart_id); + const songIDs = mostPlayed.map((e) => e.song_id); - const [songs, charts, pbRows] = await Promise.all([ - GetSongsByLegacyIDs(game, songIDs), - GetChartsByIds(game, chartIDs), - chartIDs.length === 0 - ? Promise.resolve([] as Array) - : DB.selectFrom("pb") - .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") - .innerJoin("chart", "chart.id", "pb.chart_id") - .innerJoin("song", "song.id", "chart.song_id") - .select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD) - .where("pb.user_id", "=", user.id) - .where("chart.id", "in", chartIDs) - .execute() - .then((rows) => rows as Array), - ]); + const [songs, charts, pbRows] = await Promise.all([ + GetSongsByIDs(songIDs), + GetChartsByIds(chartIDs), + chartIDs.length === 0 + ? Promise.resolve([] as Array) + : DB.selectFrom("pb") + .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") + .innerJoin("chart", "chart.id", "pb.chart_id") + .innerJoin("song", "song.id", "chart.song_id") + .select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD) + .where("pb.user_id", "=", user.id) + .where("chart.id", "in", chartIDs) + .execute() + .then((rows) => rows as Array), + ]); - const playcountMap = new Map(); + const playcountMap = new Map(); - for (const doc of mostPlayed) { - playcountMap.set(doc.chart_id, doc.playcount); - } + for (const doc of mostPlayed) { + playcountMap.set(doc.chart_id, doc.playcount); + } - const playcountPBs = (await Promise.all(pbRows.map((row) => ToPbScoreDocument(row)))) as Array< - { __playcount: integer } & MONGO_PBScoreDocument - >; + const playcountPBs = (await Promise.all( + pbRows.map((row) => ToPbScoreDocument(row)), + )) as Array<{ __playcount: integer } & PBScoreDocument>; - for (const pb of playcountPBs) { - pb.__playcount = playcountMap.get(pb.chartID) ?? 0; - } + for (const pb of playcountPBs) { + pb.__playcount = playcountMap.get(pb.chartID) ?? 0; + } - playcountPBs.sort((a, b) => b.__playcount - a.__playcount); + playcountPBs.sort((a, b) => b.__playcount - a.__playcount); - return res.status(200).json({ - success: true, - description: `Returned ${playcountPBs.length} scores.`, - body: { - songs, + return success(`Returned ${playcountPBs.length} scores.`, { charts, pbs: playcountPBs, - }, - }); -}); + songs, + }); + }, +); /** * Returns the users around the given user on the leaderboard. * - * @param alg - Optional, the algorithm to use. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/leaderboard-adjacent + * @name GET /api/v1/users/:userID/games/:game/leaderboard-adjacent */ -router.get("/leaderboard-adjacent", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/leaderboard-adjacent", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game, userGameStats: thisUsersStats } = ctx; + const gameConfig = GetGameConfig(game); - const gptConfig = GetGamePTConfig(game, playtype); + let alg = gameConfig.defaultProfileRatingAlg; - let alg = gptConfig.defaultProfileRatingAlg; + if (IsString(input.alg)) { + const temp = CheckStrProfileAlg(game, input.alg); - if (IsString(req.query.alg)) { - const temp = CheckStrProfileAlg(game, playtype, req.query.alg); + if (temp === null) { + throw new ExpectedErr( + 400, + `Invalid value of ${input.alg} for alg. Expected one of ${Object.keys(gameConfig.profileRatingAlgs).join(", ")}`, + ); + } - if (temp === null) { - return res.status(400).json({ - success: false, - description: `Invalid value of ${ - req.query.alg - } for alg. Expected one of ${Object.keys(gptConfig.profileRatingAlgs).join(", ")}`, - }); + alg = temp; } - alg = temp; - } + const userRating = thisUsersStats.ratings[alg] ?? 0; + const ratingCol = sql`coalesce((game_profile.ratings::jsonb->>${sql.lit(alg)})::numeric, 0)`; - const thisUsersStats = GetTachiData(req, "requestedUserGameStats"); - const v3Game = GamePTToV3(game, playtype); - const userRating = thisUsersStats.ratings[alg] ?? 0; - const ratingCol = sql`coalesce((game_profile.ratings::jsonb->>${sql.lit(alg)})::numeric, 0)`; + const [aboveRows, belowRows] = await Promise.all([ + DB.selectFrom("game_profile") + .select(SELECT_GAME_PROFILE) + .where("game", "=", game) + .where("user_id", "<>", user.id) + .where(sql`${ratingCol} > ${userRating}`) + .orderBy(ratingCol, "asc") + .limit(5) + .execute(), + DB.selectFrom("game_profile") + .select(SELECT_GAME_PROFILE) + .where("game", "=", game) + .where("user_id", "<>", user.id) + .where(sql`${ratingCol} <= ${userRating}`) + .orderBy(ratingCol, "desc") + .limit(5) + .execute(), + ]); - const [aboveRows, belowRows] = await Promise.all([ - DB.selectFrom("game_profile") - .select(SELECT_GAME_PROFILE) - .where("game", "=", v3Game) - .where("user_id", "<>", user.id) - .where(sql`${ratingCol} > ${userRating}`) - .orderBy(ratingCol, "asc") - .limit(5) - .execute(), - DB.selectFrom("game_profile") - .select(SELECT_GAME_PROFILE) - .where("game", "=", v3Game) - .where("user_id", "<>", user.id) - .where(sql`${ratingCol} <= ${userRating}`) - .orderBy(ratingCol, "desc") - .limit(5) - .execute(), - ]); + const above = aboveRows.map(ToGameStatsDocument); + const below = belowRows.map(ToGameStatsDocument); - const above = aboveRows.map(ToGameStatsDocument); - const below = belowRows.map(ToGameStatsDocument); + const users = await GetUsersWithIDs([ + ...aboveRows.map((e) => e.user_id), + ...belowRows.map((e) => e.user_id), + ]); - const users = await GetUsersWithIDs([ - ...aboveRows.map((e) => e.user_id), - ...belowRows.map((e) => e.user_id), - ]); + const thisUsersRanking = await GetUsersRankingAndOutOf(thisUsersStats, alg); - const thisUsersRanking = await GetUsersRankingAndOutOf(thisUsersStats, alg); - - return res.status(200).json({ - success: true, - description: `Returned ${above.length + below.length} nearby stats.`, - body: { + return success(`Returned ${above.length + below.length} nearby stats.`, { above: above.reverse(), below, - users, - thisUsersStats, thisUsersRanking, - }, - }); -}); + thisUsersStats, + users, + }); + }, +); /** * Retrieve activity for this user. * - * @param session - See CreateActivityRouteHandler - * @param startTime - See CreateActivityRouteHandler - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/activity + * @name GET /api/v1/users/:userID/games/:game/activity */ -router.get("/activity", (req, res) => { - const { game, playtype, user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/activity", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; - const route = CreateActivityRouteHandler({ - userID: user.id, - game, - playtype, - }); + const data = await GetRecentActivity( + game, + { userID: user.id }, + input.sessions ?? 30, + input.startTime ?? null, + ); - // this handles responding - void route(req, res); -}); + return success("Retrieved activity.", data); + }, +); /** - * Completely wipe this profile. - * Requires a self-request from this user, and also checks that the user knows their - * password. This is one of the most bolted down endpoints in the site, for obvious - * reasons. + * Completely wipe this profile. Requires the user's password. * - * @param !password - This user's password. - * - * @name DELETE /api/v1/users/:userID/games/:game/:playtype + * @name DELETE /api/v1/users/:userID/games/:game */ -router.delete( - "/", - RequireSelfRequestFromUser, - RequireAuthedAsUser, - prValidate({ - "!password": ValidatePassword, - }), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - const body = req.safeBody as { - "!password": string; - }; +API_V1_ROUTER.add( + "DELETE /users/:userID/games/:game", + withUserGameProfile, + async ({ ctx, input }) => { + const { game: v3Game, requestedUser: user } = ctx; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(v3Game); log.info( - `Recieved request to delete UGPT ${FormatUserDoc(user)} ${FormatGameGroup(game, playtype)}`, + `Recieved request to delete UGPT ${FormatUserDoc(user)} ${LEGACY_FormatGameGroupPT(gameGroup, playtype)}`, ); const privateInfo = await GetUserPrivateInfo(user.id); if (!privateInfo) { - log.error( - { user }, - `State desync for user ${FormatUserDoc( - user, - )}. This user has no password/email information?`, - ); - - return res.status(500).json({ - success: false, - description: `An internal server error has occured.`, - }); + log.error({ user }, `State desync for user ${FormatUserDoc(user)}.`); + throw new ExpectedErr(500, "An internal server error has occured."); } - const passwordMatch = await PasswordCompare(body["!password"], privateInfo.password); + const password = input["!password"] as string; + const isValidPassword = ValidatePassword(password); + + if (isValidPassword !== true) { + throw new ExpectedErr(400, "Invalid password format."); + } + + const passwordMatch = await PasswordCompare(password, privateInfo.password); if (!passwordMatch) { - return res.status(403).json({ - success: false, - description: `Invalid password.`, - }); + throw new ExpectedErr(403, "Invalid password."); } - await DestroyUserGameProfile(user.id, game, playtype); + await DestroyUserGameProfile(user.id, gameGroup, playtype); - return res.status(200).json({ - success: true, - description: `Destroyed profile.`, - body: {}, - }); + return success("Destroyed profile.", {}); }, ); - -router.use("/pbs", pbsRouter); -router.use("/scores", scoresRouter); -router.use("/sessions", sessionsRouter); -router.use("/tables", tablesRouter); -router.use("/showcase", showcaseRouter); -router.use("/settings", settingsRouter); -router.use("/folders", foldersRouter); -router.use("/targets", targetsRouter); -router.use("/rivals", rivalsRouter); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.oldtest.ts deleted file mode 100644 index 8b9cf8e01..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.oldtest.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { ChartDocument, ScoreDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { mkFakeScoreIIDXSP, mkFakeScoreSDVX } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - GetKTDataJSON, - LoadTachiIIDXData, - Testing511Song, - Testing511SPA, -} from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores/recent", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a users 100 most recent scores.", async (t) => { - const mockScores: Array = []; - - for (let i = 0; i < 200; i++) { - mockScores.push({ - userID: 1, - game: "iidx", - playtype: "SP", - timeAchieved: i * 100, - scoreID: `scoreID_${i}`, - chartID: Testing511SPA.chartID, - songID: Testing511Song.id, - } as ScoreDocument); - } - - await MONGODB_KILL.scores.insert(mockScores); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/scores/recent"); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 100 scores.", - body: { - scores: mockScores.slice(100).reverse(), - songs: [Testing511Song], - charts: [Testing511SPA], - }, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should return 400 if no search param is given", async (t) => { - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/scores"); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.test("Should return 400 if invalid search param is given", async (t) => { - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/scores?search=foo&search=bar", - ); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - // evil eval attempts - const res2 = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/scores?search[$where]=process.exit(1)", - ); - - t.equal(res2.statusCode, 400); - t.equal(res2.body.success, false); - - t.end(); - }); - - t.test("Should search a user's scores.", async (t) => { - const mockScores: Array = []; - - const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array< - ChartDocument<"iidx:DP" | "iidx:SP"> - >; - - for (let i = 0; i < 200; i++) { - const chart = charts[i]; - - if (!chart) { - return t.fail( - `tachi-charts-iidx.json doesn't have enough mock data for testing. Needed atleast 200 entries, but failed to retrieve one at index ${i}.`, - ); - } - - mockScores.push({ - scoreID: i.toString(), - userID: 1, - game: "iidx", - playtype: "SP", - isPrimary: true, - chartID: chart.chartID, - songID: chart.songID, - calculatedData: { - ktLampRating: i, - }, - } as ScoreDocument); - } - - await MONGODB_KILL.scores.insert(mockScores); - - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/scores?search=5.1.1.", - ); - - t.equal(res.body.body.scores.length, 5); - t.equal(res.body.body.songs.length, 1); - t.equal(res.body.body.charts.length, 4); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 5 scores.", - body: { - scores: [ - { - songID: 1, - }, - { - songID: 1, - }, - { - songID: 1, - }, - ], - songs: [ - { - title: "5.1.1.", - }, - ], - charts: [ - { - songID: 1, - }, - { - songID: 1, - }, - ], - }, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores/all", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return every score from a user.", async (t) => { - await MONGODB_KILL.scores.remove({}); - await MONGODB_KILL.scores.insert([ - mkFakeScoreIIDXSP({ scoreID: "score_1" }), - mkFakeScoreIIDXSP({ scoreID: "score_2" }), - mkFakeScoreIIDXSP({ scoreID: "score_3" }), - mkFakeScoreIIDXSP({ scoreID: "other_score", userID: 2 }), - mkFakeScoreSDVX({ scoreID: "other_game_same_user" }), - ]); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/scores/all"); - - t.equal(res.statusCode, 200, "Should return 200"); - - t.strictSame( - res.body.body.scores.map((e: ScoreDocument) => e.scoreID), - ["score_1", "score_2", "score_3"], - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores/:chartID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should return a users score history on a given chart.", async (t) => { - await MONGODB_KILL.scores.remove({}); - await MONGODB_KILL.scores.insert([ - { - chartID: Testing511SPA.chartID, - userID: 1, - scoreID: "foo", - }, - { - chartID: Testing511SPA.chartID, - userID: 1, - scoreID: "bar", - }, - { - chartID: Testing511SPA.chartID, - userID: 2, - scoreID: "baz", - }, - ] as Array); - - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/scores/${Testing511SPA.chartID}`, - ); - - const scoreIDs = res.body.body.map((e: ScoreDocument) => e.scoreID); - - t.equal(scoreIDs.length, 2); - t.ok(scoreIDs.includes("foo")); - t.ok(scoreIDs.includes("bar")); - - t.end(); - }); - - t.test("Should reject a chart that does not exist.", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/scores/CHART_THAT_DOESNT_EXIST`, - ); - - t.equal(res.statusCode, 404); - t.match(res.body.description, /chart does not exist/iu); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts index 179120ffd..1ba538c8e 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts @@ -88,13 +88,13 @@ async function seedIidxChartAndScores(opts: { otherUserId: number; targetUserId: }; } -describe("GET /api/v1/users/:userID/games/:game/:playtype/scores/:chartID", () => { +describe("GET /api/v1/users/:userID/games/:game/scores/:chartID", () => { it("returns 404 when the chart does not exist", async () => { const { id } = await seedUser({ username: "ugpt_scores_chart_missing" }); await seedIidxSpProfile(id); const res = await mockApi.get( - `/api/v1/users/${id}/games/iidx/SP/scores/00000000-0000-0000-0000-000000000000`, + `/api/v1/users/${id}/games/iidx-sp/scores/00000000-0000-0000-0000-000000000000`, ); expect(res.status).toBe(404); @@ -140,7 +140,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/scores/:chartID", () = }) .execute(); - const res = await mockApi.get(`/api/v1/users/${targetId}/games/iidx/SP/scores/${chartPg}`); + const res = await mockApi.get(`/api/v1/users/${targetId}/games/iidx-sp/scores/${chartPg}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -167,7 +167,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/scores/:chartID", () = }) .execute(); - const res2 = await mockApi.get(`/api/v1/users/${targetId}/games/iidx/SP/scores/${chartPg}`); + const res2 = await mockApi.get(`/api/v1/users/${targetId}/games/iidx-sp/scores/${chartPg}`); expect(res2.body.body).toEqual([]); }); @@ -181,7 +181,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/scores/:chartID", () = otherUserId: otherId, }); - const res = await mockApi.get(`/api/v1/users/${targetId}/games/iidx/SP/scores/${chartPg}`); + const res = await mockApi.get(`/api/v1/users/${targetId}/games/iidx-sp/scores/${chartPg}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts index 589539a04..5d63d76fc 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts @@ -1,139 +1,118 @@ -import { GetChartById } from "#lib/db-formats/chart.js"; +import { GetChartByIdForGame } from "#lib/db-formats/chart"; import { type ScoreDocumentJoinRow, SELECT_SCORE_DOCUMENT, ToScoreDocument, } from "#lib/db-formats/score"; -import { SearchSpecificGameSongsAndCharts } from "#lib/search/song-charts.js"; -import { HyperAggressiveRateLimitMiddleware } from "#server/middleware/rate-limiter"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { SearchSpecificGameSongsAndCharts } from "#lib/search/song-charts"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; import { GetRelevantSongsAndCharts } from "#utils/db"; import { GetPrimaryScoresForUserUGPT, GetRecentUGPTScoresByTimeAchieved, - GetScoresForUserOnChartPgIds, + GetScoresForUserOnChartIDs, } from "#utils/queries/scores"; -import { GetUGPT } from "#utils/req-tachi-data"; import { FilterChartsAndSongs } from "#utils/scores"; -import { Router } from "express"; -import { GamePTToV3 } from "tachi-common"; - -const router: Router = Router({ mergeParams: true }); +import { ExpectedErr } from "bliss"; /** * Searches a user's individual scores. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/scores + * @name GET /api/v1/users/:userID/games/:game/scores */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/scores", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; - if (typeof req.query.search !== "string") { - return res.status(400).json({ - success: false, - description: `Invalid value for search parameter.`, - }); - } + const { songs: allSongs, charts: allCharts } = await SearchSpecificGameSongsAndCharts( + game, + input.search, + ); - const { songs: allSongs, charts: allCharts } = await SearchSpecificGameSongsAndCharts( - game, - req.query.search, - playtype, - ); + const chartIDs = [...new Set(allCharts.map((c) => c.chartID))]; + const scores = await GetScoresForUserOnChartIDs(user.id, game, chartIDs, 30); - const v3Game = GamePTToV3(game, playtype); - const chartPgIds = [...new Set(allCharts.map((c) => c.chartID))]; - const scores = await GetScoresForUserOnChartPgIds(user.id, v3Game, chartPgIds, 30); + const { songs, charts } = FilterChartsAndSongs(scores, allCharts, allSongs); - const { songs, charts } = FilterChartsAndSongs(scores, allCharts, allSongs); - - return res.status(200).json({ - success: true, - description: `Retrieved ${scores.length} scores.`, - body: { - scores, - songs, - charts, - }, - }); -}); + return success(`Retrieved ${scores.length} scores.`, { charts, scores, songs }); + }, +); /** - * Retrieve all scores from this user. + * Retrieve all scores from this user (expensive). * - * @warn This endpoint is expensive, and is rate-limited as such. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/scores/all + * @name GET /api/v1/users/:userID/games/:game/scores/all */ -router.get("/all", HyperAggressiveRateLimitMiddleware, async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/scores/all", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const scores = await GetPrimaryScoresForUserUGPT(user.id, game, playtype); + const scores = await GetPrimaryScoresForUserUGPT(user.id, game); + const { songs, charts } = await GetRelevantSongsAndCharts(scores); - const { songs, charts } = await GetRelevantSongsAndCharts(scores, game); - - return res.status(200).json({ - success: true, - description: `Returned ${scores.length} PBs.`, - body: { scores, songs, charts }, - }); -}); + return success(`Returned ${scores.length} PBs.`, { charts, scores, songs }); + }, +); /** * Returns a users recent 100 scores for this game. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/scores/recent + * @name GET /api/v1/users/:userID/games/:game/scores/recent */ -router.get("/recent", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/scores/recent", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const recentScores = await GetRecentUGPTScoresByTimeAchieved(user.id, game, playtype, 100); + const recentScores = await GetRecentUGPTScoresByTimeAchieved(user.id, game, 100); + const { songs, charts } = await GetRelevantSongsAndCharts(recentScores); - const { songs, charts } = await GetRelevantSongsAndCharts(recentScores, game); - - return res.status(200).json({ - success: true, - description: `Retrieved ${recentScores.length} scores.`, - body: { + return success(`Retrieved ${recentScores.length} scores.`, { + charts, scores: recentScores, songs, - charts, - }, - }); -}); + }); + }, +); /** * Retrieve all the scores a user has on the given chartID. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/scores/:chartID + * @name GET /api/v1/users/:userID/games/:game/scores/:chartID */ -router.get("/:chartID", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/scores/:chartID", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; - const chart = await GetChartById(GamePTToV3(game, playtype), req.params.chartID); + const chart = await GetChartByIdForGame(game, params.chartID); - if (!chart) { - return res.status(404).json({ - success: false, - description: `This chart does not exist.`, - }); - } + if (!chart) { + throw new ExpectedErr(404, "This chart does not exist."); + } - const rows = await DB.selectFrom("score") - .innerJoin("chart", "chart.id", "score.chart_id") - .innerJoin("song", "song.id", "chart.song_id") - .leftJoin("import", "import.id", "score.import_id") - .select(SELECT_SCORE_DOCUMENT) - .where("score.user_id", "=", user.id) - .where("chart.id", "=", chart.chartID) - .orderBy("score.time_added", "desc") - .execute(); + const rows = await DB.selectFrom("score") + .innerJoin("chart", "chart.id", "score.chart_id") + .innerJoin("song", "song.id", "chart.song_id") + .leftJoin("import", "import.id", "score.import_id") + .select(SELECT_SCORE_DOCUMENT) + .where("score.user_id", "=", user.id) + .where("chart.id", "=", chart.chartID) + .orderBy("score.time_added", "desc") + .execute(); - return res.status(200).json({ - success: true, - description: `Returned ${rows.length} scores.`, - body: rows.map((r) => ToScoreDocument(r as ScoreDocumentJoinRow)), - }); -}); - -export default router; + return success( + `Returned ${rows.length} scores.`, + rows.map((r) => ToScoreDocument(r as ScoreDocumentJoinRow)), + ); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.oldtest.ts deleted file mode 100644 index 2731115d4..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.oldtest.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { SessionDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { LoadTachiIIDXData } from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should return 400 if no search param is given", async (t) => { - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/sessions"); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.test("Should return 400 if invalid search param is given", async (t) => { - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search=foo&search=bar", - ); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - // evil eval attempts - const res2 = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search[$where]=process.exit(1)", - ); - - t.equal(res2.statusCode, 400); - t.equal(res2.body.success, false); - - t.end(); - }); - - t.test("Should search a user's sessions.", async (t) => { - await MONGODB_KILL.sessions.insert( - ["Epic Session", "Session Of Epic", "Epic Gaming", "something else", "bad session"].map( - (e) => ({ - userID: 1, - game: "iidx", - playtype: "SP", - name: e, - desc: "something", - - // hack to avoid db nonsense - sessionID: e, - }), - ) as Array, - ); - - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search=Epic", - ); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 3 sessions.", - body: [], - }); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/best", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadTachiIIDXData); - - t.test("Should return a user's best 100 sessions.", async (t) => { - const sessions: Array = []; - - for (let i = 0; i < 200; i++) { - sessions.push({ - sessionID: i.toString(), - game: "iidx", - playtype: "SP", - userID: 1, - scoreIDs: [], - calculatedData: { - ktLampRating: i, - }, - desc: null, - name: i.toString(), - highlight: false, - timeEnded: 0, - timeInserted: 0, - timeStarted: 0, - }); - } - - await MONGODB_KILL.sessions.remove({}); - await MONGODB_KILL.sessions.insert(sessions); - - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/sessions/best"); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 100 sessions.", - }); - - t.strictSame( - res.body.body.map((e: SessionDocument) => e.sessionID), - sessions - .slice(100) - .reverse() - .map((e) => e.sessionID), - ); - - t.end(); - }); - - t.test( - "Should return a user's best 100 sessions according to the provided rating alg.", - async (t) => { - const sessions: Array = []; - - for (let i = 0; i < 200; i++) { - sessions.push({ - sessionID: i.toString(), - game: "iidx", - playtype: "SP", - userID: 1, - scoreIDs: [], - calculatedData: { - ktLampRating: 200 - i, - BPI: i, - }, - desc: null, - name: i.toString(), - highlight: false, - timeEnded: 0, - timeInserted: 0, - timeStarted: 0, - }); - } - - await MONGODB_KILL.sessions.remove({}); - await MONGODB_KILL.sessions.insert(sessions); - - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/sessions/best?alg=BPI", - ); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 100 sessions.", - }); - - t.strictSame( - res.body.body.map((e: SessionDocument) => e.sessionID), - sessions - .slice(100) - .reverse() - .map((e) => e.sessionID), - ); - - t.end(); - }, - ); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return only highlighted sessions.", async (t) => { - await MONGODB_KILL.sessions.insert({ - highlight: true, - userID: 1, - game: "iidx", - playtype: "SP", - sessionID: "highlighted_id", - scoreIDs: [""], - } as SessionDocument); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/sessions/highlighted"); - - t.equal(res.body.body.length, 1); - t.equal(res.body.body[0].sessionID, "highlighted_id"); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the users most recent sessions.", async (t) => { - await MONGODB_KILL.sessions.remove({}); - await MONGODB_KILL.sessions.insert([ - { - highlight: false, - userID: 1, - game: "iidx", - playtype: "SP", - sessionID: "recent_id1", - timeEnded: 1, - scoreIDs: [""], - }, - { - highlight: false, - userID: 1, - game: "iidx", - playtype: "SP", - sessionID: "recent_id2", - timeEnded: 3, - scoreIDs: [""], - }, - { - highlight: false, - userID: 1, - game: "iidx", - playtype: "SP", - sessionID: "recent_id3", - timeEnded: 2, - scoreIDs: [""], - }, - ] as Array); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/sessions/recent"); - - t.equal(res.body.body.length, 3); - t.strictSame( - // @ts-expect-error temporary type hack - res.body.body.map((e) => e.sessionID), - ["recent_id2", "recent_id3", "recent_id1"], - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts index 8271b089f..7f281dc6f 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts @@ -43,12 +43,12 @@ async function insertSession(opts: { .execute(); } -describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions", () => { +describe("GET /api/v1/users/:userID/games/:game/sessions", () => { it("returns 400 when search query is missing or not a string", async () => { const { id: userId } = await seedUser({ username: `sess_search_bad_${++counter}` }); await seedIidxSpProfile(userId); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/sessions`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/sessions`); expect(res.status).toBe(400); expect(res.body.success).toBe(false); @@ -69,7 +69,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions", () => { }); const res = await mockApi.get( - `/api/v1/users/${userId}/games/iidx/SP/sessions?search=Flower`, + `/api/v1/users/${userId}/games/iidx-sp/sessions?search=Flower`, ); expect(res.status).toBe(200); @@ -78,7 +78,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions", () => { }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/best", () => { +describe("GET /api/v1/users/:userID/games/:game/sessions/best", () => { it("sorts sessions by default session rating alg from calculated_data", async () => { const { id: userId } = await seedUser({ username: `sess_best_${++counter}` }); await seedIidxSpProfile(userId); @@ -103,7 +103,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/best", () => timeEndedIso: t, }); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/sessions/best`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/sessions/best`); expect(res.status).toBe(200); expect(res.body.body[0].sessionID).toBe(high); @@ -115,14 +115,14 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/best", () => await seedIidxSpProfile(userId); const res = await mockApi.get( - `/api/v1/users/${userId}/games/iidx/SP/sessions/best?alg=not_a_real_alg`, + `/api/v1/users/${userId}/games/iidx-sp/sessions/best?alg=not_a_real_alg`, ); expect(res.status).toBe(400); }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted", () => { +describe("GET /api/v1/users/:userID/games/:game/sessions/highlighted", () => { it("returns only highlighted sessions", async () => { const { id: userId } = await seedUser({ username: `sess_hi_${++counter}` }); await seedIidxSpProfile(userId); @@ -147,7 +147,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted", timeEndedIso: t, }); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/sessions/highlighted`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/sessions/highlighted`); expect(res.status).toBe(200); expect(res.body.body).toHaveLength(1); @@ -155,7 +155,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted", }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent", () => { +describe("GET /api/v1/users/:userID/games/:game/sessions/recent", () => { it("orders sessions by timeEnded descending", async () => { const { id: userId } = await seedUser({ username: `sess_rec_${++counter}` }); await seedIidxSpProfile(userId); @@ -179,7 +179,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent", () = timeEndedIso: newer, }); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/sessions/recent`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/sessions/recent`); expect(res.status).toBe(200); expect(res.body.body[0].sessionID).toBe(`sess-new-${counter}`); @@ -187,12 +187,12 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent", () = }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/last", () => { +describe("GET /api/v1/users/:userID/games/:game/sessions/last", () => { it("returns 404 when the user has no sessions for this GPT", async () => { const { id: userId } = await seedUser({ username: `sess_last_404_${++counter}` }); await seedIidxSpProfile(userId); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/sessions/last`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/sessions/last`); expect(res.status).toBe(404); expect(res.body.success).toBe(false); @@ -221,7 +221,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/last", () => timeEndedIso: newer, }); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/sessions/last`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/sessions/last`); expect(res.status).toBe(200); expect(res.body.body.session.sessionID).toBe(`sess-last-new-${counter}`); @@ -229,7 +229,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/last", () => }); }); -describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/calendar", () => { +describe("GET /api/v1/users/:userID/games/:game/sessions/calendar", () => { it("returns slim session objects for the calendar view", async () => { const { id: userId } = await seedUser({ username: `sess_cal_${++counter}` }); await seedIidxSpProfile(userId); @@ -245,7 +245,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/calendar", () timeEndedIso: t, }); - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/sessions/calendar`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/sessions/calendar`); expect(res.status).toBe(200); expect(res.body.body).toHaveLength(1); @@ -253,8 +253,8 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/sessions/calendar", () expect(ev.sessionID).toBe(sid); expect(ev.name).toBe("Cal"); - expect(ev.game).toBe("iidx"); - expect(ev.playtype).toBe("SP"); + expect(ev.game).toBe("iidx-sp"); + expect(ev).not.toHaveProperty("playtype"); expect(ev).not.toHaveProperty("scoreIDs"); expect(ev).not.toHaveProperty("calculatedData"); }); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts index ff606a8c3..7a36af751 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts @@ -4,39 +4,35 @@ import { ToSessionCalendarDocument, ToSessionDocument, } from "#lib/db-formats/session"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { GetSessionScoreInfo } from "#lib/score-import/framework/sessions/sessions"; import { SearchSessions } from "#lib/search/search"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; import { GetScoreIdsGroupedBySessionId } from "#utils/queries/sessions"; -import { GetUGPT } from "#utils/req-tachi-data"; import { CheckStrSessionAlg } from "#utils/string-checks"; -import { Router } from "express"; +import { ExpectedErr } from "bliss"; import { sql } from "kysely"; import { type AnySessionRatingAlg, - GamePTToV3, - GetGamePTConfig, - type MONGO_SessionDocument, + GetGameConfig, + LEGACY_GameToGameGroupPT, + type SessionDocument, type SessionScoreInfo, } from "tachi-common"; import { type Game } from "tachi-db"; -const router: Router = Router({ mergeParams: true }); - async function attachSessionScoreInfo( - sessions: Array, -): Promise } & MONGO_SessionDocument>> { - const sessionsWithScoreInfo: Array< - { __scoreInfo: Array } & MONGO_SessionDocument - > = []; + sessions: Array, +): Promise } & SessionDocument>> { + const sessionsWithScoreInfo: Array<{ __scoreInfo: Array } & SessionDocument> = + []; await Promise.all( sessions.map((session) => GetSessionScoreInfo(session).then((r) => { - sessionsWithScoreInfo.push({ - ...session, - __scoreInfo: r, - }); + sessionsWithScoreInfo.push({ ...session, __scoreInfo: r }); }), ), ); @@ -47,217 +43,183 @@ async function attachSessionScoreInfo( /** * Search a users sessions. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions + * @name GET /api/v1/users/:userID/games/:game/sessions */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/sessions", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(game); - if (typeof req.query.search !== "string") { - return res.status(400).json({ - success: false, - description: `Invalid value for search parameter.`, - }); - } + const hits = await SearchSessions(input.search, gameGroup, playtype, user.id, 100); - const hits = await SearchSessions(req.query.search, game, playtype, user.id, 100); - - return res.status(200).json({ - success: true, - description: `Retrieved ${hits.length} sessions.`, - body: hits.map(({ session, rank }) => ({ ...session, __textScore: rank })), - }); -}); + return success( + `Retrieved ${hits.length} sessions.`, + hits.map(({ session, rank }) => ({ ...session, __textScore: rank })), + ); + }, +); /** - * Returns a user's best 100 sessions according to the default statistic - * for that game. + * Returns a user's best 100 sessions according to the default statistic for that game. * - * @param alg - An override to specify a different algorithm for that game. - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/best + * @name GET /api/v1/users/:userID/games/:game/sessions/best */ -router.get("/best", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/sessions/best", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; + const gameConfig = GetGameConfig(game); - const gptConfig = GetGamePTConfig(game, playtype); + let alg = gameConfig.defaultSessionRatingAlg as AnySessionRatingAlg; - let alg = gptConfig.defaultSessionRatingAlg as AnySessionRatingAlg; + if (typeof input.alg === "string") { + const userAlg = CheckStrSessionAlg(game, input.alg); - if (typeof req.query.alg === "string") { - const userAlg = CheckStrSessionAlg(game, playtype, req.query.alg); + if (userAlg === null) { + throw new ExpectedErr( + 400, + `Invalid algorithm '${input.alg}' provided. Expected any of ${Object.keys(gameConfig.sessionRatingAlgs).join(", ")}.`, + ); + } - if (userAlg === null) { - return res.status(400).json({ - success: false, - description: `Invalid algorithm '${ - req.query.alg - }' provided. Expected any of ${Object.keys(gptConfig.sessionRatingAlgs).join( - ", ", - )}.`, - }); + alg = userAlg; } - alg = userAlg; - } + const rows = await DB.selectFrom("session") + .select(SELECT_SESSION_DOCUMENT) + .where("user_id", "=", user.id) + .where("game", "=", game) + .orderBy( + sql`(session.calculated_data::jsonb->>${sql.lit(alg)})::double precision desc nulls last`, + ) + .orderBy("session.time_ended", "desc") + .limit(100) + .execute(); - const v3Game = GamePTToV3(game, playtype) as Game; + const scoreMap = await GetScoreIdsGroupedBySessionId(rows.map((r) => r.id)); + const sessions = rows.map((row) => ToSessionDocument(row, scoreMap.get(row.id) ?? [])); + const sessionsWithScoreInfo = await attachSessionScoreInfo(sessions); - const rows = await DB.selectFrom("session") - .select(SELECT_SESSION_DOCUMENT) - .where("user_id", "=", user.id) - .where("game", "=", v3Game) - .orderBy( - sql`(session.calculated_data::jsonb->>${sql.lit(alg)})::double precision desc nulls last`, - ) - .orderBy("session.time_ended", "desc") - .limit(100) - .execute(); + sessionsWithScoreInfo.sort( + (a, b) => (b.calculatedData[alg] ?? -Infinity) - (a.calculatedData[alg] ?? -Infinity), + ); - const scoreMap = await GetScoreIdsGroupedBySessionId(rows.map((r) => r.id)); - - const sessions = rows.map((row) => ToSessionDocument(row, scoreMap.get(row.id) ?? [])); - - const sessionsWithScoreInfo = await attachSessionScoreInfo(sessions); - - sessionsWithScoreInfo.sort( - (a, b) => (b.calculatedData[alg] ?? -Infinity) - (a.calculatedData[alg] ?? -Infinity), - ); - - return res.status(200).json({ - success: true, - description: `Retrieved ${sessions.length} sessions.`, - body: sessionsWithScoreInfo, - }); -}); + return success(`Retrieved ${sessions.length} sessions.`, sessionsWithScoreInfo); + }, +); /** - * Returns a users 100 most recent highlighted sessions. Returned in timeEnded order. + * Returns a users 100 most recent highlighted sessions. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted + * @name GET /api/v1/users/:userID/games/:game/sessions/highlighted */ -router.get("/highlighted", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/sessions/highlighted", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const v3Game = GamePTToV3(game, playtype) as Game; + const rows = await DB.selectFrom("session") + .select(SELECT_SESSION_DOCUMENT) + .where("user_id", "=", user.id) + .where("game", "=", game) + .where("highlight", "=", true) + .orderBy("session.time_ended", "desc") + .limit(100) + .execute(); - const rows = await DB.selectFrom("session") - .select(SELECT_SESSION_DOCUMENT) - .where("user_id", "=", user.id) - .where("game", "=", v3Game) - .where("highlight", "=", true) - .orderBy("session.time_ended", "desc") - .limit(100) - .execute(); + const scoreMap = await GetScoreIdsGroupedBySessionId(rows.map((r) => r.id)); + const sessions = rows.map((row) => ToSessionDocument(row, scoreMap.get(row.id) ?? [])); + const sessionsWithScoreInfo = await attachSessionScoreInfo(sessions); - const scoreMap = await GetScoreIdsGroupedBySessionId(rows.map((r) => r.id)); + sessionsWithScoreInfo.sort((a, b) => b.timeEnded - a.timeEnded); - const sessions = rows.map((row) => ToSessionDocument(row, scoreMap.get(row.id) ?? [])); - - const sessionsWithScoreInfo = await attachSessionScoreInfo(sessions); - - sessionsWithScoreInfo.sort((a, b) => b.timeEnded - a.timeEnded); - - return res.status(200).json({ - success: true, - description: `Returned ${sessions.length} sessions.`, - body: sessionsWithScoreInfo, - }); -}); + return success(`Returned ${sessions.length} sessions.`, sessionsWithScoreInfo); + }, +); /** - * Returns a users 100 most recent sessions. Returned in timeEnded order. + * Returns a users 100 most recent sessions. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent + * @name GET /api/v1/users/:userID/games/:game/sessions/recent */ -router.get("/recent", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/sessions/recent", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; + const v3Game = game as Game; - const v3Game = GamePTToV3(game, playtype) as Game; + const rows = await DB.selectFrom("session") + .select(SELECT_SESSION_DOCUMENT) + .where("user_id", "=", user.id) + .where("game", "=", v3Game) + .orderBy("session.time_ended", "desc") + .limit(100) + .execute(); - const rows = await DB.selectFrom("session") - .select(SELECT_SESSION_DOCUMENT) - .where("user_id", "=", user.id) - .where("game", "=", v3Game) - .orderBy("session.time_ended", "desc") - .limit(100) - .execute(); + const scoreMap = await GetScoreIdsGroupedBySessionId(rows.map((r) => r.id)); + const sessions = rows.map((row) => ToSessionDocument(row, scoreMap.get(row.id) ?? [])); + const sessionsWithScoreInfo = await attachSessionScoreInfo(sessions); - const scoreMap = await GetScoreIdsGroupedBySessionId(rows.map((r) => r.id)); + sessionsWithScoreInfo.sort((a, b) => b.timeEnded - a.timeEnded); - const sessions = rows.map((row) => ToSessionDocument(row, scoreMap.get(row.id) ?? [])); - - const sessionsWithScoreInfo = await attachSessionScoreInfo(sessions); - - sessionsWithScoreInfo.sort((a, b) => b.timeEnded - a.timeEnded); - - return res.status(200).json({ - success: true, - description: `Returned ${sessions.length} sessions.`, - body: sessionsWithScoreInfo, - }); -}); + return success(`Returned ${sessions.length} sessions.`, sessionsWithScoreInfo); + }, +); /** * Returns a user's most recent session. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/last + * @name GET /api/v1/users/:userID/games/:game/sessions/last */ -router.get("/last", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/sessions/last", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; + const v3Game = game as Game; - const v3Game = GamePTToV3(game, playtype) as Game; + const row = await DB.selectFrom("session") + .select(SELECT_SESSION_DOCUMENT) + .where("user_id", "=", user.id) + .where("game", "=", v3Game) + .orderBy("session.time_ended", "desc") + .executeTakeFirst(); - const row = await DB.selectFrom("session") - .select(SELECT_SESSION_DOCUMENT) - .where("user_id", "=", user.id) - .where("game", "=", v3Game) - .orderBy("session.time_ended", "desc") - .executeTakeFirst(); + if (!row) { + throw new ExpectedErr(404, "This user has not got any sessions!"); + } - if (!row) { - return res.status(404).json({ - success: false, - description: `This user has not got any sessions!`, - }); - } + const scoreMap = await GetScoreIdsGroupedBySessionId([row.id]); + const session = ToSessionDocument(row, scoreMap.get(row.id) ?? []); + const scoreInfo = await GetSessionScoreInfo(session); - const scoreMap = await GetScoreIdsGroupedBySessionId([row.id]); - - const session = ToSessionDocument(row, scoreMap.get(row.id) ?? []); - - const scoreInfo = await GetSessionScoreInfo(session); - - return res.status(200).json({ - success: true, - description: `Returned a session.`, - body: { - session, - scoreInfo, - }, - }); -}); + return success("Returned a session.", { scoreInfo, session }); + }, +); /** - * Returns all sessions, but with unecessary properties removed so as to reduce - * bandwidth. This is used for the calendar view in tachi-client, hence the name. + * Returns all sessions with unnecessary properties removed for calendar display. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/calendar + * @name GET /api/v1/users/:userID/games/:game/sessions/calendar */ -router.get("/calendar", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/sessions/calendar", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; + const v3Game = game as Game; - const v3Game = GamePTToV3(game, playtype) as Game; + const rows = await DB.selectFrom("session") + .select(SELECT_SESSION_CALENDAR) + .where("user_id", "=", user.id) + .where("game", "=", v3Game) + .execute(); - const rows = await DB.selectFrom("session") - .select(SELECT_SESSION_CALENDAR) - .where("user_id", "=", user.id) - .where("game", "=", v3Game) - .execute(); - - return res.status(200).json({ - success: true, - description: `Found ${rows.length} events.`, - body: rows.map(ToSessionCalendarDocument), - }); -}); - -export default router; + return success(`Found ${rows.length} events.`, rows.map(ToSessionCalendarDocument)); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.oldtest.ts deleted file mode 100644 index 8dee47e68..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.oldtest.ts +++ /dev/null @@ -1,440 +0,0 @@ -import type { UGPTSettingsDocument } from "tachi-common"; - -/* eslint-disable no-await-in-loop */ -import MONGODB_KILL from "#services/mongo/db"; -import { mkFakeUser } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a user's settings.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/settings"); - - t.strictSame(res.body.body, { - userID: 1, - game: "iidx", - playtype: "SP", - preferences: { - preferredScoreAlg: null, - preferredSessionAlg: null, - preferredProfileAlg: null, - preferredRanking: null, - preferredDefaultEnum: null, - defaultTable: null, - stats: [], - gameSpecific: { - display2DXTra: false, - bpiTarget: 0, - }, - }, - rivals: [], - }); - - t.end(); - }); - - t.end(); -}); - -t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should update a user's settings.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "api_token", - permissions: { - customise_profile: true, - }, - token: "api_token", - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - preferredScoreAlg: "ktLampRating", - }); - - t.strictSame( - res.body.body, - { - userID: 1, - game: "iidx", - playtype: "SP", - preferences: { - preferredScoreAlg: "ktLampRating", - preferredSessionAlg: null, - preferredProfileAlg: null, - preferredDefaultEnum: null, - preferredRanking: null, - defaultTable: null, - stats: [], - gameSpecific: { - display2DXTra: false, - bpiTarget: 0, - }, - }, - rivals: [], - }, - "Should only update the mutated properties.", - ); - - const data = await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(data?.preferences.preferredScoreAlg, "ktLampRating"); - - t.end(); - }); - - t.test("Should update a user's game specific settings.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "api_token", - permissions: { - customise_profile: true, - }, - token: "api_token", - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - preferredScoreAlg: "ktLampRating", - gameSpecific: { - display2DXTra: true, - bpiTarget: 50, - }, - }); - - t.strictSame( - res.body.body, - { - userID: 1, - game: "iidx", - playtype: "SP", - preferences: { - preferredScoreAlg: "ktLampRating", - preferredSessionAlg: null, - preferredProfileAlg: null, - preferredRanking: null, - preferredDefaultEnum: null, - defaultTable: null, - stats: [], - gameSpecific: { - display2DXTra: true, - bpiTarget: 50, - }, - }, - rivals: [], - }, - "Should only update the mutated properties.", - ); - - const data = await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(data?.preferences.preferredScoreAlg, "ktLampRating"); - - t.end(); - }); - - t.test("Should update an IIDX player's bpiTarget settings.", (t) => { - t.beforeEach(async () => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "api_token", - permissions: { - customise_profile: true, - }, - token: "api_token", - fromAPIClient: null, - }); - }); - - for (const target of [0, 10, 15, 17.5, 100]) { - t.test(`Should be able to update the BPI target to ${target}`, async (t) => { - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - gameSpecific: { - bpiTarget: target, - }, - }); - - t.equal(res.statusCode, 200); - - t.strictSame( - res.body.body, - { - userID: 1, - game: "iidx", - playtype: "SP", - preferences: { - preferredScoreAlg: null, - preferredSessionAlg: null, - preferredProfileAlg: null, - preferredRanking: null, - preferredDefaultEnum: null, - defaultTable: null, - stats: [], - gameSpecific: { - display2DXTra: false, - bpiTarget: target, - }, - }, - rivals: [], - }, - "Should only update the mutated properties.", - ); - - const data = (await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - })) as UGPTSettingsDocument<"iidx:SP"> | undefined; - - t.equal(data?.preferences.gameSpecific.bpiTarget, target); - - t.end(); - }); - } - - t.test("Should reject BPI targets < -15", async (t) => { - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - gameSpecific: { - bpiTarget: -20, - }, - }); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should reject >= 100 BPI targets", async (t) => { - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - gameSpecific: { - bpiTarget: 101, - }, - }); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); - }); - - t.test("Should reject arbitrary things on game specific settings.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "api_token", - permissions: { - customise_profile: true, - }, - token: "api_token", - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - preferredScoreAlg: "BPI", - gameSpecific: { - display2DXTra: true, - arbitraryValue: {}, - }, - }); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Requires the user to be authed as the requested user.", async (t) => { - await MONGODB_KILL.users.insert(mkFakeUser(2)); - await MONGODB_KILL["api-tokens"].insert({ - userID: 2, - identifier: "api_token", - permissions: { - customise_profile: true, - }, - token: "api_token", - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", `Bearer api_token`); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Requires the permission customise_profile", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "api_token", - permissions: { - customise_profile: false, - }, - token: "api_token", - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", `Bearer api_token`); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should reject invalid values.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "foo", - permissions: { - customise_profile: true, - }, - token: "api_token", - fromAPIClient: null, - }); - - for (const key of [ - "preferredScoreAlg", - "preferredSessionAlg", - "preferredProfileAlg", - ] as const) { - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - [key]: "nonsense", - }); - - t.equal(res.statusCode, 400); - - const data = await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(data?.preferences[key], null, "Should be unmodified."); - } - - t.end(); - }); - - t.test("Should update a user's default table.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "api_token", - permissions: { - customise_profile: true, - }, - token: "api_token", - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - defaultTable: "mock_table", - }); - - t.strictSame( - res.body.body, - { - userID: 1, - game: "iidx", - playtype: "SP", - preferences: { - preferredScoreAlg: null, - preferredSessionAlg: null, - preferredProfileAlg: null, - preferredRanking: null, - preferredDefaultEnum: null, - defaultTable: "mock_table", - stats: [], - gameSpecific: { - display2DXTra: false, - bpiTarget: 0, - }, - }, - rivals: [], - }, - "Should only update the mutated properties.", - ); - - const data = await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(data?.preferences.defaultTable, "mock_table"); - - t.end(); - }); - - t.test("Should not allow updating to a table that doesn't exist.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "api_token", - permissions: { - customise_profile: true, - }, - token: "api_token", - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - defaultTable: "fake_table", - }); - - t.equal(res.statusCode, 400); - - const data = await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(data?.preferences.defaultTable, null); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts new file mode 100644 index 000000000..979e9deb7 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts @@ -0,0 +1,93 @@ +import { seedApiToken } from "#actions/test-utils/api-tokens"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function seedIidxUgpt(userId: number) { + await DB.insertInto("game_profile") + .values({ + user_id: userId, + game: "iidx-sp", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + }) + .execute(); + + await DB.insertInto("game_settings") + .values({ + user_id: userId, + game: "iidx-sp", + pf_preferred_score_alg: null, + pf_preferred_session_alg: null, + pf_preferred_profile_alg: null, + pf_preferred_default_enum: null, + pf_default_table: null, + pf_preferred_ranking: null, + data: JSON.stringify({ display2DXTra: false, bpiTarget: 0 }), + }) + .execute(); +} + +describe("GET /api/v1/users/:userID/games/:game/settings", () => { + beforeEach(async () => { + const { id } = await seedUser({ username: `ugpt_get_${Date.now()}` }); + await seedIidxUgpt(id); + }); + + it("returns default IIDX SP settings", async () => { + const res = await mockApi.get("/api/v1/users/1/games/iidx-sp/settings"); + + expect(res.status).toBe(200); + expect(res.body.body).toMatchObject({ + userID: 1, + game: "iidx-sp", + preferences: { + preferredScoreAlg: null, + preferredSessionAlg: null, + preferredProfileAlg: null, + preferredRanking: null, + preferredDefaultEnum: null, + defaultTable: null, + stats: [], + gameSpecific: { + display2DXTra: false, + bpiTarget: 0, + }, + }, + rivals: [], + }); + }); +}); + +describe("PATCH /api/v1/users/:userID/games/:game/settings", () => { + beforeEach(async () => { + await seedUser({ username: "ugpt_patch", withCredential: true, withSettings: true }); + await seedIidxUgpt(1); + await seedApiToken({ token: "api_token", userId: 1, identifier: "a" }); + await DB.updateTable("priv_api_token") + .set({ pm_customise_profile: true }) + .where("priv_api_token.token", "=", "api_token") + .execute(); + }); + + it("updates preferredScoreAlg", async () => { + const res = await mockApi + .patch("/api/v1/users/1/games/iidx-sp/settings") + .set("Authorization", "Bearer api_token") + .send({ + preferredScoreAlg: "ktLampRating", + }); + + expect(res.status).toBe(200); + expect(res.body.body.preferences.preferredScoreAlg).toBe("ktLampRating"); + }); +}); + +describe("PATCH UGPT settings — extended cases", () => { + it.todo( + "port remaining router.oldtest.ts cases (BPI bounds, defaultTable validation, cross-user 403, …)", + ); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts index fb358b9a5..c9a645e62 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts @@ -1,114 +1,60 @@ import { ACTION_PatchUGPTSettings } from "#actions/patch-ugpt-settings"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings"; -import { RequirePermissions } from "#server/middleware/auth"; -import { FormatPrError, optNull } from "#utils/prudence"; -import { GetUGPT } from "#utils/req-tachi-data"; -import { FormatUserDoc, GetUserWithIDGuaranteed } from "#utils/user"; -import { Router } from "express"; -import { p } from "prudence"; -import { - GetGamePTConfig, - GetScoreMetrics, - type MONGO_UGPTSettingsDocument, - PrudenceZodShim, -} from "tachi-common"; - -import { RequireAuthedAsUser } from "../../../../middleware"; - -const router: Router = Router({ mergeParams: true }); +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import { GetUserWithIDGuaranteed } from "#utils/user"; +import { ExpectedErr } from "bliss"; /** - * Update your settings. + * Returns this user's UGPT settings. * - * @param - See the prudence validation. - * - * @name PATCH /api/v1/users/:userID/games/:game/:playtype/settings + * @name GET /api/v1/users/:userID/games/:game/settings */ -router.patch( - "/", - RequireAuthedAsUser, - RequirePermissions("customise_profile"), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/settings", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const gptConfig = GetGamePTConfig(game, playtype); + const settings = await GetUGPTSettingsDocument(user.id, game); - const gameSpecificSchema = PrudenceZodShim(gptConfig.preferences); - - const err = p(req.safeBody, { - preferredScoreAlg: p.optional( - p.nullable(p.isIn(Object.keys(gptConfig.scoreRatingAlgs))), - ), - preferredSessionAlg: p.optional( - p.nullable(p.isIn(Object.keys(gptConfig.sessionRatingAlgs))), - ), - preferredProfileAlg: p.optional( - p.nullable(p.isIn(Object.keys(gptConfig.profileRatingAlgs))), - ), - defaultTable: "*?string", - preferredRanking: optNull(p.isIn("global", "rival")), - - gameSpecific: optNull(gameSpecificSchema), - preferredDefaultEnum: optNull(p.isIn(...GetScoreMetrics(gptConfig, "ENUM"))), - }); - - if (err) { - return res.status(400).json({ - success: false, - description: FormatPrError(err, "Invalid game-settings."), - }); - } - - const body = req.safeBody as Partial; - - const authUserID = req[SYMBOL_TACHI_API_AUTH].userID; - - if (authUserID === null) { - return res.status(401).json({ - success: false, - description: "Authentication is required for this endpoint.", - }); - } - - const authedUser = await GetUserWithIDGuaranteed(authUserID); - const taker = { ip: req.ip, acct: { id: authedUser.id, username: authedUser.username } }; - - const { settings } = await ACTION_PatchUGPTSettings(taker, { - userID: user.id, - game, - playtype, - preferences: body, - }); - - const description = - Object.keys(body).length === 0 - ? "Nothing has been modified, successfully." - : "Updated settings."; - - return res.status(200).json({ - success: true, - description, - body: settings, - }); + return success(`Returned ${user.username}'s settings.`, settings); }, ); /** - * Returns this user's settings. + * Update UGPT settings preferences. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/settings + * @name PATCH /api/v1/users/:userID/games/:game/settings */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "PATCH /users/:userID/games/:game/settings", + withUserGameProfile, + async ({ ctx, input, req }) => { + const { requestedUser: user, game } = ctx; - const settings = await GetUGPTSettingsDocument(user.id, game, playtype); + const authUserID = req[SYMBOL_TACHI_API_AUTH].userID; - return res.status(200).json({ - success: true, - description: `Returned ${FormatUserDoc(user)}'s settings.`, - body: settings, - }); -}); + if (authUserID === null) { + throw new ExpectedErr(401, "Authentication is required for this endpoint."); + } -export default router; + const authedUser = await GetUserWithIDGuaranteed(authUserID); + const taker = { acct: { id: authedUser.id, username: authedUser.username }, ip: req.ip }; + + const { settings } = await ACTION_PatchUGPTSettings(taker, { + game, + preferences: input as Parameters[1]["preferences"], + userID: user.id, + }); + + const description = + Object.keys(input).length === 0 + ? "Nothing has been modified, successfully." + : "Updated settings."; + + return success(description, settings); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.oldtest.ts deleted file mode 100644 index 634c2064b..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.oldtest.ts +++ /dev/null @@ -1,434 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { mkFakeUser } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { Testing511SPA, TestingIIDXFolderSP10, TestingIIDXSPScorePB } from "#test-utils/test-data"; -import { CreateFolderChartLookup } from "#utils/folder"; -import deepmerge from "deepmerge"; -import { type ChartDocument, IIDX_GRADES, IIDX_LAMPS, type UserDocument } from "tachi-common"; -import t from "tap"; - -const SetFolders = async () => { - await MONGODB_KILL.folders.insert(TestingIIDXFolderSP10); - await CreateFolderChartLookup(TestingIIDXFolderSP10, true); - - await MONGODB_KILL["game-settings"].remove({}); - - await MONGODB_KILL["game-settings"].insert({ - userID: 1, - game: "iidx", - playtype: "SP", - preferences: { - preferredProfileAlg: null, - preferredScoreAlg: null, - preferredSessionAlg: null, - preferredRanking: null, - preferredDefaultEnum: null, - defaultTable: null, - stats: [ - { - folderID: TestingIIDXFolderSP10.folderID, - mode: "folder", - metric: "lamp", - gte: IIDX_LAMPS.HARD_CLEAR, - }, - { - chartID: Testing511SPA.chartID, - mode: "chart", - metric: "score", - }, - ], - gameSpecific: { - display2DXTra: false, - bpiTarget: 0, - }, - }, - rivals: [], - }); - - await MONGODB_KILL["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})); -}; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(SetFolders); - - t.test("Should return the evaluated stats for this user.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/showcase"); - - t.hasStrict(res.body.body, [ - { - stat: { - mode: "folder", - }, - result: { - value: 1, - outOf: 1, - }, - }, - { - stat: { - mode: "chart", - }, - result: { - value: 1479, - }, - }, - ]); - - t.end(); - }); - - t.test("Should project another users stats over this one if projectUser is set.", async (t) => { - await MONGODB_KILL["game-settings"].insert({ - userID: 2, - game: "iidx", - playtype: "SP", - preferences: { - preferredProfileAlg: null, - preferredScoreAlg: null, - preferredSessionAlg: null, - preferredRanking: null, - preferredDefaultEnum: null, - defaultTable: null, - stats: [ - { - mode: "folder", - folderID: TestingIIDXFolderSP10.folderID, - metric: "score", - gte: 1480, - }, - ], - gameSpecific: { - display2DXTra: false, - bpiTarget: 0, - }, - }, - rivals: [], - }); - - // hack - await MONGODB_KILL.users.insert({ - username: "test_acc", - usernameLowercase: "test_acc", - id: 2, - } as UserDocument); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/showcase?projectUser=2"); - - t.hasStrict(res.body.body, [ - { - stat: { - mode: "folder", - }, - result: { - value: 0, - outOf: 1, - }, - }, - ]); - - t.end(); - }); - - t.test("Should return 404 if the user has not played this game.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/games/bms/7K/showcase"); - - t.equal(res.statusCode, 404); - - t.match(res.body.description, /not played/iu); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(SetFolders); - - t.test("Should return a custom folder evaluated stat on a user.", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=3&folderID=${TestingIIDXFolderSP10.folderID}`, - ); - - t.hasStrict(res.body.body, { - result: { - value: 1, - outOf: 1, - }, - }); - - t.end(); - }); - - t.test("Should return a custom chart evaluated stat on a user.", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&chartID=${Testing511SPA.chartID}`, - ); - - t.hasStrict(res.body.body, { - result: { value: IIDX_GRADES.AAA }, - }); - - t.end(); - }); - - t.test("Should reject for invalid folderID.", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=4`, - ); - - t.equal(res.statusCode, 400, "Should reject for no folderID"); - - const res2 = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=4&folderID=foo&folderID=bar`, - ); - - t.equal(res2.statusCode, 400, "Should reject for non-string folderID"); - - t.end(); - }); - - t.test("Should reject for invalid chartID.", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade>e=4`, - ); - - t.equal(res.statusCode, 400, "Should reject for no chartID"); - - const res2 = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&chartID=foo&chartID=bar`, - ); - - t.equal(res2.statusCode, 400, "Should reject for non-string chartID"); - - t.end(); - }); - - t.test("Should reject for chartID that doesn't exist.", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&chartID=chart_does_not_exist`, - ); - - t.equal(res.statusCode, 400); - - await MONGODB_KILL.charts.iidx.insert({ - chartID: "testing_dp_chart", - playtype: "DP", - } as ChartDocument<"iidx:DP">); - - const res2 = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&chartID=testing_dp_chart`, - ); - - t.equal(res2.statusCode, 400); - - t.end(); - }); - - t.test("Should reject for folderID that doesn't exist.", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=4&folderID=invalid`, - ); - - t.equal(res.statusCode, 400); - - const res2 = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=4&folderID=${TestingIIDXFolderSP10.folderID},invalid`, - ); - - t.equal(res2.statusCode, 400); - - t.end(); - }); - - t.test("Should reject for invalid mode", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=nonsense&metric=grade>e=4&chartID=foo`, - ); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); -}); - -t.test("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(SetFolders); - - t.beforeEach( - async () => - // eslint-disable-next-line no-return-await - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "alt_token", - permissions: { - customise_profile: true, - }, - token: "alt_token", - fromAPIClient: null, - }), - ); - - t.test("Requires the user to be authed as the requested user.", async (t) => { - await MONGODB_KILL.users.insert(mkFakeUser(2)); - await MONGODB_KILL["api-tokens"].insert({ - userID: 2, - identifier: "alt_token", - permissions: { - customise_profile: true, - }, - token: "altz_token", - fromAPIClient: null, - }); - - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/showcase") - .set("Authorization", `Bearer altz_token`); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Requires the permission customise_profile", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "alt_token", - permissions: { - customise_profile: false, - }, - token: "unauth_token", - fromAPIClient: null, - }); - - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/showcase") - .set("Authorization", `Bearer unauth_token`); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should replace a user's preferences.stats with the contained stats.", async (t) => { - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/showcase") - .set("Authorization", `Bearer alt_token`) - .send([ - { - mode: "chart", - chartID: Testing511SPA.chartID, - metric: "lamp", - }, - ]); - - t.equal(res.statusCode, 200); - - const data = await MONGODB_KILL["game-settings"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.hasStrict( - data?.preferences.stats, - [ - { - mode: "chart", - chartID: Testing511SPA.chartID, - metric: "lamp", - }, - ], - "Should update preferences.stats in the database.", - ); - - t.hasStrict( - res.body.body.preferences.stats, - [ - { - mode: "chart", - chartID: Testing511SPA.chartID, - metric: "lamp", - }, - ], - "Should return the updated preferences", - ); - - t.end(); - }); - - t.test("Should reject for chartID that doesn't exist.", async (t) => { - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/showcase") - .set("Authorization", `Bearer alt_token`) - .send([ - { - mode: "chart", - chartID: "chart_id_does_not_exist", - metric: "lamp", - }, - ]); - - t.equal(res.statusCode, 400); - - await MONGODB_KILL.charts.iidx.insert({ - chartID: "testing_dp_chart", - playtype: "DP", - } as ChartDocument<"iidx:DP">); - - const res2 = await mockApi - .put("/api/v1/users/1/games/iidx/SP/showcase") - .set("Authorization", `Bearer alt_token`) - .send([ - { - mode: "chart", - chartID: "testing_dp_chart", - metric: "lamp", - }, - ]); - - t.equal(res2.statusCode, 400); - - t.end(); - }); - - t.test("Should reject for folderID that doesn't exist.", async (t) => { - const res = await mockApi - .put("/api/v1/users/1/games/iidx/SP/showcase") - .set("Authorization", `Bearer alt_token`) - .send([ - { - mode: "folder", - metric: "grade", - gte: 4, - folderID: "folder_does_not_exist", - }, - ]); - - t.equal(res.statusCode, 400); - - const res2 = await mockApi - .put("/api/v1/users/1/games/iidx/SP/showcase") - .set("Authorization", `Bearer alt_token`) - .send([ - { - mode: "folder", - metric: "grade", - gte: 4, - folderID: [TestingIIDXFolderSP10.folderID, "folder_does_not_exist"], - }, - ]); - - t.equal(res2.statusCode, 400); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts index 99e0f78b4..a815fe60d 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts @@ -40,7 +40,7 @@ async function seedIIDXUserProfile(userId: number) { .execute(); } -describe("GET /api/v1/users/:userID/games/:game/:playtype/showcase", () => { +describe("GET /api/v1/users/:userID/games/:game/showcase", () => { let userId: number; beforeEach(async () => { @@ -52,7 +52,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/showcase", () => { }); it("returns 200 with an empty evaluated list when showcase has no stats", async () => { - const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx/SP/showcase`); + const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/showcase`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -60,7 +60,7 @@ describe("GET /api/v1/users/:userID/games/:game/:playtype/showcase", () => { }); }); -describe("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", () => { +describe("PUT /api/v1/users/:userID/games/:game/showcase", () => { let userId: number; let cookie: string[]; @@ -107,12 +107,12 @@ describe("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", () => { }) .execute(); - const stats = [{ mode: "chart" as const, metric: "playcount", chartID: chartId }]; + const stats = [{ mode: "chart" as const, chartID: chartId }]; const res = await mockApi - .put(`/api/v1/users/${userId}/games/iidx/SP/showcase`) + .put(`/api/v1/users/${userId}/games/iidx-sp/showcase`) .set("Cookie", cookie) - .send(stats); + .send({ showcase: stats }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts index 91329cbe1..28de31fa3 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts @@ -1,368 +1,170 @@ import { ACTION_UpdateUgptShowcase as ACTION_UpdateUGPTShowcase } from "#actions/update-ugpt-showcase"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { GetChartById } from "#lib/db-formats/chart"; -import { LoadFolderDocumentById, LoadFolderDocumentsByIds } from "#lib/db-formats/folders"; -import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings.js"; +import { LoadFolderDocumentByGameAndSlug } from "#lib/db-formats/folders"; +import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { EvaluateShowcaseStat } from "#lib/showcase/evaluator"; import { GetRelatedStatDocuments } from "#lib/showcase/get-related"; import { EvaluateUsersStatsShowcase } from "#lib/showcase/get-stats"; -import { RequirePermissions } from "#server/middleware/auth"; -import { RequireAuthedAsUser } from "#server/router/api/v1/users/_userID/middleware"; -import { IsRecord } from "#utils/misc"; -import { FormatPrError } from "#utils/prudence"; -import { GetUGPT } from "#utils/req-tachi-data"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { GetUserWithIDGuaranteed, ResolveUser } from "#utils/user"; -import { Router } from "express"; -import { p } from "prudence"; +import { ExpectedErr } from "bliss"; import { - FormatGameGroup, - GamePTToV3, - GetGamePTConfig, - GetGPTString, + FormatGame, + GetGameConfig, GetScoreMetrics, + type integer, type ShowcaseStatDetails, } from "tachi-common"; -const router: Router = Router({ mergeParams: true }); - /** * Evaluate this users set stats. * - * @param projectUser - Project another user's stats instead of their set stats. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/showcase + * @name GET /api/v1/users/:userID/games/:game/showcase */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/showcase", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser: user, game } = ctx; - let projectUser; + let projectUser: integer | undefined; - if (typeof req.query.projectUser === "string") { - const user = await ResolveUser(req.query.projectUser); + if (typeof input.projectUser === "string") { + const resolved = await ResolveUser(input.projectUser); - if (!user) { - return res.status(404).json({ - success: false, - description: `The projected user ${req.query.projectUser} does not exist.`, - }); + if (!resolved) { + throw new ExpectedErr( + 404, + `The projected user ${input.projectUser} does not exist.`, + ); + } + + projectUser = resolved.id; } - projectUser = user.id; - } + const results = await EvaluateUsersStatsShowcase(user.id, game, projectUser); - const results = await EvaluateUsersStatsShowcase(user.id, game, playtype, projectUser); - - return res.status(200).json({ - success: true, - description: `Evaluated ${results.length} stats.`, - body: results, - }); -}); + return success(`Evaluated ${results.length} stats.`, results); + }, +); /** - * Evalulate a custom stat on this user. + * Evaluate a single custom folder or chart stat. * - * @param mode - "folder" or "chart" - * @param metric - "any score metric for this game (i.e. non-optional). - * Also, "playcount" if mode is chart. - * @param chartID - If mode is "chart" this must contain the chartID the stat is referencing. - * @param folderID - If mode is "folder" this must contain the folderID the stat is referencing. - * @param gte - If mode is "folder" this must contain the value the metric must be greater than. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom + * @name GET /api/v1/users/:userID/games/:game/showcase/custom */ -router.get("/custom", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/showcase/custom", + withUserGameProfile, + async ({ ctx, input }) => { + const { requestedUser, game } = ctx; + const gameConfig = GetGameConfig(game); + const availableMetrics = GetScoreMetrics(gameConfig, ["DECIMAL", "ENUM", "INTEGER"]); - const gptConfig = GetGamePTConfig(game, playtype); - const v3Game = GamePTToV3(game, playtype); + let stat: ShowcaseStatDetails; - let stat: ShowcaseStatDetails; + if (input.mode === "folder") { + if (!input.folderSlug) { + throw new ExpectedErr(400, "folderSlug is required for folder mode."); + } - const availableMetrics = GetScoreMetrics(gptConfig, ["DECIMAL", "ENUM", "INTEGER"]); + if (input.gte === undefined) { + throw new ExpectedErr(400, "gte is required for folder mode."); + } - if (req.query.mode === "folder") { - const err = p( - req.query, - { - mode: p.is("folder"), - metric: p.isIn(availableMetrics), - folderID: "string", + if (input.metric === undefined || input.metric === "") { + throw new ExpectedErr(400, "metric is required for folder mode."); + } - // lazy regex for matching strings that look like numbers - gte: p.regex(/^[0-9]*(.[0-9])?$/u), - }, - {}, - { allowExcessKeys: true }, - ); + if (!availableMetrics.includes(input.metric)) { + throw new ExpectedErr( + 400, + `Invalid metric ${input.metric}. Expected any of ${availableMetrics.join(", ")}.`, + ); + } - if (err) { - return res.status(400).json({ - success: false, - description: FormatPrError(err, "Invalid folder stat"), - }); + const folder = await LoadFolderDocumentByGameAndSlug(game, input.folderSlug); + + if (!folder || folder.game !== game) { + throw new ExpectedErr( + 400, + `Invalid folderSlug - all folders must be for ${FormatGame(game)}, and exist.`, + ); + } + + stat = { + slug: folder.slug, + gte: input.gte, + metric: input.metric, + mode: "folder", + }; + } else { + if (!input.chartID) { + throw new ExpectedErr(400, "chartID is required for chart mode."); + } + + const chart = await GetChartById(input.chartID); + + if (!chart || chart.game !== game) { + throw new ExpectedErr(400, "Chart does not exist, or is not for this game."); + } + + stat = { + chartID: input.chartID, + mode: "chart", + }; } - const folderID = req.query.folderID as string; + const result = await EvaluateShowcaseStat(game, stat, requestedUser.id); + const related = await GetRelatedStatDocuments(stat, game); - const folder = await LoadFolderDocumentById(folderID); - - if (!folder || folder.game !== game || folder.playtype !== playtype) { - return res.status(400).json({ - success: false, - description: `Invalid folderID - all folders must be for ${FormatGameGroup( - game, - playtype, - )}, and exist.`, - }); - } - - stat = { - mode: "folder", - metric: req.query.metric as string, - folderID, - gte: Number(req.query.gte), - }; - } else if (req.query.mode === "chart") { - const err = p( - req.query, - { - mode: p.is("chart"), - metric: p.isIn(...availableMetrics, "playcount"), - chartID: "string", - }, - {}, - { allowExcessKeys: true }, - ); - - if (err) { - return res.status(400).json({ - success: false, - description: FormatPrError(err, "Invalid chart stat"), - }); - } - - const chart = await GetChartById(v3Game, req.query.chartID as string); - - if (!chart || chart.playtype !== playtype) { - return res.status(400).json({ - success: false, - description: `Chart does not exist, or is not for this game and playtype.`, - }); - } - - stat = { - mode: "chart", - metric: req.query.metric as string, - chartID: req.query.chartID as string, - }; - } else { - return res.status(400).json({ - success: false, - description: `Invalid stat mode - expected either 'chart' or 'folder'.`, - }); - } - - const gpt = GetGPTString(game, playtype); - - const result = await EvaluateShowcaseStat(gpt, stat, user.id); - - const related = await GetRelatedStatDocuments(stat, game); - - return res.status(200).json({ - success: true, - description: `Evaluated Stat for ${user.username}`, - body: { stat, result, related }, - }); -}); + return success(`Evaluated Stat for ${requestedUser.username}`, { related, result, stat }); + }, +); /** * Replaces a user's stat showcase. * - * @name PUT /api/v1/users/:userID/games/:game/:playtype/showcase + * @name PUT /api/v1/users/:userID/games/:game/showcase */ -router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "PUT /users/:userID/games/:game/showcase", + withUserGameProfile, + async ({ ctx, input, req }) => { + const { requestedUser: user, game } = ctx; - const gptConfig = GetGamePTConfig(game, playtype); - const v3Game = GamePTToV3(game, playtype); + const showcase = input.showcase; - if (!Array.isArray(req.safeBody)) { - return res.status(400).json({ - success: false, - description: `No stats provided, or was not an array.`, - }); - } - - if (req.safeBody.length > 6) { - return res.status(400).json({ - success: false, - description: `You are only allowed 6 stats at once.`, - }); - } - - const stats = req.safeBody as Array; - - const availableMetrics = GetScoreMetrics(gptConfig, ["DECIMAL", "ENUM", "INTEGER"]); - - for (const unvalidatedStat of stats) { - let err; - - if (!IsRecord(unvalidatedStat)) { - return res.status(400).json({ - success: false, - description: `Invalid stat -- got null or a non-object.`, - }); + if (!Array.isArray(showcase)) { + throw new ExpectedErr(400, "No stats provided, or was not an array."); } - if (unvalidatedStat.mode === "chart") { - err = p(unvalidatedStat, { - chartID: "string", - mode: p.is("chart"), - metric: p.isIn(...availableMetrics, "playcount"), - }); - } else if (unvalidatedStat.mode === "folder") { - err = p(unvalidatedStat, { - folderID: (self) => { - if (typeof self === "string") { - return true; - } else if (Array.isArray(self)) { - return self.length <= 6 && self.every((r) => typeof r === "string"); - } + const stats = showcase as Array; - return false; - }, - mode: p.is("folder"), - metric: p.isIn(availableMetrics), - - gte: (self, parent) => { - if (typeof self !== "number") { - return "Expected a number."; - } - - if (typeof parent.metric !== "string") { - return `Expected parent.metric to be a string.`; - } - - const conf = - gptConfig.providedMetrics[parent.metric] ?? - gptConfig.derivedMetrics[parent.metric]; - - if (!conf) { - return `Invalid metric ${ - parent.metric - }, Expected any of ${availableMetrics.join(", ")}.`; - } - - if (conf.type === "ENUM") { - return p.isBoundedInteger(0, conf.values.length - 1)(self); - } - - if (conf.type === "GRAPH" || conf.type === "NULLABLE_GRAPH") { - return "Cannot set a showcase stat for this metric."; - } - - if (conf.chartDependentMax) { - return `Cannot set a folder showcase goal for this metric as it is chart dependent.`; - } - - return conf.validate(self); - }, - }); - } else { - return res.status(400).json({ - success: false, - description: `Invalid stat - Expected mode to be 'chart' or 'folder').`, - }); + if (stats.length > 6) { + throw new ExpectedErr(400, "You are only allowed 6 stats at once."); } - if (err) { - return res.status(400).json({ - success: false, - description: FormatPrError(err, "Invalid stat."), - }); + const authUserID = req[SYMBOL_TACHI_API_AUTH].userID; + + if (authUserID === null) { + throw new ExpectedErr(401, "Authentication is required."); } - const stat = unvalidatedStat as unknown as ShowcaseStatDetails; + const authedUser = await GetUserWithIDGuaranteed(authUserID); + const taker = { acct: { id: authedUser.id, username: authedUser.username }, ip: req.ip }; - if (stat.mode === "chart") { - // eslint-disable-next-line no-await-in-loop - const chart = await GetChartById(v3Game, stat.chartID); - - if (!chart || chart.playtype !== playtype) { - return res.status(400).json({ - success: false, - description: `Invalid chartID - must be a chart for this game and playtype.`, - }); - } - } else if (unvalidatedStat.mode === "folder") { - const folderIDs = Array.isArray(unvalidatedStat.folderID) - ? unvalidatedStat.folderID - : [unvalidatedStat.folderID]; - - // eslint-disable-next-line no-await-in-loop - const folderMap = await LoadFolderDocumentsByIds(folderIDs); - - if (folderMap.size !== folderIDs.length) { - return res.status(400).json({ - success: false, - - // this error message is kinda lazy. - description: `Invalid folderID - must be a folder for this game and playtype.`, - }); - } - - if ( - !folderIDs.every((id) => { - const f = folderMap.get(id); - return f !== undefined && f.game === game && f.playtype === playtype; - }) - ) { - return res.status(400).json({ - success: false, - - // this error message is kinda lazy. - description: `Invalid folderID - must be a folder for this game and playtype.`, - }); - } - } - } - - const authUserID = req[SYMBOL_TACHI_API_AUTH].userID; - - if (authUserID === null) { - return res.status(401).json({ - success: false, - description: "Authentication is required for this endpoint.", + await ACTION_UpdateUGPTShowcase(taker, { + game, + stats: stats as Array, + userID: user.id, }); - } - const authedUser = await GetUserWithIDGuaranteed(authUserID); - const taker = { ip: req.ip, acct: { id: authedUser.id, username: authedUser.username } }; + const settings = await GetUGPTSettingsDocument(user.id, game); - const statsPayload = req.safeBody as ShowcaseStatDetails[]; - - await ACTION_UpdateUGPTShowcase(taker, { - userID: user.id, - game, - playtype, - stats: statsPayload, - }); - - const newSettings = await GetUGPTSettingsDocument(user.id, game, playtype); - - if (!newSettings) { - return res.status(500).json({ - success: false, - description: `Failed to load settings after updating showcase.`, - }); - } - - return res.status(200).json({ - success: true, - description: `Updated stat showcase.`, - body: newSettings, - }); -}); - -export default router; + return success("Updated showcase.", settings); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.oldtest.ts deleted file mode 100644 index ce93ab806..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.oldtest.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { FolderDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { TestingIIDXFolderSP10, TestingIIDXSPScorePB } from "#test-utils/test-data"; -import { CreateFolderChartLookup } from "#utils/folder"; -import deepmerge from "deepmerge"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/tables/:tableID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a users statistics for this table", async (t) => { - const folder = deepmerge(TestingIIDXFolderSP10, { - folderID: "testing_folder", - }) as FolderDocument; - - await MONGODB_KILL.folders.insert(folder); - - await CreateFolderChartLookup(folder, true); - - await MONGODB_KILL["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/tables/mock_table"); - - t.equal(res.body.body.folders.length, 1); - t.equal(res.body.body.folders[0].folderID, "testing_folder"); - t.equal(res.body.body.table.tableID, "mock_table"); - t.equal(res.body.body.stats.length, 1); - - t.equal(res.body.body.stats[0].stats.lamp["EX HARD CLEAR"], 1); - t.equal(res.body.body.stats[0].stats.grade.AAA, 1); - - t.end(); - }); - - t.test("Should return 404 if the table does not exist.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/tables/bad_table"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts new file mode 100644 index 000000000..00794c93f --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts @@ -0,0 +1,127 @@ +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { TestingIIDXFolderSP10, TestingIIDXSPScorePB } from "#test-utils/test-data"; +import { type ScoreData } from "tachi-common"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +describe("GET /api/v1/users/:userID/games/:game/tables/:tableID", () => { + beforeEach(async () => { + await seedUser({ username: "tbl_user" }); + + const songId = "tsong1"; + const chartId = TestingIIDXSPScorePB.chartID; + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: 1, + game_group: "iidx", + title: "S", + artist: "A", + search_terms: [], + alt_titles: [], + data: {}, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: songId, + difficulty: "ANOTHER", + level: "10", + level_num: 10, + is_primary: true, + versions: [], + data: {}, + }) + .execute(); + + const folderId = "ugtfolder"; + await DB.insertInto("folder") + .values({ + id: folderId, + legacy_id: "testing_folder", + game: "iidx-sp", + inactive: false, + title: TestingIIDXFolderSP10.title, + slug: folderId, + where: `chart.id = '${chartId}'`, + version_filter: null, + search_terms: [], + }) + .execute(); + + const tableUuid = `tblu_${Date.now()}`; + await DB.insertInto("table") + .values({ + id: tableUuid, + legacy_id: "mock_table", + game: "iidx-sp", + inactive: false, + title: "T", + default_value: false, + slug: "mock_table", + }) + .execute(); + + await DB.insertInto("table_folder") + .values({ table_id: tableUuid, folder_id: folderId, ordering: 0 }) + .execute(); + + await DB.insertInto("game_profile") + .values({ + user_id: 1, + game: "iidx-sp", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + }) + .execute(); + + const sd = TestingIIDXSPScorePB.scoreData as ScoreData<"iidx-sp">; + const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", sd); + const now = new Date().toISOString(); + + await DB.insertInto("pb") + .values({ + user_id: 1, + chart_id: chartId, + lens: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + calculated_data: JSON.stringify(TestingIIDXSPScorePB.calculatedData), + judgements: JSON.stringify(judgements), + ranking_value: 100, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: false, + time_achieved: now, + }) + .execute(); + }); + + it("returns stats for a table the user has played", async () => { + const res = await mockApi.get("/api/v1/users/1/games/iidx-sp/tables/mock_table"); + + expect(res.status).toBe(200); + expect(res.body.body.table.tableID).toBe("mock_table"); + expect(res.body.body.folders.length).toBe(1); + expect(res.body.body.stats.length).toBeGreaterThanOrEqual(1); + }); + + it("returns 404 when the table does not exist", async () => { + const res = await mockApi.get("/api/v1/users/1/games/iidx-sp/tables/bad_table"); + + expect(res.status).toBe(404); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts index c1b605c5d..267d4d20d 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts @@ -1,36 +1,30 @@ -import { GetEnumDistForFolders, GetFoldersFromTable } from "#utils/folder"; -import { GetTachiData, GetUGPT } from "#utils/req-tachi-data"; -import { Router } from "express"; - -import { GetTableFromParam } from "../../../../../../games/_game/_playtype/tables/middleware"; - -const router: Router = Router({ mergeParams: true }); +import { LoadTableDocumentByLegacyIdForGame } from "#lib/db-formats/table"; +import { GetEnumDistForFolders, GetFoldersFromTable } from "#lib/folders/folders"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import { ExpectedErr } from "bliss"; /** * Retrieves a users statistics on this table. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/tables/:tableID + * @name GET /api/v1/users/:userID/games/:game/tables/:tableID */ -router.get("/:tableID", GetTableFromParam, async (req, res) => { - const { user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/tables/:tableID", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; - const table = GetTachiData(req, "tableDoc"); + const table = await LoadTableDocumentByLegacyIdForGame(params.tableID, game); - const folders = await GetFoldersFromTable(table); + if (!table) { + throw new ExpectedErr(404, `No table with ID ${params.tableID} exists.`); + } - // @optimisable - Requests a lot of charts we don't care about - // could be cached too, i guess. - const stats = await GetEnumDistForFolders(user.id, folders); + const folders = await GetFoldersFromTable(table); + const stats = await GetEnumDistForFolders(user.id, folders); - return res.status(200).json({ - success: true, - description: `Returned stats for ${folders.length} folders.`, - body: { - folders, - stats, - table, - }, - }); -}); - -export default router; + return success(`Returned stats for ${folders.length} folders.`, { folders, stats, table }); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.oldtest.ts deleted file mode 100644 index 4fd6562bb..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.oldtest.ts +++ /dev/null @@ -1,708 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { TestSnapshot } from "#test-utils/single-process-snapshot"; -import { - HC511Goal, - HC511UserGoal, - IIDXSPQuestGoals, - IIDXSPQuestGoalSubs, - Testing511SPA, - TestingIIDXFolderSP10, - TestingIIDXSPQuest, - TestingIIDXSPQuestSub, - TestingIIDXSPScore, - TestingIIDXSPScorePB, -} from "#test-utils/test-data"; -import { CreateFolderChartLookup } from "#utils/folder"; -import { - type ChartDocument, - type GoalSubscriptionDocument, - IIDX_GRADES, - IIDX_LAMPS, - type PBScoreDocument, -} from "tachi-common"; -/* eslint-disable no-await-in-loop */ -import dm from "deepmerge"; -import t from "tap"; - -// @ts-expect-error Not sure why the types break here, but they do. -const dupedGoal = dm({}, HC511Goal); - -// @ts-expect-error Not sure why the types break here, but they do. -const dupedGoalSub = dm({}, HC511UserGoal); - -// having two charts available is useful here for testing multi-chart goals. -const anotherFakeChart = dm(Testing511SPA, { - chartID: "another_chart", - difficulty: "HYPER", - data: {}, -}) as ChartDocument<"iidx:SP">; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should retrieve this user's goal subscriptions.", async (t) => { - await MONGODB_KILL.goals.insert(dupedGoal); - await MONGODB_KILL["goal-subs"].insert(dupedGoalSub); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/goals"); - - t.equal(res.statusCode, 200); - - t.strictSame(res.body.body, { - goals: [HC511Goal], - goalSubs: [HC511UserGoal], - quests: [], - questSubs: [], - }); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", async (t) => { - t.beforeEach(ResetDBState); - - const customScorePB = dm(TestingIIDXSPScorePB, { - scoreData: TestingIIDXSPScore.scoreData, - }) as PBScoreDocument; - - t.beforeEach(async () => { - await MONGODB_KILL["personal-bests"].remove({}); - await MONGODB_KILL["personal-bests"].insert(customScorePB); - }); - - const cookie = await CreateFakeAuthCookie(mockApi); - - const baseInput = { - criteria: { - key: "percent", - value: 80, - mode: "single", - }, - charts: { - type: "single", - data: Testing511SPA.chartID, - }, - }; - - function mkInput(caseName: string, merge: any) { - const input = { - caseName, - content: dm(baseInput, merge) as any, - }; - - // if we've set this prop to undefined, we want to delete it, actually. - if (input.content.charts.data === undefined) { - delete input.content.charts.data; - } - - return input; - } - - t.beforeEach(() => MONGODB_KILL.charts.iidx.insert(anotherFakeChart)); - - // @ts-expect-error weird type issues - t.beforeEach(() => MONGODB_KILL.folders.insert(dm(TestingIIDXFolderSP10, {}))); - - t.beforeEach(() => CreateFolderChartLookup(TestingIIDXFolderSP10, true)); - - const multiCharts = { - type: "multi", - data: [Testing511SPA.chartID, "another_chart"], - }; - - const folderCharts = { - type: "folder", - data: TestingIIDXFolderSP10.folderID, - }; - - const absModeCriteria = { - mode: "absolute", - countNum: 2, - }; - - const proportionModeCriteria = { - mode: "proportion", - countNum: 1, - }; - - t.test("Should set valid goals.", async (t) => { - const validInput = [ - mkInput("Percent:Single Case", {}), - mkInput("Score:Single Case", { - criteria: { - key: "score", - value: 1000, - }, - }), - mkInput("LampIndex:Single Case", { - criteria: { - key: "lamp", - value: IIDX_LAMPS.EX_HARD_CLEAR, - }, - }), - mkInput("GradeIndex:Single Case", { - criteria: { - key: "grade", - value: IIDX_GRADES.AAA, - }, - }), - mkInput("Percent:Multi Case", { - charts: multiCharts, - }), - mkInput("Percent:Multi:Abs Case", { - criteria: absModeCriteria, - charts: multiCharts, - }), - - // NOT LEGAL FOR IIDX, due to being nonsense! NEEDS SPECIFIC TESTING. - // mkInput("Score:Multi Case", { - // criteria: { - // key: "score", - // value: 1, - // }, - // charts: multiCharts, - // }), - mkInput("LampIndex:Multi Case", { - criteria: { - key: "lamp", - value: IIDX_LAMPS.HARD_CLEAR, - }, - charts: multiCharts, - }), - mkInput("LampIndex:Multi:Abs Case", { - criteria: { - key: "lamp", - value: IIDX_LAMPS.HARD_CLEAR, - ...absModeCriteria, - }, - charts: multiCharts, - }), - - mkInput("GradeIndex:Multi Case", { - criteria: { - key: "grade", - value: IIDX_GRADES.AAA, - }, - charts: multiCharts, - }), - mkInput("GradeIndex:Multi:Abs Case", { - criteria: { - key: "grade", - value: IIDX_GRADES.AAA, - ...absModeCriteria, - }, - charts: multiCharts, - }), - mkInput("Percent:Folder Case", { - charts: folderCharts, - }), - mkInput("Percent:Folder:Abs Case", { - criteria: absModeCriteria, - charts: folderCharts, - }), - mkInput("Percent:Folder:Proportion Case", { - criteria: proportionModeCriteria, - charts: folderCharts, - }), - - // also illegal in iidx, bms and pms. - // mkInput("Score:Folder Case", { - // criteria: { - // key: "score", - // value: 1000, - // }, - // charts: folderCharts, - // }), - mkInput("LampIndex:Folder Case", { - criteria: { - key: "lamp", - value: IIDX_LAMPS.HARD_CLEAR, - }, - charts: folderCharts, - }), - mkInput("LampIndex:Folder:Abs Case", { - criteria: { - key: "lamp", - value: IIDX_LAMPS.HARD_CLEAR, - ...absModeCriteria, - }, - charts: folderCharts, - }), - mkInput("LampIndex:Folder:Proportion Case", { - criteria: { - key: "lamp", - value: IIDX_LAMPS.HARD_CLEAR, - ...proportionModeCriteria, - }, - charts: folderCharts, - }), - mkInput("GradeIndex:Folder Case", { - criteria: { - key: "grade", - value: IIDX_GRADES.AAA, - }, - charts: folderCharts, - }), - mkInput("GradeIndex:Folder:Abs Case", { - criteria: { - key: "grade", - value: IIDX_GRADES.AAA, - ...absModeCriteria, - }, - charts: folderCharts, - }), - mkInput("GradeIndex:Folder:Proportion Case", { - criteria: { - key: "grade", - value: IIDX_GRADES.AAA, - ...proportionModeCriteria, - }, - charts: folderCharts, - }), - ]; - - for (const input of validInput) { - await t.test(`Valid Goal: ${input.caseName}`, async (t) => { - const res = await mockApi - .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") - .set("Cookie", cookie) - .send(input.content); - - t.equal( - // this looks stupid, but results in better errmsgs. - res.statusCode === 200 ? 200 : res.body.description, - 200, - "Should return 200", - ); - - // don't bother doing db checks if this is good. - if (res.statusCode !== 200) { - return; - } - - const existsInDb = await MONGODB_KILL.goals.findOne({ - charts: input.content.charts, - criteria: input.content.criteria, - }); - - t.not(existsInDb, null, "A new goal should be added to the database."); - - const subscribed = await MONGODB_KILL["goal-subs"].findOne({ - userID: 1, - goalID: existsInDb?.goalID ?? "edge_case", - }); - - t.not( - subscribed, - null, - `Requesting user 1 should be subscribed to their new goal.`, - ); - - t.end(); - }); - } - - t.end(); - }); - - t.test("Should reject invalid goals.", async (t) => { - const invalidInput = [ - mkInput("negative percent", { - criteria: { - value: -1, - }, - }), - // as stupid as it is, it technically "is" a goal, as it's a valid - // value for percent. - // mkInput("percent of 0 is a non-goal", { - // criteria: { - // value: 0, - // }, - // }), - mkInput("percent greater than 100", { - criteria: { - value: 100.1, - }, - }), - mkInput("too big grade", { - criteria: { - key: "grade", - value: IIDX_GRADES.MAX + 1, - }, - }), - mkInput("string enum", { - criteria: { - key: "grade", - value: IIDX_GRADES.MAX.toString(), - }, - }), - mkInput("invalid grade", { - criteria: { - key: "grade", - value: 0.5, - }, - }), - mkInput("too big lamp", { - criteria: { - key: "lamp", - value: IIDX_LAMPS.FULL_COMBO + 1, - }, - }), - mkInput("invalid lamp", { - criteria: { - key: "lamp", - value: 0.5, - }, - }), - mkInput("too big score", { - criteria: { - key: "score", - value: 9_000, - }, - }), - mkInput("abs without countNum", { - criteria: { - mode: "absolute", - }, - charts: multiCharts, - }), - mkInput("proportion without countNum", { - criteria: { - mode: "proportion", - }, - charts: multiCharts, - }), - mkInput("single with countNum", { - criteria: { - mode: "single", - countNum: 123, - }, - }), - mkInput("proportion with negative countNum", { - criteria: { - mode: "proportion", - countNum: -0.5, - }, - charts: multiCharts, - }), - mkInput("proportion with greater than 1 countNum", { - criteria: { - mode: "proportion", - countNum: 1.1, - }, - charts: multiCharts, - }), - mkInput("proportion which results in countNum of 0", { - criteria: { - mode: "proportion", - countNum: 0.05, - }, - charts: multiCharts, - }), - mkInput("nonsense mode", { - criteria: { - mode: "nonsense", - }, - }), - mkInput("abs with countNum but charts.type == single", { - criteria: { - mode: "absolute", - countNum: 1, - }, - charts: { - type: "single", - }, - }), - mkInput("nonsense charts.type", { - charts: { - type: "nonsense", - }, - }), - mkInput("charts.data array when type == single", { - charts: { - type: "single", - data: multiCharts.data, - }, - }), - mkInput("charts.data array of identical chartIDs", { - charts: { - type: "multi", - data: [Testing511SPA.chartID, Testing511SPA.chartID], - }, - }), - mkInput("charts.data array of single chartID", { - charts: { - type: "multi", - data: [Testing511SPA.chartID], - }, - }), - mkInput("charts.data array of chartIDs where some don't exist", { - charts: { - type: "multi", - data: [Testing511SPA.chartID, "not_exist"], - }, - }), - mkInput("charts.data folder refers to folder that doesn't exist", { - charts: { - type: "folder", - data: "fake-folder", - }, - }), - mkInput("nonsense charts.data", { - charts: { - data: "nonsense", - }, - }), - mkInput("multi-score for iidx is illegal", { - criteria: { - key: "score", - value: 1000, - }, - charts: multiCharts, - }), - mkInput("folder-score for iidx is illegal", { - criteria: { - key: "score", - value: 1000, - }, - charts: folderCharts, - }), - mkInput("charts single but criteria not.", { - criteria: { - mode: "absolute", - countNum: 2, - }, - }), - mkInput("LampIndex:Multi:Proportion Case", { - criteria: { - key: "lamp", - value: IIDX_LAMPS.HARD_CLEAR, - ...proportionModeCriteria, - }, - charts: multiCharts, - }), - mkInput("GradeIndex:Multi:Proportion Case", { - criteria: { - key: "grade", - value: IIDX_GRADES.AAA, - ...proportionModeCriteria, - }, - charts: multiCharts, - }), - mkInput("Percent:Multi:Proportion Case", { - criteria: proportionModeCriteria, - charts: multiCharts, - }), - ]; - - for (const input of invalidInput) { - await t.test(`Invalid Goal: ${input.caseName}`, async (t) => { - const res = await mockApi - .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") - .set("Cookie", cookie) - .send(input.content); - - t.equal(res.statusCode, 400); - - TestSnapshot(t, res.body.description, `Invalid Goal: ${input.caseName}`); - - t.end(); - }); - } - - t.end(); - }); - - t.test("Should reject if user tries to subscribe to already subscribed goal.", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") - .set("Cookie", cookie) - .send(baseInput); - - t.equal(res.statusCode, 200, "Should allow a subscription the first time."); - - const res2 = await mockApi - .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") - .set("Cookie", cookie) - .send(baseInput); - - t.equal(res2.statusCode, 409, "Should fail the second time."); - - t.end(); - }); - - t.test("Should reject if user tries to subscribe to immediately achieved goal.", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") - .set("Cookie", cookie) - .send( - dm(baseInput, { - criteria: { - key: "score", - value: 1, - }, - }), - ); - - t.equal( - res.statusCode, - 400, - "Should disallow goal subscriptions that would be instantly achieved.", - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should retrieve this user's goal subscription.", async (t) => { - await MONGODB_KILL.goals.insert(dupedGoal); - await MONGODB_KILL["goal-subs"].insert(dupedGoalSub); - - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/targets/goals/${HC511Goal.goalID}`, - ); - - t.equal(res.statusCode, 200); - - t.hasStrict(res.body.body, { - goal: HC511Goal, - goalSub: HC511UserGoal, - quests: [], - user: { - id: 1, - }, - }); - - t.end(); - }); - - t.test("Should return parent quests if goal has any.", async (t) => { - await MONGODB_KILL.goals.insert(IIDXSPQuestGoals); - await MONGODB_KILL["goal-subs"].insert(IIDXSPQuestGoalSubs); - await MONGODB_KILL.quests.insert(TestingIIDXSPQuest); - - if (!IIDXSPQuestGoalSubs[0] || !IIDXSPQuestGoals[0]) { - throw new Error(`Expected atleast one quest goal or sub to work with?`); - } - - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/targets/goals/${IIDXSPQuestGoalSubs[0].goalID}`, - ); - - t.equal(res.statusCode, 200); - - t.hasStrict(res.body.body, { - goal: IIDXSPQuestGoals[0], - goalSub: IIDXSPQuestGoalSubs[0], - quests: [TestingIIDXSPQuest], - user: { - id: 1, - }, - }); - - t.end(); - }); - - t.test("Should return 404 if the user is not subscribed to this goal ID.", async (t) => { - const res = await mockApi.get(`/api/v1/users/1/games/iidx/SP/targets/goals/INVALID_GOAL`); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("DELETE /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should delete a goal subscription if subscribed.", async (t) => { - await MONGODB_KILL.goals.insert(dupedGoal); - await MONGODB_KILL["goal-subs"].insert(dupedGoalSub); - - const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/${dupedGoalSub.goalID}`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - const dbRes = await MONGODB_KILL["goal-subs"].findOne({ - userID: 1, - goalID: dupedGoal.goalID, - }); - - t.equal(dbRes, null, "Should delete the goal sub from the database."); - - t.end(); - }); - - t.test("Should reject a goal deletion if goal has parent quests.", async (t) => { - await MONGODB_KILL.quests.insert(TestingIIDXSPQuest); - await MONGODB_KILL["quest-subs"].insert(TestingIIDXSPQuestSub); - await MONGODB_KILL.goals.insert(IIDXSPQuestGoals); - await MONGODB_KILL["goal-subs"].insert( - dm(dupedGoalSub, { goalID: "eg_goal_1" }) as GoalSubscriptionDocument, - ); - - const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/eg_goal_1`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 400); - t.equal( - res.body.description, - `This goal is part of a quest you are subscribed to. It can only be removed by unsubscribing from the relevant quests: '${TestingIIDXSPQuest.name}'.`, - ); - - const dbRes = await MONGODB_KILL["goal-subs"].findOne({ - userID: 1, - goalID: "eg_goal_1", - }); - - t.not(dbRes, null, "Should NOT delete the goal sub from the database."); - - t.end(); - }); - - t.test("Should reject a goal deletion if not subscribed.", async (t) => { - await MONGODB_KILL.goals.insert(dupedGoal); - await MONGODB_KILL["goal-subs"].insert(dupedGoalSub); - - const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/INVALID_GOAL`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Should reject a goal deletion if not authed.", async (t) => { - await MONGODB_KILL.goals.insert(dupedGoal); - await MONGODB_KILL["goal-subs"].insert(dupedGoalSub); - - const res = await mockApi.delete( - `/api/v1/users/1/games/iidx/SP/targets/goals/${dupedGoalSub.goalID}`, - ); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts new file mode 100644 index 000000000..1187bdd33 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts @@ -0,0 +1,7 @@ +import { describe, it } from "vitest"; + +describe("UGPT targets goals", () => { + it.todo( + "port large router.oldtest.ts (GET goals, POST add-goal, …) with Postgres goal/goal_sub", + ); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index 771a0a7e5..6a8a4faf6 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -1,285 +1,176 @@ -import { ACTION_AddGoal } from "#actions/add-goal"; -import { ACTION_RemoveGoalSubscription } from "#actions/remove-goal-subscription"; import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { + AttachFolderSlugsToGoals, ToGoalDocument, ToGoalSubscriptionDocument, ToQuestSubscriptionDocument, } from "#lib/db-formats/target-documents"; -import { GetQuestsThatContainGoal } from "#lib/targets/goals"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { GetParentQuests } from "#lib/targets/quests"; -import { RequirePermissions } from "#server/middleware/auth"; -import prValidate from "#server/middleware/prudence-validate"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db"; -import { AssignToReqTachiData, GetTachiData, GetUGPT } from "#utils/req-tachi-data"; -import { type RequestHandler, Router } from "express"; -import { p } from "prudence"; -import { GamePTToV3, type MONGO_GoalDocument, type MONGO_QuestDocument } from "tachi-common"; - -import { RequireAuthedAsUser } from "../../../../../middleware"; - -const router: Router = Router({ mergeParams: true }); +import { ExpectedErr } from "bliss"; +import { type GoalDocument } from "tachi-common"; /** * Retrieves this user's set goals for this GPT. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/goals + * @name GET /api/v1/users/:userID/games/:game/targets/goals */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/goals", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const v3Game = GamePTToV3(game, playtype); + const subRows = await DB.selectFrom("goal_sub") + .innerJoin("goal", "goal.id", "goal_sub.goal_id") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) + .where("goal_sub.user_id", "=", user.id) + .where("goal.game", "=", game) + .execute(); - const subRows = await DB.selectFrom("goal_sub") - .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) - .where("goal_sub.user_id", "=", user.id) - .where("goal.game", "=", v3Game) - .execute(); + const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); + const goalIds = goalSubs.map((e) => e.goalID); - const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); + const goalRows = + goalIds.length === 0 + ? [] + : await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIds) + .execute(); - const goalIds = goalSubs.map((e) => e.goalID); + const goals = goalRows.map(ToGoalDocument); + await AttachFolderSlugsToGoals(goals); + const allQuests = await GetParentQuests(user.id, game, goalSubs); - const goalRows = - goalIds.length === 0 - ? [] - : await DB.selectFrom("goal") - .select(SELECT_GOAL) - .where("goal.id", "in", goalIds) - .execute(); + const questSubRows = await DB.selectFrom("quest_sub") + .innerJoin("quest", "quest.id", "quest_sub.quest_id") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) + .where("quest_sub.user_id", "=", user.id) + .where("quest.game", "=", game) + .execute(); - const goals = goalRows.map(ToGoalDocument); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); + const questSubIDs = questSubs.map((e) => e.questID); + const quests = allQuests.filter((quest) => questSubIDs.includes(quest.questID)); - const allQuests = await GetParentQuests(user.id, game, playtype, goalSubs); - - const questSubRows = await DB.selectFrom("quest_sub") - .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) - .where("quest_sub.user_id", "=", user.id) - .where("quest.game", "=", v3Game) - .execute(); - - const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); - - const questSubIDs = questSubs.map((e) => e.questID); - - const quests = allQuests.filter((quest) => questSubIDs.includes(quest.questID)); - - return res.status(200).json({ - success: true, - description: `Retrieved ${goalSubs.length} goal(s).`, - body: { - goals, + return success(`Retrieved ${goalSubs.length} goal(s).`, { goalSubs, - quests, + goals, questSubs, - }, - }); -}); - -type GoalCreationBody = Pick; + quests, + }); + }, +); /** - * Add a goal to your account. If the goal document already exists, it is subscribed to. - * Otherwise, that goal document is created, and then subscribed to. + * Add a goal to your account. * - * @param criteria.key - The key for the goal to be on. This is stuff like scoreData.percent. - * @param criteria.value - The value the key must be greater than for it to count as achieved. - * @param criteria.mode - "single", "absolute" or "proportion". If abs or proportion, countNum - * must be supplied. - * @param criteria.countNum - For abs/proportion mode. Atleast N scores must achieve the - * key:value condition. - * - * @param charts.type - "single", "multi" or "folder". - * @param charts.data - an identifier for the set of charts must be - * supplied here. For single, this is a chartID. For multi, this is an array of chartIDs. - * For folder, this is a folderID. - * - * @name POST /api/v1/users/:userID/games/:game/:playtype/targets/goals/add-goal + * @name POST /api/v1/users/:userID/games/:game/targets/goals/add-goal */ -router.post( - "/add-goal", - RequireAuthedAsUser, - RequirePermissions("manage_targets"), - prValidate({ - criteria: { - // we do proper validation on this later. - key: "string", - value: p.gte(0), - - mode: p.isIn("single", "absolute", "proportion"), - countNum: (self, parent) => { - if (parent.mode === "single") { - return ( - self === undefined || - "Invalid countNum for mode 'single'. Must not have one!" - ); - } - - // proper validation later. - return p.gte(0)(self); - }, - }, - charts: { - type: p.isIn("single", "multi", "folder"), - data: (self, parent) => { - if (parent.type === "single") { - return ( - typeof self === "string" || - "Expected a string in charts.data due to charts.type being 'single'." - ); - } else if (parent.type === "multi") { - return ( - (Array.isArray(self) && - self.every((k) => typeof k === "string") && - self.length <= 10 && - self.length > 1) || - "Expected an array of 2 to 10 strings in charts.data due to charts.type being 'multi'." - ); - /* istanbul ignore next */ - } else if (parent.type === "folder") { - return ( - typeof self === "string" || - "Expected a string in charts.data due to charts.type being 'folder'." - ); - } - - // impossible to reach, so doesn't count for coverage. - /* istanbul ignore next */ - return "Unknown charts.type."; - }, - }, - }), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "POST /users/:userID/games/:game/targets/goals/add-goal", + withUserGameProfile, + async ({ ctx, input, req }) => { + const { requestedUser: user, game } = ctx; const sessionUser = req.session.tachi?.user; if (!sessionUser) { - return res.status(401).json({ - success: false, - description: "You are not authenticated.", - }); + throw new ExpectedErr(401, "You are not authenticated."); } - const data = req.safeBody as GoalCreationBody; + const taker = { acct: { id: sessionUser.id, username: sessionUser.username }, ip: req.ip }; - const taker = { ip: req.ip, acct: { id: sessionUser.id, username: sessionUser.username } }; + const { ACTION_AddGoal } = await import("#actions/add-goal"); + const { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } = await import( + "#utils/db" + ); const { goalID } = await ACTION_AddGoal(taker, { - userID: user.id, + charts: input.charts as GoalDocument["charts"], + criteria: input.criteria as GoalDocument["criteria"], game, - playtype, - charts: data.charts, - criteria: data.criteria, + userID: user.id, }); const goal = await GetGoalForIDGuaranteed(goalID); const goalSub = await GetGoalSubscriptionForIDGuaranteed(goalID, user.id); - return res.status(200).json({ - success: true, - description: `Subscribed to ${goal.name}.`, - body: { - goal, - goalSub, - }, - }); + return success(`Subscribed to ${goal.name}.`, { goal, goalSub }); }, ); -const GetGoalSubscription: RequestHandler = async (req, res, next) => { - const { user, game, playtype } = GetUGPT(req); - - const v3Game = GamePTToV3(game, playtype); - - const row = await DB.selectFrom("goal_sub") - .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) - .where("goal_sub.user_id", "=", user.id) - .where("goal_sub.goal_id", "=", req.params.goalID) - .where("goal.game", "=", v3Game) - .executeTakeFirst(); - - if (!row) { - return res.status(404).json({ - success: false, - description: `${user.username} is not subscribed to this goal.`, - }); - } - - const goalSub = ToGoalSubscriptionDocument(row); - - AssignToReqTachiData(req, { goalSubDoc: goalSub }); - - next(); -}; - /** * Reads information about the users subscription to this goal ID. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID + * @name GET /api/v1/users/:userID/games/:game/targets/goals/:goalID */ -router.get("/:goalID", GetGoalSubscription, async (req, res) => { - const { user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/goals/:goalID", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; - const goalSub = GetTachiData(req, "goalSubDoc"); + const row = await DB.selectFrom("goal_sub") + .innerJoin("goal", "goal.id", "goal_sub.goal_id") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) + .where("goal_sub.user_id", "=", user.id) + .where("goal_sub.goal_id", "=", params.goalID) + .where("goal.game", "=", game) + .executeTakeFirst(); - const quests: Array = await GetQuestsThatContainGoal(goalSub.goalID); + if (!row) { + throw new ExpectedErr(404, `${user.username} is not subscribed to this goal.`); + } - const goal = await GetGoalForIDGuaranteed(goalSub.goalID); + const goalSub = ToGoalSubscriptionDocument(row); + const { GetGoalForIDGuaranteed } = await import("#utils/db"); + const { GetQuestsThatContainGoal } = await import("#lib/targets/goals"); - return res.status(200).json({ - success: true, - description: `Returned information about goal '${goal.name}'.`, - body: { + const goal = await GetGoalForIDGuaranteed(goalSub.goalID); + const quests = await GetQuestsThatContainGoal(goalSub.goalID); + + return success(`Returned information about goal '${goal.name}'.`, { goal, goalSub, quests, user, - }, - }); -}); - -/** - * Unsubscribe from a goal. - * - * @name DELETE /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID - */ -router.delete( - "/:goalID", - RequireAuthedAsUser, - RequirePermissions("manage_targets"), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - - const sessionUser = req.session.tachi?.user; - - if (!sessionUser) { - return res.status(401).json({ - success: false, - description: "You are not authenticated.", - }); - } - - const taker = { ip: req.ip, acct: { id: sessionUser.id, username: sessionUser.username } }; - - await ACTION_RemoveGoalSubscription(taker, { - userID: user.id, - game, - playtype, - goalID: req.params.goalID, - }); - - return res.status(200).json({ - success: true, - description: `Removed this goal from your subscriptions.`, - body: {}, }); }, ); -export default router; +/** + * Unsubscribe from a goal. + * + * @name DELETE /api/v1/users/:userID/games/:game/targets/goals/:goalID + */ +API_V1_ROUTER.add( + "DELETE /users/:userID/games/:game/targets/goals/:goalID", + withUserGameProfile, + async ({ ctx, params, req }) => { + const { requestedUser: user, game } = ctx; + + const sessionUser = req.session.tachi?.user; + + if (!sessionUser) { + throw new ExpectedErr(401, "You are not authenticated."); + } + + const taker = { acct: { id: sessionUser.id, username: sessionUser.username }, ip: req.ip }; + + const { ACTION_RemoveGoalSubscription } = await import("#actions/remove-goal-subscription"); + + await ACTION_RemoveGoalSubscription(taker, { + game, + goalID: params.goalID, + userID: user.id, + }); + + return success("Removed this goal from your subscriptions.", {}); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.oldtest.ts deleted file mode 100644 index dfeee744e..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.oldtest.ts +++ /dev/null @@ -1,248 +0,0 @@ -import type { UserDocument, UserGameStats } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - IIDXSPQuestGoals, - IIDXSPQuestGoalSubs, - TestingIIDXSPQuest, - TestingIIDXSPQuestSub, -} from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/quests", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.quests.insert(TestingIIDXSPQuest)); - - t.test("Should return all quest subscriptions.", async (t) => { - await MONGODB_KILL["quest-subs"].insert(TestingIIDXSPQuestSub); - await MONGODB_KILL.goals.insert(IIDXSPQuestGoals); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/quests"); - - t.strictSame(res.body.body, { - quests: [TestingIIDXSPQuest], - questSubs: [TestingIIDXSPQuestSub], - goals: IIDXSPQuestGoals, - }); - - t.end(); - }); - - t.test("Should panic if quest subs are parentless.", async (t) => { - await MONGODB_KILL.quests.remove({ questID: TestingIIDXSPQuest.questID }); - await MONGODB_KILL["quest-subs"].insert(TestingIIDXSPQuestSub); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/quests"); - - t.equal(res.statusCode, 500); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.quests.insert(TestingIIDXSPQuest)); - - t.test("Should return the user's specific quest sub.", async (t) => { - await MONGODB_KILL["quest-subs"].insert(TestingIIDXSPQuestSub); - - await MONGODB_KILL.goals.insert(IIDXSPQuestGoals); - await MONGODB_KILL["goal-subs"].insert(IIDXSPQuestGoalSubs); - - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`, - ); - - t.strictSame(res.body.body, { - quest: TestingIIDXSPQuest, - questSub: TestingIIDXSPQuestSub, - results: IIDXSPQuestGoalSubs.map((e) => ({ - achieved: e.achieved, - progress: e.progress, - progressHuman: e.progressHuman, - outOf: e.outOf, - outOfHuman: e.outOfHuman, - goalID: e.goalID, - })), - goals: IIDXSPQuestGoals, - }); - - t.end(); - }); - - t.test("Should return 404 if the user is not subscribed to this quest.", async (t) => { - const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`, - ); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("PUT /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.quests.insert(TestingIIDXSPQuest)); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return 404 if this quest doesn't exist.", async (t) => { - const res = await mockApi - .put(`/api/v1/users/1/games/iidx/SP/targets/quests/fake_quest`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Should return 409 if user is already subscribed to this quest.", async (t) => { - await MONGODB_KILL["quest-subs"].insert(TestingIIDXSPQuestSub); - - const res = await mockApi - .put(`/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 409); - - t.end(); - }); - - t.test("Should subscribe to a quest.", async (t) => { - await MONGODB_KILL.goals.insert(IIDXSPQuestGoals); - - const res = await mockApi - .put(`/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - const dbRes = await MONGODB_KILL["quest-subs"].findOne({ - userID: 1, - questID: TestingIIDXSPQuest.questID, - }); - - t.not(dbRes, null, "Should have subscribed the user to the quest."); - - t.end(); - }); - - t.test("Should return 401 if the user is not authed.", async (t) => { - const res = await mockApi.put( - `/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`, - ); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("Should return 403 if the user is authed as someone else.", async (t) => { - // We have to make sure that the user exists, otherwise we'll just get - // a normal 404. - await MONGODB_KILL.users.insert({ - id: 2, - username: "fake_person", - usernameLowercase: "fake_person", - } as UserDocument); - - await MONGODB_KILL["game-stats"].insert({ - game: "iidx", - playtype: "SP", - userID: 2, - } as UserGameStats); - - const res = await mockApi - .put(`/api/v1/users/2/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.end(); -}); - -t.test("DELETE /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.quests.insert(TestingIIDXSPQuest)); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return 409 if the user is not subscribed to this quest.", async (t) => { - const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 409); - - t.end(); - }); - - t.test("Should return 401 if the user is not authed.", async (t) => { - const res = await mockApi.delete( - `/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`, - ); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("Should return 403 if the user is authed as someone else.", async (t) => { - // We have to make sure that the user exists, otherwise we'll just get - // a normal 404. - await MONGODB_KILL.users.insert({ - id: 2, - username: "fake_person", - usernameLowercase: "fake_person", - } as UserDocument); - - await MONGODB_KILL["game-stats"].insert({ - game: "iidx", - playtype: "SP", - userID: 2, - } as UserGameStats); - - const res = await mockApi - .delete(`/api/v1/users/2/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should unsubscribe from a quest.", async (t) => { - await MONGODB_KILL["quest-subs"].insert(TestingIIDXSPQuestSub); - - const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`) - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - t.strictSame(res.body.body.quest, TestingIIDXSPQuest); - - const dbRes = await MONGODB_KILL["quest-subs"].findOne({ - userID: 1, - questID: TestingIIDXSPQuest.questID, - }); - - t.equal(dbRes, null, "Should have removed the quest from the database."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts new file mode 100644 index 000000000..80a676845 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts @@ -0,0 +1,5 @@ +import { describe, it } from "vitest"; + +describe("UGPT targets quests", () => { + it.todo("port router.oldtest.ts (GET/PATCH quest routes) with Postgres quest/quest_sub"); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts index aac102754..7bfe2e3b2 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts @@ -1,281 +1,207 @@ -import { SubscribeFailReasons } from "#lib/constants/err-codes"; import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents"; -import { log } from "#lib/log/log"; -import { ServerConfig } from "#lib/setup/config"; -import { - EvaluateQuestProgress, - GetGoalsInQuests, - SubscribeToQuest, - UnsubscribeFromQuest, -} from "#lib/targets/quests"; -import { RequirePermissions } from "#server/middleware/auth"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { AssignToReqTachiData, GetGPT, GetTachiData, GetUGPT } from "#utils/req-tachi-data"; -import { FormatUserDoc } from "#utils/user"; -import { type RequestHandler, Router } from "express"; +import { ExpectedErr } from "bliss"; import { sql } from "kysely"; -import { GamePTToV3 } from "tachi-common"; - -import { RequireAuthedAsUser } from "../../../../../middleware"; - -const router: Router = Router({ mergeParams: true }); /** * Retrieves this user's subscribed quests. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/quests + * @name GET /api/v1/users/:userID/games/:game/targets/quests */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/quests", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; - const v3Game = GamePTToV3(game, playtype); + const { GetGoalsInQuests } = await import("#lib/targets/quests"); - const questSubRows = await DB.selectFrom("quest_sub") - .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) - .where("quest_sub.user_id", "=", user.id) - .where("quest.game", "=", v3Game) - .execute(); + const questSubRows = await DB.selectFrom("quest_sub") + .innerJoin("quest", "quest.id", "quest_sub.quest_id") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) + .where("quest_sub.user_id", "=", user.id) + .where("quest.game", "=", game) + .execute(); - const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); + const questIds = questSubs.map((e) => e.questID); - const questIds = questSubs.map((e) => e.questID); + const questRows = + questIds.length === 0 + ? [] + : await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "in", questIds) + .execute(); - const questRows = - questIds.length === 0 - ? [] - : await DB.selectFrom("quest") - .select(SELECT_QUEST) - .where("quest.id", "in", questIds) - .execute(); + const quests = questRows.map(ToQuestDocument); + const goals = await GetGoalsInQuests(quests); - if (questRows.length !== questSubs.length) { - log.error( - `Found ${questSubs.length} subscriptions, but got ${questRows.length} parents. This is a state desync.`, - ); - throw new Error("Failed to fetch quests"); - } - - const questById = new Map(questRows.map((q) => [q.id, ToQuestDocument(q)])); - - const quests = questIds.map((id) => { - const q = questById.get(id); - - if (!q) { - throw new Error("Failed to fetch quests"); - } - - return q; - }); - - const goals = await GetGoalsInQuests(quests); - - return res.status(200).json({ - success: true, - description: `Retrieved ${questSubs.length} quest(s).`, - body: { - quests, - questSubs, - goals, - }, - }); -}); - -const GetQuestSubscription: RequestHandler = async (req, res, next) => { - const { user, game, playtype } = GetUGPT(req); - - const v3Game = GamePTToV3(game, playtype); - - const row = await DB.selectFrom("quest_sub") - .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) - .where("quest_sub.user_id", "=", user.id) - .where("quest_sub.quest_id", "=", req.params.questID) - .where("quest.game", "=", v3Game) - .executeTakeFirst(); - - if (!row) { - return res.status(404).json({ - success: false, - description: `${user.username} is not subscribed to this quest.`, - }); - } - - const questSub = ToQuestSubscriptionDocument(row); - - AssignToReqTachiData(req, { questSubDoc: questSub }); - - next(); -}; - -const GetQuest: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - - const v3Game = GamePTToV3(game, playtype); - - const row = await DB.selectFrom("quest") - .select(SELECT_QUEST) - .where("quest.id", "=", req.params.questID) - .where("quest.game", "=", v3Game) - .executeTakeFirst(); - - if (!row) { - return res.status(404).json({ - success: false, - description: `Can't find a quest with id '${req.params.questID}'.`, - }); - } - - AssignToReqTachiData(req, { questDoc: ToQuestDocument(row) }); - - next(); -}; + return success(`Retrieved ${questSubs.length} quest(s).`, { goals, questSubs, quests }); + }, +); /** * Returns this user's progress on this quest. - * This also evaluates individual progress on all of the quests goals. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID + * @name GET /api/v1/users/:userID/games/:game/targets/quests/:questID */ -router.get("/:questID", GetQuest, GetQuestSubscription, async (req, res) => { - const { user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/quests/:questID", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; - const questSub = GetTachiData(req, "questSubDoc"); - const quest = GetTachiData(req, "questDoc"); + const { EvaluateQuestProgress } = await import("#lib/targets/quests"); - const { goalResults: results, goals } = await EvaluateQuestProgress(user.id, quest); + const questRow = await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "=", params.questID) + .where("quest.game", "=", game) + .executeTakeFirst(); - return res.status(200).json({ - success: true, - description: `Returned information about ${FormatUserDoc(user)}'s progress on ${ - quest.name - }.`, - body: { - questSub, - quest, - results, + if (!questRow) { + throw new ExpectedErr(404, `Can't find a quest with id '${params.questID}'.`); + } + + const quest = ToQuestDocument(questRow); + + const questSubRow = await DB.selectFrom("quest_sub") + .innerJoin("quest", "quest.id", "quest_sub.quest_id") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) + .where("quest_sub.user_id", "=", user.id) + .where("quest_sub.quest_id", "=", params.questID) + .where("quest.game", "=", game) + .executeTakeFirst(); + + if (!questSubRow) { + throw new ExpectedErr(404, `${user.username} is not subscribed to this quest.`); + } + + const questSub = ToQuestSubscriptionDocument(questSubRow); + const { goalResults: results, goals } = await EvaluateQuestProgress(user.id, quest); + + return success(`Returned information about ${user.username}'s progress on ${quest.name}.`, { goals, - }, - }); -}); + quest, + questSub, + results, + }); + }, +); /** * Subscribe to a quest. * - * @name PUT /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID + * @name PUT /api/v1/users/:userID/games/:game/targets/quests/:questID */ -router.put( - "/:questID", - RequireAuthedAsUser, - GetQuest, - RequirePermissions("manage_targets"), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "PUT /users/:userID/games/:game/targets/quests/:questID", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; - const v3Game = GamePTToV3(game, playtype); + const { SubscribeFailReasons } = await import("#lib/constants/err-codes"); + const { SubscribeToQuest } = await import("#lib/targets/quests"); + const { ServerConfig } = await import("#lib/setup/config"); const countRow = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") .select(sql`count(*)::int`.as("c")) .where("quest_sub.user_id", "=", user.id) - .where("quest.game", "=", v3Game) + .where("quest.game", "=", game) .executeTakeFirst(); const existingQuestsCount = Number(countRow?.c ?? 0); if (existingQuestsCount > ServerConfig.MAX_QUEST_SUBSCRIPTIONS) { - return res.status(400).json({ - success: false, - description: `You already have ${ServerConfig.MAX_QUEST_SUBSCRIPTIONS} quests. You cannot have anymore for this game.`, - }); + throw new ExpectedErr( + 400, + `You already have ${ServerConfig.MAX_QUEST_SUBSCRIPTIONS} quests. You cannot have anymore for this game.`, + ); } - const quest = GetTachiData(req, "questDoc"); + const questRow = await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "=", params.questID) + .where("quest.game", "=", game) + .executeTakeFirst(); + + if (!questRow) { + throw new ExpectedErr(404, `Can't find a quest with id '${params.questID}'.`); + } + + const quest = ToQuestDocument(questRow); const alreadySubscribed = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") .select("quest_sub.quest_id") .where("quest_sub.user_id", "=", user.id) .where("quest_sub.quest_id", "=", quest.questID) - .where("quest.game", "=", v3Game) + .where("quest.game", "=", game) .executeTakeFirst(); if (alreadySubscribed) { - return res.status(409).json({ - success: false, - description: `You are already subscribed to this goal.`, - }); + throw new ExpectedErr(409, "You are already subscribed to this goal."); } const subResult = await SubscribeToQuest(user.id, quest, false); if (subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED) { - return res.status(409).json({ - success: false, - description: `You're already subscribed to this quest.`, - }); + throw new ExpectedErr(409, "You're already subscribed to this quest."); } - return res.status(200).json({ - success: true, - description: `Subscribed to quest '${quest.name}'.`, - body: { ...subResult, quest }, - }); + return success(`Subscribed to quest '${quest.name}'.`, { ...subResult, quest }); }, ); /** * Unsubscribe from a quest. * - * @name DELETE /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID + * @name DELETE /api/v1/users/:userID/games/:game/targets/quests/:questID */ -router.delete( - "/:questID", - RequireAuthedAsUser, - GetQuest, - RequirePermissions("manage_targets"), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - const quest = GetTachiData(req, "questDoc"); +API_V1_ROUTER.add( + "DELETE /users/:userID/games/:game/targets/quests/:questID", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; - log.info( - { - quest, - user, - }, - `User ${FormatUserDoc(user)} is unsubscribing from quest '${quest.name}'.`, - ); + const { UnsubscribeFromQuest } = await import("#lib/targets/quests"); - const v3Game = GamePTToV3(game, playtype); + const questRow = await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "=", params.questID) + .where("quest.game", "=", game) + .executeTakeFirst(); + + if (!questRow) { + throw new ExpectedErr(404, `Can't find a quest with id '${params.questID}'.`); + } + + const quest = ToQuestDocument(questRow); const row = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", user.id) .where("quest_sub.quest_id", "=", quest.questID) - .where("quest.game", "=", v3Game) + .where("quest.game", "=", game) .executeTakeFirst(); if (!row) { - return res.status(409).json({ - success: false, - description: `Can't unsubscribe from a quest you were never subscribed to.`, - }); + throw new ExpectedErr( + 409, + "Can't unsubscribe from a quest you were never subscribed to.", + ); } const questSub = ToQuestSubscriptionDocument(row); - await UnsubscribeFromQuest(questSub, quest); - return res.status(200).json({ - success: true, - description: `Unsubscribed from quest.`, - body: { - quest, - }, - }); + return success("Unsubscribed from quest.", { quest }); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.oldtest.ts deleted file mode 100644 index e4684d002..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.oldtest.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { - GoalDocument, - GoalSubscriptionDocument, - QuestDocument, - QuestSubscriptionDocument, -} from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - HC511Goal, - HC511UserGoal, - TestingIIDXSPQuest, - TestingIIDXSPQuestSub, -} from "#test-utils/test-data"; -import dm from "deepmerge"; -import t from "tap"; - -function mkGoalSub(merge: Partial) { - return dm(HC511UserGoal, merge); -} - -function mkGoal(merge: Partial) { - return dm(HC511Goal, merge); -} - -function mkQuest(merge: Partial) { - return dm(TestingIIDXSPQuest, merge); -} - -function mkQuestSub(merge: Partial) { - return dm(TestingIIDXSPQuestSub, merge); -} - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return subscriptions that were recently achieved by this user.", async (t) => { - await MONGODB_KILL.goals.insert([ - mkGoal({ - goalID: "achieved_goal", - }), - mkGoal({ - goalID: "not_achieved_goal", - }), - ]); - - await MONGODB_KILL["goal-subs"].insert([ - mkGoalSub({ - achieved: true, - timeAchieved: 1000, - goalID: "achieved_goal", - }), - mkGoalSub({ - achieved: false, - goalID: "not_achieved_goal", - }), - ]); - - await MONGODB_KILL.quests.insert([ - mkQuest({}), - mkQuest({ - questID: "not_achieved_quest", - }), - ]); - - await MONGODB_KILL["quest-subs"].insert([ - mkQuestSub({ - achieved: true, - timeAchieved: 2000, - }), - mkQuestSub({ - achieved: false, - questID: "not_achieved_quest", - }), - ]); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/recently-achieved"); - - t.equal(res.statusCode, 200); - - t.hasStrict(res.body.body, { - quests: [{ questID: "example_quest_id" }], - goals: [{ goalID: "achieved_goal" }], - goalSubs: [{ goalID: "achieved_goal" }], - questSubs: [{ questID: "example_quest_id" }], - user: { id: 1 }, - }); - - t.equal(res.body.body.goals.length, 1); - t.equal(res.body.body.quests.length, 1); - t.equal(res.body.body.goalSubs.length, 1); - t.equal(res.body.body.questSubs.length, 1); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return subscriptions that were recently achieved by this user.", async (t) => { - await MONGODB_KILL.goals.insert([ - mkGoal({ - goalID: "achieved_goal", - }), - mkGoal({ - goalID: "not_achieved_goal", - }), - ]); - - await MONGODB_KILL["goal-subs"].insert([ - mkGoalSub({ - achieved: true, - timeAchieved: 1000, - goalID: "achieved_goal", - }), - mkGoalSub({ - achieved: false, - lastInteraction: 1000, - goalID: "not_achieved_goal", - }), - ]); - - await MONGODB_KILL.quests.insert([ - mkQuest({}), - mkQuest({ - questID: "not_achieved_quest", - }), - ]); - - await MONGODB_KILL["quest-subs"].insert([ - mkQuestSub({ - achieved: true, - timeAchieved: 2000, - }), - mkQuestSub({ - achieved: false, - lastInteraction: 1000, - questID: "not_achieved_quest", - }), - ]); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/recently-raised"); - - t.equal(res.statusCode, 200); - - t.hasStrict(res.body.body, { - quests: [{ questID: "not_achieved_quest" }], - goals: [{ goalID: "not_achieved_goal" }], - goalSubs: [{ goalID: "not_achieved_goal" }], - questSubs: [{ questID: "not_achieved_quest" }], - user: { id: 1 }, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts new file mode 100644 index 000000000..22bd1483d --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts @@ -0,0 +1,7 @@ +import { describe, it } from "vitest"; + +describe("UGPT targets (parent router)", () => { + it.todo( + "port recently-achieved / recently-raised from router.oldtest.ts with goal/quest PG seeds", + ); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts index 5a0ddd199..57e864e7b 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts @@ -1,15 +1,21 @@ -import { GetChartById } from "#lib/db-formats/chart"; -import { LoadFolderDocumentById } from "#lib/db-formats/folders"; +import { GetChartByIdForGame } from "#lib/db-formats/chart"; +import { LoadFolderDocumentByGameAndSlug } from "#lib/db-formats/folders"; import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; -import { SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; +import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { + AttachFolderSlugsToGoals, ToGoalDocument, ToGoalSubscriptionDocument, + ToQuestDocument, ToQuestSubscriptionDocument, } from "#lib/db-formats/target-documents"; +import { GetFolderChartIDs } from "#lib/folders/folders"; import { log } from "#lib/log/log"; +import { withUserGameProfile } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { GetRelevantGoals } from "#lib/targets/goals"; import { GetParentQuests } from "#lib/targets/quests"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; import { GetRecentlyAchievedGoals, @@ -17,260 +23,234 @@ import { GetRecentlyInteractedGoals, GetRecentlyInteractedQuests, } from "#utils/db"; -import { GetFolderChartIDs } from "#utils/folder"; -import { GetUGPT } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { GamePTToV3, MongoChartLegacyId } from "tachi-common"; - -import goalsRouter from "./goals/router"; -import questsRouter from "./quests/router"; - -const router: Router = Router({ mergeParams: true }); +import { ExpectedErr } from "bliss"; +import { LEGACY_GameToGameGroupPT } from "tachi-common"; /** * Return a user's recently achieved goals and quests. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved + * @name GET /api/v1/users/:userID/games/:game/targets/recently-achieved */ -router.get("/recently-achieved", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/recently-achieved", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(game); - const userID = user.id; + const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ + GetRecentlyAchievedGoals({ game: gameGroup, playtype, userID: user.id }), + GetRecentlyAchievedQuests({ game: gameGroup, playtype, userID: user.id }), + ]); - const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ - GetRecentlyAchievedGoals({ userID, game, playtype }), - GetRecentlyAchievedQuests({ userID, game, playtype }), - ]); - - return res.status(200).json({ - success: true, - description: `Returned ${user.username}'s recently achieved targets.`, - body: { - goals, - quests, + return success(`Returned ${user.username}'s recently achieved targets.`, { goalSubs, + goals, questSubs, + quests, user, - }, - }); -}); + }); + }, +); /** - * Returns a user's recently interacted with (raised, etc.) goals and quests. - * Note that this does not include recently achieved. + * Returns a user's recently interacted with goals and quests. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised + * @name GET /api/v1/users/:userID/games/:game/targets/recently-raised */ -router.get("/recently-raised", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/recently-raised", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; + const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(game); - const userID = user.id; + const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ + GetRecentlyInteractedGoals({ game: gameGroup, playtype, userID: user.id }), + GetRecentlyInteractedQuests({ game: gameGroup, playtype, userID: user.id }), + ]); - const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ - GetRecentlyInteractedGoals({ userID, game, playtype }), - GetRecentlyInteractedQuests({ userID, game, playtype }), - ]); - - return res.status(200).json({ - success: true, - description: `Returned ${user.username}'s recently achieved targets.`, - body: { - goals, - quests, + return success(`Returned ${user.username}'s recently achieved targets.`, { goalSubs, + goals, questSubs, + quests, user, - }, - }); -}); + }); + }, +); /** - * Find what unachieved targets this user has set that consider this chart. + * Find what targets this user has set that consider this chart. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/on-chart/:chartID + * @name GET /api/v1/users/:userID/games/:game/targets/on-chart/:chartID */ -router.get("/on-chart/:chartID", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/on-chart/:chartID", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; - const chartIDParam = req.params.chartID; + const chart = await GetChartByIdForGame(game, params.chartID); - const chart = await GetChartById(GamePTToV3(game, playtype), chartIDParam); - - if (!chart) { - return res.status(404).json({ - success: false, - description: `Failed to find a chart with chartID '${chartIDParam}'.`, - }); - } - - const { goals, goalSubsMap } = await GetRelevantGoals( - game, - user.id, - new Set([MongoChartLegacyId(chart)]), - log, - false, - ); - - const goalSubs = [...goalSubsMap.values()]; - - const quests = await GetParentQuests(user.id, game, playtype, goalSubs); - - const v3Game = GamePTToV3(game, playtype); - const questIds = quests.map((e) => e.questID); - - const questSubRows = - questIds.length === 0 - ? [] - : await DB.selectFrom("quest_sub") - .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) - .where("quest_sub.user_id", "=", user.id) - .where("quest.game", "=", v3Game) - .where("quest_sub.quest_id", "in", questIds) - .execute(); - - const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); - - return res.status(200).json({ - success: true, - description: `Found pertinent goals`, - body: { - goals, - goalSubs, - quests, - questSubs, - }, - }); -}); - -/** - * Find what unachieved targets this user has set that involve this folder. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/on-folder/:folderID - */ -router.get("/on-folder/:folderID", async (req, res) => { - const { game, playtype, user } = GetUGPT(req); - - const folderID = req.params.folderID; - - const folder = await LoadFolderDocumentById(folderID); - - if (!folder || folder.game !== game || folder.playtype !== playtype) { - return res.status(404).json({ - success: false, - description: `Failed to find a folder with folderID '${folderID}'.`, - }); - } - - const folderChartIDs = await GetFolderChartIDs(folderID); - - const v3Game = GamePTToV3(game, playtype); - - const allSubRows = await DB.selectFrom("goal_sub") - .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) - .where("goal_sub.user_id", "=", user.id) - .where("goal.game", "=", v3Game) - .execute(); - - const allGoalSubs = allSubRows.map((r) => ToGoalSubscriptionDocument(r)); - - const goalIDs = allGoalSubs.map((e) => e.goalID); - - const goalDocRows = - goalIDs.length === 0 - ? [] - : await DB.selectFrom("goal") - .select(SELECT_GOAL) - .where("goal.id", "in", goalIDs) - .execute(); - - const goals: Array> = []; - - for (const row of goalDocRows) { - const g = ToGoalDocument(row); - - if (g.charts.type === "single" && folderChartIDs.includes(g.charts.data)) { - goals.push(g); - } else if ( - g.charts.type === "multi" && - g.charts.data.some((c: string) => folderChartIDs.includes(c)) - ) { - goals.push(g); - } else if (g.charts.type === "folder" && g.charts.data === folderID) { - goals.push(g); + if (!chart) { + throw new ExpectedErr(404, `Failed to find a chart with chartID '${params.chartID}'.`); } - } - const active = new Set(goals.map((g) => g.goalID)); - const goalSubs = allGoalSubs.filter((s) => active.has(s.goalID)); + const { goals, goalSubsMap } = await GetRelevantGoals( + game, + user.id, + new Set([chart.chartID]), + log, + false, + ); - const quests = await GetParentQuests(user.id, game, playtype, goalSubs); + const goalSubs = [...goalSubsMap.values()]; + const quests = await GetParentQuests(user.id, game, goalSubs); + const questIds = quests.map((e) => e.questID); - const questIds = quests.map((e) => e.questID); + const questSubRows = + questIds.length === 0 + ? [] + : await DB.selectFrom("quest_sub") + .innerJoin("quest", "quest.id", "quest_sub.quest_id") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) + .where("quest_sub.user_id", "=", user.id) + .where("quest.game", "=", game) + .where("quest_sub.quest_id", "in", questIds) + .execute(); - const questSubRows = - questIds.length === 0 - ? [] - : await DB.selectFrom("quest_sub") - .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) - .where("quest_sub.user_id", "=", user.id) - .where("quest.game", "=", v3Game) - .where("quest_sub.quest_id", "in", questIds) - .execute(); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); - const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); + await AttachFolderSlugsToGoals(goals); - return res.status(200).json({ - success: true, - description: `Found pertinent goals`, - body: { - goals, - goalSubs, - quests, - questSubs, - }, - }); -}); + return success("Found pertinent goals", { goalSubs, goals, questSubs, quests }); + }, +); /** - * Retrieve all of this user's target subscriptions. + * Find what targets this user has set that involve this folder. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/all-subs + * @name GET /api/v1/users/:userID/games/:game/targets/on-folder/:folderSlug */ -router.get("/all-subs", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/on-folder/:folderSlug", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser, game } = ctx; - const v3Game = GamePTToV3(game, playtype); + const folder = await LoadFolderDocumentByGameAndSlug(game, params.folderSlug); - const [goalSubRows, questSubRows] = await Promise.all([ - DB.selectFrom("goal_sub") + if (!folder || folder.game !== game) { + throw new ExpectedErr(404, `Failed to find a folder with slug '${params.folderSlug}'.`); + } + + const folderChartIDs = await GetFolderChartIDs(folder.folderID); + + const allSubRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) - .where("goal_sub.user_id", "=", user.id) - .where("goal.game", "=", v3Game) - .execute(), - DB.selectFrom("quest_sub") - .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) - .where("quest_sub.user_id", "=", user.id) - .where("quest.game", "=", v3Game) - .execute(), - ]); + .where("goal_sub.user_id", "=", requestedUser.id) + .where("goal.game", "=", game) + .execute(); - const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r)); + const allGoalSubs = allSubRows.map((r) => ToGoalSubscriptionDocument(r)); + const goalIDs = allGoalSubs.map((e) => e.goalID); - const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); + const goalDocRows = + goalIDs.length === 0 + ? [] + : await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIDs) + .execute(); - return res.status(200).json({ - success: true, - description: `Returned all target subscriptions.`, - body: { goalSubs, questSubs }, - }); -}); + const goals: Array> = []; -router.use("/goals", goalsRouter); -router.use("/quests", questsRouter); + for (const row of goalDocRows) { + const g = ToGoalDocument(row); -export default router; + if (g.charts.type === "single" && folderChartIDs.includes(g.charts.data)) { + goals.push(g); + } else if ( + g.charts.type === "multi" && + g.charts.data.some((c: string) => folderChartIDs.includes(c)) + ) { + goals.push(g); + } else if (g.charts.type === "folder" && g.charts.data === folder.folderID) { + goals.push(g); + } + } + + const filteredGoalSubs = allGoalSubs.filter((s) => + goals.find((g) => g.goalID === s.goalID), + ); + + await AttachFolderSlugsToGoals(goals); + + return success("Found pertinent goals.", { + folder, + goalSubs: filteredGoalSubs, + goals, + }); + }, +); + +/** + * Retrieve all of this user's goal and quest subscriptions. + * + * @name GET /api/v1/users/:userID/games/:game/targets/all-subs + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/targets/all-subs", + withUserGameProfile, + async ({ ctx }) => { + const { requestedUser: user, game } = ctx; + + const [goalSubRows, questSubRows] = await Promise.all([ + DB.selectFrom("goal_sub") + .innerJoin("goal", "goal.id", "goal_sub.goal_id") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) + .where("goal_sub.user_id", "=", user.id) + .where("goal.game", "=", game) + .execute(), + DB.selectFrom("quest_sub") + .innerJoin("quest", "quest.id", "quest_sub.quest_id") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) + .where("quest_sub.user_id", "=", user.id) + .where("quest.game", "=", game) + .execute(), + ]); + + const goalSubs = goalSubRows.map(ToGoalSubscriptionDocument); + const questSubs = questSubRows.map(ToQuestSubscriptionDocument); + + const goalIds = goalSubs.map((e) => e.goalID); + const goalRows = + goalIds.length === 0 + ? [] + : await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIds) + .execute(); + const goals = goalRows.map(ToGoalDocument); + await AttachFolderSlugsToGoals(goals); + + const questIds = questSubs.map((e) => e.questID); + const questRows = + questIds.length === 0 + ? [] + : await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "in", questIds) + .execute(); + const quests = questRows.map(ToQuestDocument); + + return success("Returned all target subscriptions.", { + goalSubs, + goals, + questSubs, + quests, + }); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/imports/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/imports/router.oldtest.ts deleted file mode 100644 index 6e17f8b43..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/imports/router.oldtest.ts +++ /dev/null @@ -1,4 +0,0 @@ -import t from "tap"; - -t.todo("GET /api/v1/users/:userID/imports"); -t.todo("GET /api/v1/users/:userID/imports/failed"); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/imports/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/imports/router.test.ts new file mode 100644 index 000000000..6e328a8bc --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/imports/router.test.ts @@ -0,0 +1,9 @@ +import { describe, it } from "vitest"; + +describe("GET /api/v1/users/:userID/imports", () => { + it.todo("port from router.oldtest.ts"); +}); + +describe("GET /api/v1/users/:userID/imports/failed", () => { + it.todo("port from router.oldtest.ts"); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/imports/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/imports/router.ts index 7523bf24b..a53a52ae1 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/imports/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/imports/router.ts @@ -1,16 +1,10 @@ -import type { ImportTypes } from "tachi-common"; - import { ListFailedImportTrackers, ListRecentImportDocuments, } from "#lib/db-formats/import-document"; -import { TachiConfig } from "#lib/setup/config"; -import prValidate from "#server/middleware/prudence-validate"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { p } from "prudence"; - -const router: Router = Router({ mergeParams: true }); +import { withRequestedUser } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; /** * Query this user's imports. Returns the 500 most recently-finished imports. @@ -20,35 +14,18 @@ const router: Router = Router({ mergeParams: true }); * * @name GET /api/v1/users/:userID/imports */ -router.get( - "/", - prValidate({ - importType: p.optional(p.isIn(TachiConfig.IMPORT_TYPES)), - userIntent: p.optional(p.isIn("true", "false")), - }), - async (req, res) => { - const userID = GetTachiData(req, "requestedUser").id; - const importType = req.query.importType as ImportTypes | undefined; +API_V1_ROUTER.add("GET /users/:userID/imports", withRequestedUser, async ({ input, ctx }) => { + const userIntent = input.userIntent === undefined ? undefined : input.userIntent === "true"; - // all query input ends up as strings, so we need convert it into an optional - // boolean - const userIntent = - req.query.userIntent === undefined ? undefined : req.query.userIntent === "true"; + const imports = await ListRecentImportDocuments({ + importType: input.importType as never, + limit: 500, + userId: ctx.requestedUser.id, + userIntent, + }); - const imports = await ListRecentImportDocuments({ - userId: userID, - importType, - userIntent, - limit: 500, - }); - - return res.status(200).json({ - success: true, - description: `Found ${imports.length} imports.`, - body: imports, - }); - }, -); + return success(`Found ${imports.length} imports.`, imports); +}); /** * Return this users 500 most recent failed imports. @@ -58,35 +35,19 @@ router.get( * * @name GET /api/v1/users/:userID/imports/failed */ -router.get( - "/failed", - prValidate({ - importType: p.optional(p.isIn(TachiConfig.IMPORT_TYPES)), - userIntent: p.optional(p.isIn("true", "false")), - }), - async (req, res) => { - const userID = GetTachiData(req, "requestedUser").id; - - const importType = req.query.importType as ImportTypes | undefined; - - // all query input ends up as strings, so we need convert it into an optional - // boolean - const userIntent = - req.query.userIntent === undefined ? undefined : req.query.userIntent === "true"; +API_V1_ROUTER.add( + "GET /users/:userID/imports/failed", + withRequestedUser, + async ({ input, ctx }) => { + const userIntent = input.userIntent === undefined ? undefined : input.userIntent === "true"; const trackers = await ListFailedImportTrackers({ - userId: userID, - importType, - userIntent, + importType: input.importType as never, limit: 500, + userId: ctx.requestedUser.id, + userIntent, }); - return res.status(200).json({ - success: true, - description: `Found ${trackers.length} failed imports.`, - body: trackers, - }); + return success(`Found ${trackers.length} failed imports.`, trackers); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts index 74314f4bc..10e9b2eee 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts @@ -1,98 +1,73 @@ import type { CGServices } from "#lib/score-import/import-types/common/api-cg/types"; -import type { RequestHandler } from "express-serve-static-core"; -import { ACTION_DeleteCgCardInfo } from "#actions/delete-cg-card-info.js"; -import { ACTION_UpdateCgCardInfo } from "#actions/update-cg-card-info.js"; +import { ACTION_DeleteCgCardInfo } from "#actions/delete-cg-card-info"; +import { ACTION_UpdateCgCardInfo } from "#actions/update-cg-card-info"; import { SELECT_CG_CARD_INFO, ToCGCardInfo } from "#lib/db-formats/cg-card-info"; -import prValidate from "#server/middleware/prudence-validate"; +import { withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { p } from "prudence"; +import { ExpectedErr } from "bliss"; -import { RequireSelfRequestFromUser } from "../../../middleware"; +const VALID_CG_TYPES = ["dev", "nag", "gan"] as const; -const router: Router = Router({ mergeParams: true }); - -const ValidateCGType: RequestHandler = (req, res, next) => { - if (req.params.cgType === "dev" || req.params.cgType === "nag" || req.params.cgType === "gan") { - next(); - return; +/** Validate and cast `:cgType` param, throwing a 404 for unsupported values. */ +function resolveCGType(cgType: string | undefined): CGServices { + if (cgType === undefined || !VALID_CG_TYPES.includes(cgType as CGServices)) { + throw new ExpectedErr(404, `No such service 'cg/${cgType ?? ""}' is supported.`); } - return res.status(404).json({ - success: false, - description: `No such service 'cg/${req.params.cgType}' is supported.`, - }); -}; + return cgType as CGServices; +} /** * Retrieve this user's card info (cardID). * * @name GET /api/v1/users/:userID/integrations/cg/:cgType */ -router.get("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const cgType = req.params.cgType as CGServices; +API_V1_ROUTER.add( + "GET /users/:userID/integrations/cg/:cgType", + withSelf, + withRequestedUser, + async ({ params, ctx }) => { + const cgType = resolveCGType(params.cgType); + const { requestedUser: user } = ctx; - const row = await DB.selectFrom("priv_svc_cg_card_info") - .select(SELECT_CG_CARD_INFO) - .where("user_id", "=", user.id) - .where("service", "=", cgType) - .executeTakeFirst(); + const row = await DB.selectFrom("priv_svc_cg_card_info") + .select(SELECT_CG_CARD_INFO) + .where("user_id", "=", user.id) + .where("service", "=", cgType) + .executeTakeFirst(); - if (!row) { - return res.status(200).json({ - success: true, - description: `User has no card info set.`, - body: null, - }); - } + if (!row) { + return success("User has no card info set.", null); + } - return res.status(200).json({ - success: true, - description: `Found card info.`, - body: ToCGCardInfo(row), - }); -}); + return success("Found card info.", ToCGCardInfo(row)); + }, +); /** * Write new card details for this CG integration. * * @name PUT /api/v1/users/:userID/integrations/cg/:cgType */ -router.put( - "/", - ValidateCGType, - RequireSelfRequestFromUser, - prValidate( - { - cardID: p.regex(/^[a-zA-Z0-9]{16}$/u), - pin: p.regex(/^[0-9]{4}$/u), - }, - { - cardID: "Expected 16 characters.", - pin: "Expected 4 digits.", - }, - ), - async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const cgType = req.params.cgType as CGServices; - - const { cardID, pin } = req.safeBody as { - cardID: string; - pin: string; - }; - +API_V1_ROUTER.add( + "PUT /users/:userID/integrations/cg/:cgType", + withSelf, + withRequestedUser, + async ({ input, params, ctx, req }) => { + const cgType = resolveCGType(params.cgType); + const { requestedUser: user } = ctx; const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - await ACTION_UpdateCgCardInfo(taker, { service: cgType, cardID, pin }); - - return res.status(200).json({ - success: true, - description: `Updated cardID and pin.`, - body: {}, + await ACTION_UpdateCgCardInfo(taker, { + service: cgType, + cardID: input.cardID, + pin: input.pin, }); + + return success("Updated cardID and pin.", {}); }, ); @@ -101,19 +76,17 @@ router.put( * * @name DELETE /api/v1/users/:userID/integrations/cg/:cgType */ -router.delete("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const cgType = req.params.cgType as CGServices; +API_V1_ROUTER.add( + "DELETE /users/:userID/integrations/cg/:cgType", + withSelf, + withRequestedUser, + async ({ params, ctx, req }) => { + const cgType = resolveCGType(params.cgType); + const { requestedUser: user } = ctx; + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + await ACTION_DeleteCgCardInfo(taker, { service: cgType }); - await ACTION_DeleteCgCardInfo(taker, { service: cgType }); - - return res.status(200).json({ - success: true, - description: `Deleted stored card info.`, - body: {}, - }); -}); - -export default router; + return success("Deleted stored card info.", {}); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.oldtest.ts deleted file mode 100644 index 37b249805..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.oldtest.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/integrations/fervidex/settings", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return null if this user has no settings set.", async (t) => { - await MONGODB_KILL["fer-settings"].remove({}); - - const res = await mockApi - .get("/api/v1/users/1/integrations/fervidex/settings") - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - t.equal(res.body.body, null); - - t.end(); - }); - - t.test("Should return this users settings if they have them.", async (t) => { - await MONGODB_KILL["fer-settings"].update( - { userID: 1 }, - { $set: { cards: ["foo", "bar"] } }, - ); - - const res = await mockApi - .get("/api/v1/users/1/integrations/fervidex/settings") - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - t.strictSame(res.body.body, { - userID: 1, - forceStaticImport: false, - cards: ["foo", "bar"], - }); - - t.end(); - }); - - t.test("Must require self-key level authentication.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/integrations/fervidex/settings"); - - t.equal(res.statusCode, 401); - - const res2 = await mockApi - .get("/api/v1/users/1/integrations/fervidex/settings") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res2.statusCode, 403); - - // insert a fake user doc so this doesn't 404 - await MONGODB_KILL.users.insert({ - id: 2, - username: "foo", - usernameLowercase: "foo", - } as UserDocument); - - const res3 = await mockApi - .get("/api/v1/users/2/integrations/fervidex/settings") - .set("Cookie", cookie); - - t.equal(res3.statusCode, 403); - - t.end(); - }); - - t.end(); -}); - -t.test("PATCH /api/v1/users/:userID/integrations/fervidex/settings", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should update a users settings.", async (t) => { - const res = await mockApi - .patch("/api/v1/users/1/integrations/fervidex/settings") - .send({ - cards: ["foo", "bar"], - }) - .set("Cookie", cookie); - - t.strictSame(res.body.body.cards, ["foo", "bar"]); - - const dbRes = await MONGODB_KILL["fer-settings"].findOne({ userID: 1 }); - - t.strictSame(dbRes?.cards, ["foo", "bar"]); - - t.end(); - }); - - t.test("Should insert a setting filter document if one doesn't exist.", async (t) => { - await MONGODB_KILL["fer-settings"].remove({}); - - const res = await mockApi - .patch("/api/v1/users/1/integrations/fervidex/settings") - .send({ - cards: ["foo", "bar"], - }) - .set("Cookie", cookie); - - t.strictSame(res.body.body.cards, ["foo", "bar"]); - - const dbRes = await MONGODB_KILL["fer-settings"].findOne({ userID: 1 }); - - t.strictSame(dbRes?.cards, ["foo", "bar"]); - - t.end(); - }); - - t.test("Should null settings if null is provided.", async (t) => { - const res = await mockApi - .patch("/api/v1/users/1/integrations/fervidex/settings") - .send({ - cards: null, - }) - .set("Cookie", cookie); - - t.strictSame(res.body.body.cards, null); - - const dbRes = await MONGODB_KILL["fer-settings"].findOne({ userID: 1 }); - - t.strictSame(dbRes?.cards, null); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.test.ts index f525f2b5d..716c988a6 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.test.ts @@ -68,14 +68,18 @@ describe("GET /api/v1/users/:userID/integrations/fervidex/settings", () => { expect(res.status).toBe(403); }); - it("returns null body when the user has no settings", async () => { + it("returns default settings when the user has no row", async () => { const res = await mockApi .get(`/api/v1/users/${userId}/integrations/fervidex/settings`) .set("Cookie", cookie); expect(res.status).toBe(200); expect(res.body.success).toBe(true); - expect(res.body.body).toBeNull(); + expect(res.body.body).toEqual({ + userID: userId, + cards: null, + forceStaticImport: false, + }); }); it("returns the settings document when the user has a row but no cards", async () => { @@ -119,7 +123,11 @@ describe("GET /api/v1/users/:userID/integrations/fervidex/settings", () => { .set("Cookie", cookie); expect(res.status).toBe(200); - expect(res.body.body).toBeNull(); + expect(res.body.body).toEqual({ + userID: userId, + cards: null, + forceStaticImport: false, + }); }); }); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts index 869a4460d..2d7c0b00e 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts @@ -1,53 +1,47 @@ -import { ACTION_UpdateFervidexSettings } from "#actions/update-fervidex-settings.js"; +import { ACTION_UpdateFervidexSettings } from "#actions/update-fervidex-settings"; import { SELECT_FER_SETTINGS, ToFervidexSettingsDocument } from "#lib/db-formats/fervidex-settings"; -import prValidate from "#server/middleware/prudence-validate"; -import { RequireKamaitachi } from "#server/middleware/type-require"; +import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { optNull } from "#utils/prudence"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; - -import { RequireSelfRequestFromUser } from "../../middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(RequireKamaitachi); -router.use(RequireSelfRequestFromUser); +import { ExpectedErr } from "bliss"; /** * Retrieve your fervidex settings. * * @name GET /api/v1/users/:userID/integrations/fervidex/settings */ -router.get("/settings", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "GET /users/:userID/integrations/fervidex/settings", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ ctx }) => { + const { requestedUser: user } = ctx; - const row = await DB.selectFrom("svc_fer_settings") - .select(SELECT_FER_SETTINGS) - .where("user_id", "=", user.id) - .executeTakeFirst(); + const row = await DB.selectFrom("svc_fer_settings") + .select(SELECT_FER_SETTINGS) + .where("user_id", "=", user.id) + .executeTakeFirst(); - if (!row) { - return res.status(200).json({ - success: true, - description: `Retrieved Fervidex settings.`, - body: null, - }); - } + if (!row) { + return success("Retrieved Fervidex settings.", { + userID: user.id, + cards: null, + forceStaticImport: false, + }); + } - const cardRows = await DB.selectFrom("priv_svc_fer_card") - .select(["priv_svc_fer_card.card_id"]) - .where("user_id", "=", user.id) - .execute(); + const cardRows = await DB.selectFrom("priv_svc_fer_card") + .select(["priv_svc_fer_card.card_id"]) + .where("user_id", "=", user.id) + .execute(); - const cards = cardRows.length > 0 ? cardRows.map((r) => r.card_id) : null; + const cards = cardRows.length > 0 ? cardRows.map((r) => r.card_id) : null; - return res.status(200).json({ - success: true, - description: `Retrieved Fervidex settings.`, - body: ToFervidexSettingsDocument(row, cards), - }); -}); + return success("Retrieved Fervidex settings.", ToFervidexSettingsDocument(row, cards)); + }, +); /** * Update your fervidex configuration. @@ -57,48 +51,41 @@ router.get("/settings", async (req, res) => { * * @name PATCH /api/v1/users/:userID/integrations/fervidex/settings */ -router.patch( - "/settings", - prValidate({ cards: optNull(["string"]), forceStaticImport: "*?boolean" }), - async (req, res) => { - const body = req.safeBody as { - cards?: Array | null; - forceStaticImport?: boolean | null; - }; +API_V1_ROUTER.add( + "PATCH /users/:userID/integrations/fervidex/settings", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ input, ctx, req }) => { + const hasCardsField = "cards" in input; + const hasForceField = "forceStaticImport" in input; + const hasBooleanForce = typeof input.forceStaticImport === "boolean"; - const hasCards = body.cards !== undefined; - const hasForceStaticImport = typeof body.forceStaticImport === "boolean"; - - if (!hasCards && !hasForceStaticImport) { - return res.status(400).json({ - success: false, - description: `No modifications sent.`, - }); + if (!hasCardsField && !hasForceField) { + throw new ExpectedErr(400, "No modifications sent."); } - if (body.cards !== null && body.cards !== undefined && body.cards.length > 6) { - return res.status(400).json({ - success: false, - description: `You cannot have more than 6 card filters at once.`, - }); + if (hasForceField && !hasBooleanForce && !hasCardsField) { + throw new ExpectedErr(400, "No modifications sent."); } - const user = GetTachiData(req, "requestedUser"); + const hasCards = input.cards !== undefined; + const hasForceStaticImport = hasBooleanForce; + + if (input.cards !== null && input.cards !== undefined && input.cards.length > 6) { + throw new ExpectedErr(400, "You cannot have more than 6 card filters at once."); + } + + const { requestedUser: user } = ctx; const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; const result = await ACTION_UpdateFervidexSettings(taker, { - cards: body.cards, + cards: input.cards, forceStaticImport: hasForceStaticImport - ? (body.forceStaticImport as boolean) + ? (input.forceStaticImport as boolean) : undefined, }); - return res.status(200).json({ - success: true, - description: `Successfully updated settings.`, - body: result, - }); + return success("Successfully updated settings.", result); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.oldtest.ts deleted file mode 100644 index ce2c4a427..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.oldtest.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expressRequestMock } from "#test-utils/mock-request"; -import t from "tap"; - -import { ValidateKaiType } from "./middleware"; - -t.test("#ValidateKaiType", (t) => { - const k = async (k: string) => { - const { res } = await expressRequestMock(ValidateKaiType, { params: { kaiType: k } }); - - return res.statusCode; - }; - - t.test("Should allow flo, eag or min, case insensitively.", async (t) => { - t.equal(await k("flo"), 200); - t.equal(await k("eag"), 200); - t.equal(await k("min"), 200); - t.equal(await k("FLO"), 200); - t.equal(await k("EAG"), 200); - t.equal(await k("MIN"), 200); - t.equal(await k("FlO"), 200); - t.equal(await k("EaG"), 200); - t.equal(await k("MiN"), 200); - t.equal(await k("nonsense"), 400); - t.equal(await k("bad"), 400); - t.equal(await k(""), 400); - t.equal(await k("FLO2"), 400); - t.equal(await k("2FLO"), 400); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.test.ts new file mode 100644 index 000000000..f442cdb01 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.test.ts @@ -0,0 +1,32 @@ +import { expressRequestMock } from "#test-utils/mock-request"; +import { describe, expect, it } from "vitest"; + +import { ValidateKaiType } from "./middleware"; + +describe("ValidateKaiType", () => { + async function statusForKaiType(k: string) { + const { res } = await expressRequestMock(ValidateKaiType, { params: { kaiType: k } }); + + return res.statusCode; + } + + it("allows flo, eag or min case-insensitively", async () => { + expect(await statusForKaiType("flo")).toBe(200); + expect(await statusForKaiType("eag")).toBe(200); + expect(await statusForKaiType("min")).toBe(200); + expect(await statusForKaiType("FLO")).toBe(200); + expect(await statusForKaiType("EAG")).toBe(200); + expect(await statusForKaiType("MIN")).toBe(200); + expect(await statusForKaiType("FlO")).toBe(200); + expect(await statusForKaiType("EaG")).toBe(200); + expect(await statusForKaiType("MiN")).toBe(200); + }); + + it("rejects invalid kai types", async () => { + expect(await statusForKaiType("nonsense")).toBe(400); + expect(await statusForKaiType("bad")).toBe(400); + expect(await statusForKaiType("")).toBe(400); + expect(await statusForKaiType("FLO2")).toBe(400); + expect(await statusForKaiType("2FLO")).toBe(400); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts index ce7baa3c2..0dd222b05 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts @@ -1,87 +1,83 @@ import { ACTION_RevokeKaiAuthToken } from "#actions/revoke-kai-auth-token"; import { ACTION_UpsertKaiAuthToken } from "#actions/upsert-kai-auth-token"; import { log } from "#lib/log/log"; +import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { GetKaiTypeClientCredentials, KaiTypeToBaseURL, } from "#lib/score-import/import-types/common/api-kai/utils"; -import prValidate from "#server/middleware/prudence-validate"; -import { RequireKamaitachi } from "#server/middleware/type-require"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import fetch from "#utils/fetch"; -import { NotNullish } from "#utils/misc"; import { GetKaiAuth } from "#utils/queries/auth"; -import { GetTachiData } from "#utils/req-tachi-data"; import { FormatUserDoc } from "#utils/user"; -import { Router } from "express"; +import { ExpectedErr } from "bliss"; import { p } from "prudence"; -import { RequireSelfRequestFromUser } from "../../../middleware"; -import { ValidateKaiType } from "./middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(RequireKamaitachi, RequireSelfRequestFromUser, ValidateKaiType); - -/** - * Return the authentication status for this kaiType. - * @note - Express's types infer arg0 of "/" to mean no params, for some reason. - * the generic overrides this behaviour. - * - * @name GET /api/v1/users/:userID/integrations/kai/:kaiType - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -router.get("/", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const kaiType = NotNullish(req.params.kaiType).toUpperCase() as "EAG" | "FLO" | "MIN"; - - const authDoc = await GetKaiAuth(user.id, kaiType); - - return res.status(200).json({ - success: true, - description: authDoc ? `User is authenticated.` : `User is unauthenticated.`, - body: { - authStatus: !!authDoc, - }, - }); -}); - -/** - * Revoke your authentication for this kaiType. - * @note - Express's types infer arg0 of "/" to mean no params, for some reason. - * the generic overrides this behaviour. - * - * @name DELETE /api/v1/users/:userID/integrations/kai/:kaiType - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -router.delete("/", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const kaiType = NotNullish(req.params.kaiType).toUpperCase() as "EAG" | "FLO" | "MIN"; - - const authDoc = await GetKaiAuth(user.id, kaiType); - - if (!authDoc) { - return res.status(409).json({ - success: false, - description: `You are not authorised with this service.`, - }); - } - - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - - await ACTION_RevokeKaiAuthToken(taker, { service: kaiType }); - - return res.status(200).json({ - success: true, - description: `Revoked authentication for ${kaiType}.`, - body: {}, - }); -}); - const KAI_OAUTH2_RETURN_SCHEMA = { access_token: "string", refresh_token: "string", }; +/** Validate and normalise `:kaiType` param, returning the uppercase service identifier. */ +function resolveKaiType(kaiType: string | undefined): "EAG" | "FLO" | "MIN" { + if (kaiType === undefined || !["eag", "flo", "min"].includes(kaiType.toLowerCase())) { + throw new ExpectedErr(400, "Invalid kaiType - Expected min, flo or eag."); + } + + return kaiType.toUpperCase() as "EAG" | "FLO" | "MIN"; +} + +/** + * Return the authentication status for this kaiType. + * + * @name GET /api/v1/users/:userID/integrations/kai/:kaiType + */ +API_V1_ROUTER.add( + "GET /users/:userID/integrations/kai/:kaiType", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ params, ctx }) => { + const kaiType = resolveKaiType(params.kaiType); + const { requestedUser: user } = ctx; + + const authDoc = await GetKaiAuth(user.id, kaiType); + + return success(authDoc ? "User is authenticated." : "User is unauthenticated.", { + authStatus: !!authDoc, + }); + }, +); + +/** + * Revoke your authentication for this kaiType. + * + * @name DELETE /api/v1/users/:userID/integrations/kai/:kaiType + */ +API_V1_ROUTER.add( + "DELETE /users/:userID/integrations/kai/:kaiType", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ params, ctx, req }) => { + const kaiType = resolveKaiType(params.kaiType); + const { requestedUser: user } = ctx; + + const authDoc = await GetKaiAuth(user.id, kaiType); + + if (!authDoc) { + throw new ExpectedErr(409, "You are not authorised with this service."); + } + + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + + await ACTION_RevokeKaiAuthToken(taker, { service: kaiType }); + + return success(`Revoked authentication for ${kaiType}.`, {}); + }, +); + /** * The OAuth2 callback used by Kai to send an intermediate token to. * @note The way this is implemented is *really* weird due to the fact that @@ -96,16 +92,14 @@ const KAI_OAUTH2_RETURN_SCHEMA = { * * @name POST /api/v1/users/:userID/integrations/kai/:kaiType/oauth2callback */ -router.post( - "/oauth2callback", - prValidate({ code: "string" }, {}, { allowExcessKeys: true }), - async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const kaiType = NotNullish(req.params.kaiType).toUpperCase() as "EAG" | "FLO" | "MIN"; - - const body = req.safeBody as { - code: string; - }; +API_V1_ROUTER.add( + "POST /users/:userID/integrations/kai/:kaiType/oauth2callback", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ input, params, ctx, req }) => { + const kaiType = resolveKaiType(params.kaiType); + const { requestedUser: user } = ctx; const baseUrl = KaiTypeToBaseURL(kaiType); @@ -115,30 +109,27 @@ router.post( log.error( `Attempted to /callback ${kaiType}, but this server has no oauth2 credentials configured for that type.`, ); - return res.status(500).json({ - success: false, - description: `A fatal error has occured, This has been reported.`, - }); + throw new ExpectedErr(500, "A fatal error has occured, This has been reported."); } const { CLIENT_SECRET, CLIENT_ID, REDIRECT_URI } = maybeCredentials; const url = `${baseUrl}/oauth/token`; - const params = new URLSearchParams(); + const params_ = new URLSearchParams(); - params.append("code", body.code); - params.append("grant_type", "authorization_code"); - params.append("client_secret", CLIENT_SECRET); - params.append("client_id", CLIENT_ID); - params.append("redirect_uri", REDIRECT_URI); + params_.append("code", input.code); + params_.append("grant_type", "authorization_code"); + params_.append("client_secret", CLIENT_SECRET); + params_.append("client_id", CLIENT_ID); + params_.append("redirect_uri", REDIRECT_URI); log.info(`Making token reify request from ${baseUrl}/oauth/token`); let getTokenRes; try { getTokenRes = await fetch(url, { - body: params.toString(), + body: params_.toString(), headers: { "Content-Type": "application/x-www-form-urlencoded", }, @@ -146,19 +137,15 @@ router.post( }); } catch (err) { log.error(err, `Completely failed to getTokenRes from ${url}.`); - return res.status(500).json({ - success: false, - description: `We failed to reach this site. Are they down?`, - }); + throw new ExpectedErr(500, "We failed to reach this site. Are they down?"); } if (getTokenRes.status !== 200) { log.error(`Unexpected status of ${getTokenRes.status} from ${url} oauth2 flow.`); - - return res.status(getTokenRes.status < 500 ? 400 : 500).json({ - success: false, - description: `The server you requested returned a status of ${getTokenRes.status}. Either your request was malformed, or the server is malfunctioning.`, - }); + throw new ExpectedErr( + getTokenRes.status < 500 ? 400 : 500, + `The server you requested returned a status of ${getTokenRes.status}. Either your request was malformed, or the server is malfunctioning.`, + ); } let json: unknown; @@ -167,27 +154,23 @@ router.post( json = await getTokenRes.json(); } catch (err) { log.error( - { - res: getTokenRes, - err, - }, + { res: getTokenRes, err }, `Error parsing JSON in response body from getTokenRes.`, ); - - return res.status(500).json({ - success: false, - description: `Failed to parse JSON returned from this service. Is their server malfunctioning?`, - }); + throw new ExpectedErr( + 500, + "Failed to parse JSON returned from this service. Is their server malfunctioning?", + ); } const err = p(json, KAI_OAUTH2_RETURN_SCHEMA, {}, { allowExcessKeys: true }); if (err) { log.error({ err }, `Validation error in JSON return from ${url}.`); - return res.status(500).json({ - success: false, - description: `Failed to validate JSON returned from this service. Is their server malfunctioning?`, - }); + throw new ExpectedErr( + 500, + "Failed to validate JSON returned from this service. Is their server malfunctioning?", + ); } const j = json as { access_token: string; refresh_token: string }; @@ -202,12 +185,6 @@ router.post( log.info(`Updated Auth for ${kaiType} for user ${FormatUserDoc(user)}.`); - return res.status(200).json({ - success: true, - description: `Successfully updated auth for ${kaiType}`, - body: {}, - }); + return success(`Successfully updated auth for ${kaiType}`, {}); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.oldtest.ts deleted file mode 100644 index efc6fce1f..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.oldtest.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/integrations/kshook-sv6c/settings", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return null if this user has no settings set.", async (t) => { - await MONGODB_KILL["kshook-sv6c-settings"].remove({}); - - const res = await mockApi - .get("/api/v1/users/1/integrations/kshook-sv6c/settings") - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - t.equal(res.body.body, null); - - t.end(); - }); - - t.test("Should return this users settings if they have them.", async (t) => { - const res = await mockApi - .get("/api/v1/users/1/integrations/kshook-sv6c/settings") - .set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - t.strictSame(res.body.body, { - userID: 1, - forceStaticImport: true, - }); - - t.end(); - }); - - t.test("Must require self-key level authentication.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/integrations/kshook-sv6c/settings"); - - t.equal(res.statusCode, 401); - - const res2 = await mockApi - .get("/api/v1/users/1/integrations/kshook-sv6c/settings") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res2.statusCode, 403); - - // insert a fake user doc so this doesn't 404 - await MONGODB_KILL.users.insert({ - id: 2, - username: "foo", - usernameLowercase: "foo", - } as UserDocument); - - const res3 = await mockApi - .get("/api/v1/users/2/integrations/kshook-sv6c/settings") - .set("Cookie", cookie); - - t.equal(res3.statusCode, 403); - - t.end(); - }); - - t.end(); -}); - -t.test("PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should update a users settings.", async (t) => { - const res = await mockApi - .patch("/api/v1/users/1/integrations/kshook-sv6c/settings") - .send({ forceStaticImport: true }) - .set("Cookie", cookie); - - t.strictSame(res.body.body.forceStaticImport, true); - - const dbRes = await MONGODB_KILL["kshook-sv6c-settings"].findOne({ userID: 1 }); - - t.strictSame(dbRes?.forceStaticImport, true); - - t.end(); - }); - - t.test("Should insert a setting filter document if one doesn't exist.", async (t) => { - await MONGODB_KILL["kshook-sv6c-settings"].remove({}); - - const res = await mockApi - .patch("/api/v1/users/1/integrations/kshook-sv6c/settings") - .send({ forceStaticImport: true }) - .set("Cookie", cookie); - - t.strictSame(res.body.body.forceStaticImport, true); - - const dbRes = await MONGODB_KILL["kshook-sv6c-settings"].findOne({ userID: 1 }); - - t.strictSame(dbRes?.forceStaticImport, true); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts index b7b8ba138..440a38a7d 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts @@ -1,40 +1,35 @@ -import { ACTION_UpdateKshookSv6cSettings } from "#actions/update-kshook-sv6c-settings.js"; +import { ACTION_UpdateKshookSv6cSettings } from "#actions/update-kshook-sv6c-settings"; import { SELECT_KSHOOK_SV6C_SETTINGS, ToKshookSv6cSettings, } from "#lib/db-formats/kshook-sv6c-settings"; -import prValidate from "#server/middleware/prudence-validate"; -import { RequireKamaitachi } from "#server/middleware/type-require"; +import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; - -import { RequireSelfRequestFromUser } from "../../middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(RequireKamaitachi); -router.use(RequireSelfRequestFromUser); +import { ExpectedErr } from "bliss"; /** * Retrieve your KsHook SV6C settings. * * @name GET /api/v1/users/:userID/integrations/kshook-sv6c/settings */ -router.get("/settings", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "GET /users/:userID/integrations/kshook-sv6c/settings", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ ctx }) => { + const { requestedUser: user } = ctx; - const row = await DB.selectFrom("svc_kshook_sv6c_settings") - .select(SELECT_KSHOOK_SV6C_SETTINGS) - .where("user_id", "=", user.id) - .executeTakeFirst(); + const row = await DB.selectFrom("svc_kshook_sv6c_settings") + .select(SELECT_KSHOOK_SV6C_SETTINGS) + .where("user_id", "=", user.id) + .executeTakeFirst(); - return res.status(200).json({ - success: true, - description: `Retrieved KsHook (S6VC) settings.`, - body: row ? ToKshookSv6cSettings(row) : null, - }); -}); + return success("Retrieved KsHook (S6VC) settings.", row ? ToKshookSv6cSettings(row) : null); + }, +); /** * Update your KsHook SV6C configuration. @@ -43,20 +38,23 @@ router.get("/settings", async (req, res) => { * * @name PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings */ -router.patch("/settings", prValidate({ forceStaticImport: "boolean" }), async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "PATCH /users/:userID/integrations/kshook-sv6c/settings", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ input, ctx, req }) => { + const { requestedUser: user } = ctx; + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + if (input.forceStaticImport === undefined) { + throw new ExpectedErr(400, "No modifications sent."); + } - const result = await ACTION_UpdateKshookSv6cSettings(taker, { - forceStaticImport: req.body.forceStaticImport, - }); + const result = await ACTION_UpdateKshookSv6cSettings(taker, { + forceStaticImport: input.forceStaticImport, + }); - return res.status(200).json({ - success: true, - description: `Successfully updated settings.`, - body: { userID: user.id, ...result }, - }); -}); - -export default router; + return success("Successfully updated settings.", { userID: user.id, ...result }); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/myt/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/myt/router.ts index 0b77f4146..18d89fde5 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/myt/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/myt/router.ts @@ -1,64 +1,53 @@ -import { ACTION_DeleteMytCardInfo } from "#actions/delete-myt-card-info.js"; -import { ACTION_UpdateMytCardInfo } from "#actions/update-myt-card-info.js"; +import { ACTION_DeleteMytCardInfo } from "#actions/delete-myt-card-info"; +import { ACTION_UpdateMytCardInfo } from "#actions/update-myt-card-info"; import { SELECT_MYT_CARD_INFO, ToMytCardInfo } from "#lib/db-formats/myt-card-info"; -import prValidate from "#server/middleware/prudence-validate"; -import { RequireKamaitachi } from "#server/middleware/type-require"; +import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; -import { p } from "prudence"; - -import { RequireSelfRequestFromUser } from "../../middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(RequireKamaitachi); -router.use(RequireSelfRequestFromUser); /** * Retrieve this user's card info (cardAccessCode). * * @name GET /api/v1/users/:userID/integrations/myt */ -router.get("/", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "GET /users/:userID/integrations/myt", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ ctx }) => { + const { requestedUser: user } = ctx; - const row = await DB.selectFrom("priv_svc_myt_card_info") - .select(SELECT_MYT_CARD_INFO) - .where("user_id", "=", user.id) - .executeTakeFirst(); + const row = await DB.selectFrom("priv_svc_myt_card_info") + .select(SELECT_MYT_CARD_INFO) + .where("user_id", "=", user.id) + .executeTakeFirst(); - return res.status(200).json({ - success: true, - description: row ? `Found card info.` : `User has no card info set.`, - body: row ? ToMytCardInfo(row) : null, - }); -}); + return success( + row ? `Found card info.` : `User has no card info set.`, + row ? ToMytCardInfo(row) : null, + ); + }, +); /** * Write new card details for Myt. * * @name PUT /api/v1/users/:userID/integrations/myt */ -router.put( - "/", - prValidate( - { cardAccessCode: p.regex(/^[0-9]{20}$/u) }, - { cardAccessCode: "Expected 20 digits." }, - ), - async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "PUT /users/:userID/integrations/myt", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ input, ctx, req }) => { + const { requestedUser: user } = ctx; const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - const { cardAccessCode } = req.safeBody as { cardAccessCode: string }; + await ACTION_UpdateMytCardInfo(taker, { cardAccessCode: input.cardAccessCode }); - await ACTION_UpdateMytCardInfo(taker, { cardAccessCode }); - - return res.status(200).json({ - success: true, - description: `Updated cardAccessCode.`, - body: {}, - }); + return success("Updated cardAccessCode.", {}); }, ); @@ -67,17 +56,17 @@ router.put( * * @name DELETE /api/v1/users/:userID/integrations/myt */ -router.delete("/", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; +API_V1_ROUTER.add( + "DELETE /users/:userID/integrations/myt", + withKamaitachi, + withSelf, + withRequestedUser, + async ({ ctx, req }) => { + const { requestedUser: user } = ctx; + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - await ACTION_DeleteMytCardInfo(taker, {}); + await ACTION_DeleteMytCardInfo(taker, {}); - return res.status(200).json({ - success: true, - description: `Deleted stored card info.`, - body: {}, - }); -}); - -export default router; + return success("Deleted stored card info.", {}); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/router.ts index a15fca56d..cb0ff5c3b 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/router.ts @@ -1,17 +1 @@ -import { Router } from "express"; - -import cgCgTypeRouter from "./cg/_cgType/router"; -import fervidexRouter from "./fervidex/router"; -import kaiKaiTypeRouter from "./kai/_kaiType/router"; -import ksHookSV6CRouter from "./kshook-sv6c/router"; -import mytRouter from "./myt/router"; - -const router: Router = Router({ mergeParams: true }); - -router.use("/cg/:cgType", cgCgTypeRouter); -router.use("/myt", mytRouter); -router.use("/kai/:kaiType", kaiKaiTypeRouter); -router.use("/fervidex", fervidexRouter); -router.use("/kshook-sv6c", ksHookSV6CRouter); - -export default router; +export {}; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/invites/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/invites/router.oldtest.ts deleted file mode 100644 index e50c55a43..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/invites/router.oldtest.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { ONE_MONTH } from "#lib/constants/time"; -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { type InviteCodeDocument, UserAuthLevels, type UserDocument } from "tachi-common"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/invites", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return all of this users created invites and who used them.", async (t) => { - await MONGODB_KILL.users.insert({ - id: 2, - username: "other_dude", - usernameLowercase: "other_dude", - } as UserDocument); - - const res = await mockApi.get("/api/v1/users/1/invites").set("Cookie", cookie); - - t.strictSame( - (res.body.body.invites as Array).sort( - (a, b) => a.createdAt - b.createdAt, - ), - [ - { - code: "example_invite", - createdBy: 1, - createdAt: 0, - consumed: false, - consumedBy: null, - consumedAt: null, - }, - { - code: "example_consumed_invite", - createdBy: 1, - createdAt: 1, - consumed: true, - consumedBy: 2, - consumedAt: 123, - }, - ], - ); - - t.strictSame(res.body.body.consumers, [ - { - id: 2, - username: "other_dude", - usernameLowercase: "other_dude", - }, - ]); - - t.end(); - }); - - t.test("Should require self-key level authentication.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/invites"); - - t.equal(res.statusCode, 401); - - const res2 = await mockApi - .get("/api/v1/users/1/invites") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res2.statusCode, 403); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/invites/limit", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return this users current limit on invites.", async (t) => { - // because this thing is time-based, we need to make sure - // this is always relative to right now. - - await MONGODB_KILL.users.update( - { id: 1 }, - { $set: { joinDate: Date.now() - ONE_MONTH * 2.5 } }, - ); - const res = await mockApi.get("/api/v1/users/1/invites/limit").set("Cookie", cookie); - - t.equal(res.body.body.invites, 2); - t.equal(res.body.body.limit, 4); - - t.end(); - }); - - t.test("Should require self-key level authentication.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/invites/limit"); - - t.equal(res.statusCode, 401); - - const res2 = await mockApi - .get("/api/v1/users/1/invites/limit") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res2.statusCode, 403); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/users/:userID/invites/create", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should create a new invite.", async (t) => { - await MONGODB_KILL.users.update( - { id: 1 }, - { $set: { joinDate: Date.now() - ONE_MONTH * 2.5 } }, - ); - - const res = await mockApi.post("/api/v1/users/1/invites/create").set("Cookie", cookie); - - t.equal(res.statusCode, 200); - - const dbRes = await MONGODB_KILL.invites.findOne({ - code: res.body.body.code, - }); - - t.not(dbRes, null, "Should exist in the database."); - - t.end(); - }); - - t.test("Should honor invite limit.", async (t) => { - await MONGODB_KILL.invites.remove({}); - - // users with less than a month of life in them dont get invites, - // so this will force an invite limit honor. - await MONGODB_KILL.users.update( - { id: 1 }, - { $set: { joinDate: Date.now(), authLevel: UserAuthLevels.USER } }, - ); - - const res = await mockApi.post("/api/v1/users/1/invites/create").set("Cookie", cookie); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should require self-key level authentication.", async (t) => { - const res = await mockApi.post("/api/v1/users/1/invites/create"); - - t.equal(res.statusCode, 401); - - const res2 = await mockApi - .post("/api/v1/users/1/invites/create") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res2.statusCode, 403); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/invites/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/invites/router.test.ts new file mode 100644 index 000000000..dbf1ff930 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/invites/router.test.ts @@ -0,0 +1,215 @@ +import type { InviteCodeDocument } from "tachi-common"; + +import { seedApiToken } from "#actions/test-utils/api-tokens"; +import { ONE_MONTH } from "#lib/constants/time"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +describe("GET /api/v1/users/:userID/invites", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "inv_u1", withCredential: true, withSettings: true }); + + await seedUser({ username: "other_dude" }); + + await DB.insertInto("priv_invite") + .values([ + { + code: "example_invite", + created_by: 1, + created_at: "1970-01-01T00:00:00.000Z", + consumed: false, + consumed_by: null, + consumed_at: null, + }, + { + code: "example_consumed_invite", + created_by: 1, + created_at: "1970-01-01T00:00:00.001Z", + consumed: true, + consumed_by: 2, + consumed_at: "1970-01-01T00:00:00.123Z", + }, + ]) + .execute(); + + cookie = await loginAs("inv_u1"); + }); + + it("returns this user's created invites and who used them", async () => { + const res = await mockApi.get("/api/v1/users/1/invites").set("Cookie", cookie); + + expect(res.status).toBe(200); + + const invites = (res.body.body.invites as Array).sort( + (a, b) => a.createdAt - b.createdAt, + ); + + expect(invites).toEqual([ + { + code: "example_invite", + createdBy: 1, + createdAt: 0, + consumed: false, + consumedBy: null, + consumedAt: null, + }, + { + code: "example_consumed_invite", + createdBy: 1, + createdAt: 1, + consumed: true, + consumedBy: 2, + consumedAt: 123, + }, + ]); + + expect(res.body.body.consumers).toHaveLength(1); + expect(res.body.body.consumers[0]).toMatchObject({ + id: 2, + username: "other_dude", + usernameLowercase: "other_dude", + }); + }); + + it("requires self-key authentication", async () => { + const res = await mockApi.get("/api/v1/users/1/invites"); + + expect(res.status).toBe(401); + + await seedApiToken({ token: "inv_sess_only", userId: 1, identifier: "sess" }); + + const res2 = await mockApi + .get("/api/v1/users/1/invites") + .set("Authorization", "Bearer inv_sess_only"); + + expect(res2.status).toBe(403); + }); +}); + +describe("GET /api/v1/users/:userID/invites/limit", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "lim_u1", withCredential: true, withSettings: true }); + + await DB.insertInto("priv_invite") + .values([ + { + code: "a", + created_by: 1, + created_at: new Date().toISOString(), + consumed: false, + consumed_by: null, + consumed_at: null, + }, + { + code: "b", + created_by: 1, + created_at: new Date().toISOString(), + consumed: false, + consumed_by: null, + consumed_at: null, + }, + ]) + .execute(); + + await DB.updateTable("account") + .set({ joined: new Date(Date.now() - ONE_MONTH * 2.5).toISOString() }) + .where("account.id", "=", 1) + .execute(); + + cookie = await loginAs("lim_u1"); + }); + + it("returns invite usage vs limit for this user", async () => { + const res = await mockApi.get("/api/v1/users/1/invites/limit").set("Cookie", cookie); + + expect(res.body.body.invites).toBe(2); + expect(res.body.body.limit).toBe(4); + }); + + it("requires self-key authentication", async () => { + const res = await mockApi.get("/api/v1/users/1/invites/limit"); + + expect(res.status).toBe(401); + + await seedApiToken({ token: "lim_sess_only", userId: 1, identifier: "sess" }); + + const res2 = await mockApi + .get("/api/v1/users/1/invites/limit") + .set("Authorization", "Bearer lim_sess_only"); + + expect(res2.status).toBe(403); + }); +}); + +describe("POST /api/v1/users/:userID/invites/create", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "crt_u1", withCredential: true, withSettings: true }); + + await DB.updateTable("account") + .set({ joined: new Date(Date.now() - ONE_MONTH * 2.5).toISOString() }) + .where("account.id", "=", 1) + .execute(); + + cookie = await loginAs("crt_u1"); + }); + + it("creates a new invite", async () => { + const res = await mockApi.post("/api/v1/users/1/invites/create").set("Cookie", cookie); + + expect(res.status).toBe(200); + + const dbRes = await DB.selectFrom("priv_invite") + .selectAll() + .where("code", "=", res.body.body.code) + .executeTakeFirst(); + + expect(dbRes).not.toBeNull(); + }); + + it("honours the invite limit for new accounts", async () => { + await DB.deleteFrom("priv_invite").execute(); + + await DB.updateTable("account") + .set({ joined: new Date().toISOString(), auth_level: "user" }) + .where("account.id", "=", 1) + .execute(); + + const res = await mockApi.post("/api/v1/users/1/invites/create").set("Cookie", cookie); + + expect(res.status).toBe(400); + }); + + it("requires self-key authentication", async () => { + const res = await mockApi.post("/api/v1/users/1/invites/create"); + + expect(res.status).toBe(401); + + await seedApiToken({ token: "crt_sess_only", userId: 1, identifier: "sess" }); + + const res2 = await mockApi + .post("/api/v1/users/1/invites/create") + .set("Authorization", "Bearer crt_sess_only"); + + expect(res2.status).toBe(403); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/invites/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/invites/router.ts index 007e15214..c983dc272 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/invites/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/invites/router.ts @@ -1,46 +1,41 @@ import { ACTION_CreateInvite } from "#actions/create-invite"; import { SELECT_INVITE, ToInviteDocument } from "#lib/db-formats/invite"; import { GetTotalAllowedInvites } from "#lib/invites/invites"; -import { RequireInvitesEnabled } from "#server/middleware/type-require"; +import { withInvitesEnabled, withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { GetTachiData } from "#utils/req-tachi-data"; import { GetUsersWithIDs } from "#utils/user"; -import { Router } from "express"; import { sql } from "kysely"; -import { RequireSelfRequestFromUser } from "../middleware"; - -const router: Router = Router({ mergeParams: true }); - -router.use(RequireInvitesEnabled); -router.use(RequireSelfRequestFromUser); - /** * Retrieve all of this users created invites. * * @name GET /api/v1/users/:userID/invites */ -router.get("/", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "GET /users/:userID/invites", + withInvitesEnabled, + withRequestedUser, + withSelf, + async ({ ctx }) => { + const { requestedUser: user } = ctx; - const rows = await DB.selectFrom("priv_invite") - .select(SELECT_INVITE) - .where("created_by", "=", user.id) - .orderBy(sql`priv_invite.consumed_at desc nulls last`) - .execute(); + const rows = await DB.selectFrom("priv_invite") + .select(SELECT_INVITE) + .where("priv_invite.created_by", "=", user.id) + .orderBy(sql`priv_invite.consumed_at desc nulls last`) + .execute(); - const invites = rows.map(ToInviteDocument); + const invites = rows.map(ToInviteDocument); - const consumers = await GetUsersWithIDs( - invites.map((e) => e.consumedBy).filter((e) => e !== null) as Array, - ); + const consumers = await GetUsersWithIDs( + invites.map((e) => e.consumedBy).filter((e) => e !== null) as Array, + ); - return res.status(200).json({ - success: true, - description: `Found ${invites.length} invites.`, - body: { invites, consumers }, - }); -}); + return success(`Found ${invites.length} invites.`, { consumers, invites }); + }, +); /** * Return how many invites this user can create, and how many they @@ -48,43 +43,42 @@ router.get("/", async (req, res) => { * * @name GET /api/v1/users/:userID/invites/limit */ -router.get("/limit", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "GET /users/:userID/invites/limit", + withInvitesEnabled, + withRequestedUser, + withSelf, + async ({ ctx }) => { + const { requestedUser: user } = ctx; - const { count } = await DB.selectFrom("priv_invite") - .select(DB.fn.countAll().as("count")) - .where("created_by", "=", user.id) - .executeTakeFirstOrThrow(); + const { count } = await DB.selectFrom("priv_invite") + .select(DB.fn.countAll().as("count")) + .where("priv_invite.created_by", "=", user.id) + .executeTakeFirstOrThrow(); - const invites = Number(count); - const limit = GetTotalAllowedInvites(user); - - return res.status(200).json({ - success: true, - description: `Calculated invite limit.`, - body: { - invites, - limit, - }, - }); -}); + return success("Calculated invite limit.", { + invites: Number(count), + limit: GetTotalAllowedInvites(user), + }); + }, +); /** * Create a new invite. * * @name POST /api/v1/users/:userID/invites/create */ -router.post("/create", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; +API_V1_ROUTER.add( + "POST /users/:userID/invites/create", + withInvitesEnabled, + withRequestedUser, + withSelf, + async ({ ctx, req }) => { + const { requestedUser: user } = ctx; + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; - const inviteDoc = await ACTION_CreateInvite(taker, {}); + const inviteDoc = await ACTION_CreateInvite(taker, {}); - return res.status(200).json({ - success: true, - description: `Created Invite.`, - body: inviteDoc, - }); -}); - -export default router; + return success("Created Invite.", inviteDoc); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/middleware.ts b/typescript/server/src/server/router/api/v1/users/_userID/middleware.ts index dfe9da2ab..1b376e80e 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/middleware.ts @@ -3,7 +3,7 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; import { IsNullish } from "#utils/misc"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; +import { REQ_AssignToReqTachiData, REQ_GetTachiData } from "#utils/req-tachi-data"; import { GetUserWithID, ResolveUser } from "#utils/user"; import { UserAuthLevels } from "tachi-common"; @@ -29,7 +29,7 @@ export const GetUserFromParam: RequestHandler = async (req, res, next) => { // fast assign using JWT if (req.session.tachi?.user) { - AssignToReqTachiData(req, { requestedUser: req.session.tachi.user }); + REQ_AssignToReqTachiData(req, { requestedUser: req.session.tachi.user }); next(); return; } @@ -46,7 +46,7 @@ export const GetUserFromParam: RequestHandler = async (req, res, next) => { }); } - AssignToReqTachiData(req, { requestedUser: user }); + REQ_AssignToReqTachiData(req, { requestedUser: user }); next(); }; @@ -55,7 +55,7 @@ export const GetUserFromParam: RequestHandler = async (req, res, next) => { * Require the user making this request to also be the user in the :userID param. */ export const RequireAuthedAsUser: RequestHandler = async (req, res, next) => { - const user = GetTachiData(req, "requestedUser"); + const user = REQ_GetTachiData(req, "requestedUser"); const requestingUserID = req[SYMBOL_TACHI_API_AUTH].userID; @@ -98,7 +98,7 @@ export const RequireAuthedAsUser: RequestHandler = async (req, res, next) => { * alter/access, like integration information. */ export const RequireSelfRequestFromUser: RequestHandler = (req, res, next) => { - const user = GetTachiData(req, "requestedUser"); + const user = REQ_GetTachiData(req, "requestedUser"); if (req[SYMBOL_TACHI_API_AUTH].userID === null) { return res.status(401).json({ diff --git a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.oldtest.ts deleted file mode 100644 index eac1639f2..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.oldtest.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { NotificationDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import { mkFakeNotification, mkFakeUser } from "#test-utils/misc"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/notifications", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => - Promise.all([ - MONGODB_KILL.notifications.insert([ - mkFakeNotification({ notifID: "read", read: true, sentAt: 2 }), - mkFakeNotification({ notifID: "unread", read: false, sentAt: 3 }), - mkFakeNotification({ notifID: "not_ours", sentTo: 2, sentAt: 4 }), - ]), - MONGODB_KILL.users.insert(mkFakeUser(2)), - ]), - ); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should return all of this user's notifications, read or unread.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/notifications").set("Cookie", cookie); - - t.equal(res.statusCode, 200, "Should return 200"); - - t.strictSame( - res.body.body.map((e: NotificationDocument) => e.notifID), - ["unread", "read"], - "Should return the exact notifications we expected (in most-recent order), and no more.", - ); - - t.end(); - }); - - t.test("Should return 401 if not authenticated.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/notifications"); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("Should return 403 if authed as someone else.", async (t) => { - const res = await mockApi.get("/api/v1/users/2/notifications").set("Cookie", cookie); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should return 403 if authed as right user without self-key", async (t) => { - const res = await mockApi - .get("/api/v1/users/1/notifications") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/users/:userID/notifications/delete-all", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => - Promise.all([ - MONGODB_KILL.notifications.insert([ - mkFakeNotification({ notifID: "read", read: true, sentAt: 2 }), - mkFakeNotification({ notifID: "unread", read: false, sentAt: 3 }), - mkFakeNotification({ notifID: "unread_2", read: false, sentAt: 4 }), - mkFakeNotification({ notifID: "not_ours", sentTo: 2, sentAt: 5 }), - ]), - MONGODB_KILL.users.insert(mkFakeUser(2)), - ]), - ); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should mark all of a user's notifications as read.", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/notifications/mark-all-read") - .set("Cookie", cookie); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.strictSame(res.body.body, {}, "Should have no response body."); - - const dbRes = await MONGODB_KILL.notifications.findOne({ - sentTo: 1, - read: false, - }); - - t.equal(dbRes, null, "Should not find any unread messages in the users inbox."); - - t.end(); - }); - - t.test("Should return 401 if not authenticated.", async (t) => { - const res = await mockApi.post("/api/v1/users/1/notifications/mark-all-read"); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("Should return 403 if authed as someone else.", async (t) => { - const res = await mockApi - .post("/api/v1/users/2/notifications/mark-all-read") - .set("Cookie", cookie); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should return 403 if authed as right user without self-key", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/notifications/mark-all-read") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/users/:userID/notifications/delete-all", async (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => - Promise.all([ - MONGODB_KILL.notifications.insert([ - mkFakeNotification({ notifID: "read", read: true, sentAt: 2 }), - mkFakeNotification({ notifID: "unread", read: false, sentAt: 3 }), - mkFakeNotification({ notifID: "unread_2", read: false, sentAt: 4 }), - mkFakeNotification({ notifID: "not_ours", sentTo: 2, sentAt: 5 }), - ]), - MONGODB_KILL.users.insert(mkFakeUser(2)), - ]), - ); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should empty a user's notification inbox.", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/notifications/delete-all") - .set("Cookie", cookie); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.strictSame(res.body.body, {}, "Should have no response body."); - - const dbRes = await MONGODB_KILL.notifications.findOne({ - sentTo: 1, - }); - - t.equal(dbRes, null, "Should not find any messages in the users inbox."); - - t.end(); - }); - - t.test("Should return 401 if not authenticated.", async (t) => { - const res = await mockApi.post("/api/v1/users/1/notifications/delete-all"); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("Should return 403 if authed as someone else.", async (t) => { - const res = await mockApi - .post("/api/v1/users/2/notifications/delete-all") - .set("Cookie", cookie); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should return 403 if authed as right user without self-key", async (t) => { - const res = await mockApi - .post("/api/v1/users/1/notifications/delete-all") - .set("Authorization", "Bearer fake_api_token"); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts new file mode 100644 index 000000000..56d737872 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts @@ -0,0 +1,270 @@ +import { seedApiToken } from "#actions/test-utils/api-tokens"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +describe("GET /api/v1/users/:userID/notifications", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "notif_u1", withCredential: true, withSettings: true }); + await seedUser({ username: "notif_u2" }); + + await DB.insertInto("notification") + .values({ + title: "u", + sent_to: 1, + sent_at: new Date(3000).toISOString(), + read: false, + kind: "site_announcement", + payload: {}, + }) + .execute(); + + await DB.insertInto("notification") + .values({ + title: "r", + sent_to: 1, + sent_at: new Date(2000).toISOString(), + read: true, + kind: "site_announcement", + payload: {}, + }) + .execute(); + + await DB.insertInto("notification") + .values({ + title: "o", + sent_to: 2, + sent_at: new Date(4000).toISOString(), + read: false, + kind: "site_announcement", + payload: {}, + }) + .execute(); + + cookie = await loginAs("notif_u1"); + }); + + it("returns this user's notifications (most recent first)", async () => { + const res = await mockApi.get("/api/v1/users/1/notifications").set("Cookie", cookie); + + expect(res.status).toBe(200); + const ids = res.body.body.map((e: { notifID: string }) => e.notifID); + const rows = await DB.selectFrom("notification") + .select(["notification.row_id"]) + .where("notification.sent_to", "=", 1) + .orderBy("notification.sent_at", "desc") + .execute(); + expect(ids).toEqual(rows.map((r) => r.row_id)); + }); + + it("returns 401 when not authenticated", async () => { + const res = await mockApi.get("/api/v1/users/1/notifications"); + + expect(res.status).toBe(401); + }); + + it("returns 403 when authenticated as another user", async () => { + const res = await mockApi.get("/api/v1/users/2/notifications").set("Cookie", cookie); + + expect(res.status).toBe(403); + }); + + it("returns 403 when using an API key instead of a session cookie", async () => { + await seedApiToken({ token: "no_cookie", userId: 1, identifier: "x" }); + + const res = await mockApi + .get("/api/v1/users/1/notifications") + .set("Authorization", "Bearer no_cookie"); + + expect(res.status).toBe(403); + }); +}); + +describe("POST /api/v1/users/:userID/notifications/mark-all-read", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "mar_u1", withCredential: true, withSettings: true }); + await seedUser({ username: "mar_u2" }); + + await DB.insertInto("notification") + .values([ + { + title: "a", + sent_to: 1, + sent_at: new Date(2000).toISOString(), + read: true, + kind: "site_announcement", + payload: {}, + }, + { + title: "b", + sent_to: 1, + sent_at: new Date(3000).toISOString(), + read: false, + kind: "site_announcement", + payload: {}, + }, + { + title: "c", + sent_to: 1, + sent_at: new Date(4000).toISOString(), + read: false, + kind: "site_announcement", + payload: {}, + }, + { + title: "d", + sent_to: 2, + sent_at: new Date(5000).toISOString(), + read: false, + kind: "site_announcement", + payload: {}, + }, + ]) + .execute(); + + cookie = await loginAs("mar_u1"); + }); + + it("marks all of the user's notifications as read", async () => { + const res = await mockApi + .post("/api/v1/users/1/notifications/mark-all-read") + .set("Cookie", cookie); + + expect(res.status).toBe(200); + + const unread = await DB.selectFrom("notification") + .selectAll() + .where("notification.sent_to", "=", 1) + .where("notification.read", "=", false) + .executeTakeFirst(); + + expect(unread).toBeUndefined(); + }); + + it("returns 401 when not authenticated", async () => { + const res = await mockApi.post("/api/v1/users/1/notifications/mark-all-read"); + + expect(res.status).toBe(401); + }); + + it("returns 403 when authenticated as another user", async () => { + const res = await mockApi + .post("/api/v1/users/2/notifications/mark-all-read") + .set("Cookie", cookie); + + expect(res.status).toBe(403); + }); + + it("returns 403 when using an API key instead of a session cookie", async () => { + await seedApiToken({ token: "mar_tok", userId: 1, identifier: "x" }); + + const res = await mockApi + .post("/api/v1/users/1/notifications/mark-all-read") + .set("Authorization", "Bearer mar_tok"); + + expect(res.status).toBe(403); + }); +}); + +describe("POST /api/v1/users/:userID/notifications/delete-all", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "deln_u1", withCredential: true, withSettings: true }); + await seedUser({ username: "deln_u2" }); + + await DB.insertInto("notification") + .values([ + { + title: "a", + sent_to: 1, + sent_at: new Date(2000).toISOString(), + read: true, + kind: "site_announcement", + payload: {}, + }, + { + title: "b", + sent_to: 1, + sent_at: new Date(3000).toISOString(), + read: false, + kind: "site_announcement", + payload: {}, + }, + { + title: "c", + sent_to: 2, + sent_at: new Date(4000).toISOString(), + read: false, + kind: "site_announcement", + payload: {}, + }, + ]) + .execute(); + + cookie = await loginAs("deln_u1"); + }); + + it("empties the user's notification inbox", async () => { + const res = await mockApi + .post("/api/v1/users/1/notifications/delete-all") + .set("Cookie", cookie); + + expect(res.status).toBe(200); + + const left = await DB.selectFrom("notification") + .selectAll() + .where("notification.sent_to", "=", 1) + .executeTakeFirst(); + + expect(left).toBeUndefined(); + + const other = await DB.selectFrom("notification") + .selectAll() + .where("notification.sent_to", "=", 2) + .executeTakeFirst(); + + expect(other).toBeDefined(); + }); + + it("returns 401 when not authenticated", async () => { + const res = await mockApi.post("/api/v1/users/1/notifications/delete-all"); + + expect(res.status).toBe(401); + }); + + it("returns 403 when authenticated as another user", async () => { + const res = await mockApi + .post("/api/v1/users/2/notifications/delete-all") + .set("Cookie", cookie); + + expect(res.status).toBe(403); + }); + + it("returns 403 when using an API key instead of a session cookie", async () => { + await seedApiToken({ token: "deln_tok", userId: 1, identifier: "x" }); + + const res = await mockApi + .post("/api/v1/users/1/notifications/delete-all") + .set("Authorization", "Bearer deln_tok"); + + expect(res.status).toBe(403); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts index 9333eb0b4..56ec22fa8 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -1,91 +1,71 @@ -import { ACTION_DeleteAllNotifications } from "#actions/delete-all-notifications.js"; -import { ACTION_MarkAllNotificationsRead } from "#actions/mark-all-notifications-read.js"; +import { ACTION_DeleteAllNotifications } from "#actions/delete-all-notifications"; +import { ACTION_MarkAllNotificationsRead } from "#actions/mark-all-notifications-read"; import { SELECT_NOTIFICATION, ToNotificationDocument } from "#lib/db-formats/notification"; +import { withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; - -import { RequireSelfRequestFromUser } from "../middleware"; - -const router: Router = Router({ mergeParams: true }); - -// Notifications aren't really for anyone else to interact with. Only the requesting user -// should be able to see their notifications. -router.use(RequireSelfRequestFromUser); /** * Return all of this user's notifications, this is sorted on most recently sent first. * * @name GET /api/v1/users/:userID/notifications */ -router.get("/", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "GET /users/:userID/notifications", + withRequestedUser, + withSelf, + async ({ ctx }) => { + const rows = await DB.selectFrom("notification") + .select(SELECT_NOTIFICATION) + .where("notification.sent_to", "=", ctx.requestedUser.id) + .orderBy("notification.sent_at", "desc") + .execute(); - const rows = await DB.selectFrom("notification") - .select(SELECT_NOTIFICATION) - .where("notification.sent_to", "=", user.id) - .orderBy("notification.sent_at", "desc") - .execute(); + const notifs = rows.map(ToNotificationDocument); - const notifs = rows.map(ToNotificationDocument); - - return res.status(200).json({ - success: true, - description: `Found ${notifs.length} notifications.`, - body: notifs, - }); -}); + return success(`Found ${notifs.length} notifications.`, notifs); + }, +); /** * Mark all notifications in this user's inbox as read. * * @name POST /api/v1/users/:userID/notifications/mark-all-read */ -router.post("/mark-all-read", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "POST /users/:userID/notifications/mark-all-read", + withRequestedUser, + withSelf, + async ({ ctx, req }) => { + const { requestedUser: user } = ctx; - const { markedCount } = await ACTION_MarkAllNotificationsRead( - { - acct: { - id: user.id, - username: user.username, - }, - ip: req.ip, - }, - {}, - ); + const { markedCount } = await ACTION_MarkAllNotificationsRead( + { acct: { id: user.id, username: user.username }, ip: req.ip }, + {}, + ); - return res.status(200).json({ - success: true, - description: `Marked ${markedCount} notifications as read.`, - body: {}, - }); -}); + return success(`Marked ${markedCount} notifications as read.`, {}); + }, +); /** * Clear all notifications from your inbox. * * @name POST /api/v1/users/:userID/notifications/delete-all */ -router.post("/delete-all", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "POST /users/:userID/notifications/delete-all", + withRequestedUser, + withSelf, + async ({ ctx, req }) => { + const { requestedUser: user } = ctx; - const { deletedCount } = await ACTION_DeleteAllNotifications( - { - acct: { - id: user.id, - username: user.username, - }, - ip: req.ip, - }, - {}, - ); + const { deletedCount } = await ACTION_DeleteAllNotifications( + { acct: { id: user.id, username: user.username }, ip: req.ip }, + {}, + ); - return res.status(200).json({ - success: true, - description: `Deleted ${deletedCount} notification(s).`, - body: {}, - }); -}); - -export default router; + return success(`Deleted ${deletedCount} notification(s).`, {}); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.oldtest.ts deleted file mode 100644 index 91eb4ef08..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.oldtest.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; -import { GetProfilePictureURL } from "#lib/cdn/url-format"; -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { GetKTDataBuffer } from "#test-utils/test-data"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/pfp", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the default profile picture if user has no custom pfp", async (t) => { - await CDNStoreOrOverwrite("/users/default/pfp", "test"); - - // we have to follow redirs here lol - const res = await mockApi.get("/api/v1/users/1/pfp").redirects(1); - - t.equal(res.body.toString(), "test"); - - t.end(); - }); - - t.test("Should work with a .png extension", async (t) => { - await CDNStoreOrOverwrite("/users/default/pfp", "test"); - - // we have to follow redirs here lol - const res = await mockApi.get("/api/v1/users/1/pfp.png").redirects(2); - - t.equal(res.body.toString(), "test"); - - t.end(); - }); - - t.test("Should return a custom profile picture if one is set", async (t) => { - await CDNStoreOrOverwrite(GetProfilePictureURL(1, "checksum"), "foo"); - await MONGODB_KILL.users.update({ id: 1 }, { $set: { customPfpLocation: "checksum" } }); - const res = await mockApi.get("/api/v1/users/1/pfp").redirects(1); - - t.equal(res.body.toString(), "foo"); - - t.end(); - }); - - t.end(); -}); - -t.test("PUT /api/v1/users/:userID/pfp", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should set a profile picture if user has no custom pfp", async (t) => { - const img = GetKTDataBuffer("/images/acorn.png"); - - const res = await mockApi - .put("/api/v1/users/1/pfp") - .set("Authorization", "Bearer fake_api_token") - .attach("pfp", img, "file.jpg"); - - t.equal(res.statusCode, 200); - - const get = await mockApi.get(res.body.body.get).redirects(1); - - t.strictSame(img, get.body, "Profile picture should be stored."); - - t.end(); - }); - - t.test("Should set a profile picture if user has custom pfp", async (t) => { - await MONGODB_KILL.users.update({ id: 1 }, { $set: { customPfpLocation: "checksum" } }); - - const img = GetKTDataBuffer("/images/acorn.png"); - - const res = await mockApi - .put("/api/v1/users/1/pfp") - .set("Authorization", "Bearer fake_api_token") - .attach("pfp", img, "file.png"); - - t.equal(res.statusCode, 200); - - const get = await mockApi.get(res.body.body.get).redirects(1); - - t.strictSame(img, get.body, "Profile picture should be stored."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts new file mode 100644 index 000000000..375570d2e --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts @@ -0,0 +1,82 @@ +import { seedApiToken } from "#actions/test-utils/api-tokens"; +import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { GetProfilePictureURL } from "#lib/cdn/url-format"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { GetKTDataBuffer } from "#test-utils/test-data"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +describe("GET /api/v1/users/:userID/pfp", () => { + beforeEach(async () => { + await seedUser({ username: "pfp_get_user" }); + }); + + it("returns the default profile picture if user has no custom pfp", async () => { + await CDNStoreOrOverwrite("/users/default/pfp", "test"); + + const res = await mockApi.get("/api/v1/users/1/pfp").redirects(1); + + expect(res.body.toString()).toBe("test"); + }); + + it("returns a custom profile picture when one is set", async () => { + await CDNStoreOrOverwrite(GetProfilePictureURL(1, "checksum"), "foo"); + await DB.updateTable("account") + .set({ custom_pfp_location: "checksum" }) + .where("account.id", "=", 1) + .execute(); + + const res = await mockApi.get("/api/v1/users/1/pfp").redirects(1); + + expect(res.body.toString()).toBe("foo"); + }); +}); + +describe("PUT /api/v1/users/:userID/pfp", () => { + beforeEach(async () => { + await seedUser({ username: "pfp_put_user" }); + await seedApiToken({ token: "fake_api_token", userId: 1, identifier: "pfp_tok" }); + await DB.updateTable("priv_api_token") + .set({ pm_customise_profile: true }) + .where("priv_api_token.token", "=", "fake_api_token") + .execute(); + }); + + it("stores a profile picture when the user had no custom pfp", async () => { + const img = GetKTDataBuffer("/images/acorn.png"); + + const res = await mockApi + .put("/api/v1/users/1/pfp") + .set("Authorization", "Bearer fake_api_token") + .attach("pfp", img, "file.jpg"); + + expect(res.status).toBe(200); + + const get = await mockApi.get(res.body.body.get).redirects(1); + + expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toStrictEqual(img); + }); + + it("stores a profile picture when the user already had a custom pfp", async () => { + await DB.updateTable("account") + .set({ custom_pfp_location: "checksum" }) + .where("account.id", "=", 1) + .execute(); + + const img = GetKTDataBuffer("/images/acorn.png"); + + const res = await mockApi + .put("/api/v1/users/1/pfp") + .set("Authorization", "Bearer fake_api_token") + .attach("pfp", img, "file.png"); + + expect(res.status).toBe(200); + + const get = await mockApi.get(res.body.body.get).redirects(1); + + expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toStrictEqual(img); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts index 0b4405e2f..1ad2f9552 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts @@ -1,17 +1,17 @@ -import { ACTION_ChangePfp } from "#actions/change-pfp.js"; -import { ACTION_DeletePfp } from "#actions/delete-pfp.js"; +import { ACTION_ChangePfp } from "#actions/change-pfp"; +import { ACTION_DeletePfp } from "#actions/delete-pfp"; import { CDNRedirect } from "#lib/cdn/cdn"; import { GetProfilePictureURL } from "#lib/cdn/url-format"; import { ONE_MEGABYTE } from "#lib/constants/filesize"; import { log } from "#lib/log/log"; +import { withAuthedAsUser, withPermission, withRequestedUser } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; import { RequirePermissions } from "#server/middleware/auth"; import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload"; -import { GetTachiData } from "#utils/req-tachi-data"; -import { Router } from "express"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import { REQ_GetTachiData } from "#utils/req-tachi-data"; -import { RequireAuthedAsUser } from "../middleware"; - -const router: Router = Router({ mergeParams: true }); +import { GetUserFromParam, RequireAuthedAsUser } from "../middleware"; /** * Sets a profile picture. @@ -22,13 +22,15 @@ const router: Router = Router({ mergeParams: true }); * * @name PUT /api/v1/users/:userID/pfp */ -router.put( - "/", +API_V1_ROUTER.rawAdd( + "PUT", + "/users/:userID/pfp", + GetUserFromParam, RequireAuthedAsUser, RequirePermissions("customise_profile"), CreateMulterSingleUploadMiddleware("pfp", ONE_MEGABYTE), async (req, res) => { - const user = GetTachiData(req, "requestedUser"); + const user = REQ_GetTachiData(req, "requestedUser"); if (!req.file) { return res.status(400).json({ @@ -71,18 +73,19 @@ router.put( * * @name GET /api/v1/users/:userID/pfp */ -router.get("/", (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add("GET /users/:userID/pfp", withRequestedUser, ({ ctx, res }) => { + const { requestedUser: user } = ctx; log.debug(user, "User Info for /:userID/pfp request is "); if (!user.customPfpLocation) { res.setHeader("Content-Type", "image/png"); CDNRedirect(res, "/users/default/pfp"); - return; + return success("Redirected to default profile picture.", {}); } CDNRedirect(res, GetProfilePictureURL(user.id, user.customPfpLocation)); + return success("Redirected to profile picture.", {}); }); /** @@ -90,12 +93,13 @@ router.get("/", (req, res) => { * * @name DELETE /api/v1/users/:userID/pfp */ -router.delete( - "/", - RequireAuthedAsUser, - RequirePermissions("customise_profile"), - async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add( + "DELETE /users/:userID/pfp", + withRequestedUser, + withAuthedAsUser, + withPermission("customise_profile"), + async ({ ctx, req }) => { + const { requestedUser: user } = ctx; await ACTION_DeletePfp( { @@ -112,12 +116,6 @@ router.delete( req.session.tachi.user.customPfpLocation = null; } - return res.status(200).json({ - success: true, - description: `Removed custom profile picture.`, - body: {}, - }); + return success("Removed custom profile picture.", {}); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/router.oldtest.ts deleted file mode 100644 index da687af62..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/router.oldtest.ts +++ /dev/null @@ -1,481 +0,0 @@ -import type { GameGroup, ImportDocument, ImportTypes, UserGameStats } from "tachi-common"; - -import { ONE_DAY, ONE_YEAR } from "#lib/constants/time"; -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { Random20Hex } from "#utils/misc"; -import t from "tap"; - -import { PasswordCompare } from "../../auth/auth"; - -t.test("GET /api/v1/users/:userID", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should retrieve a user on their ID.", async (t) => { - const res = await mockApi.get("/api/v1/users/1"); - - t.hasStrict(res.body, { - success: true, - description: "Found user test_zkldi.", - body: { - id: 1, - username: "test_zkldi", - }, - }); - - t.end(); - }); - - t.test("Should retrieve a user on their name.", async (t) => { - const res = await mockApi.get("/api/v1/users/test_zkldi"); - - t.hasStrict(res.body, { - success: true, - description: "Found user test_zkldi.", - body: { - id: 1, - username: "test_zkldi", - }, - }); - - t.end(); - }); - - t.test("Should retrieve a user on their name case insensitively.", async (t) => { - const res = await mockApi.get("/api/v1/users/TeSt_ZklDI"); - - t.hasStrict(res.body, { - success: true, - description: "Found user test_zkldi.", - body: { - id: 1, - username: "test_zkldi", - }, - }); - - t.end(); - }); - - t.test("Should return 404 on users that don't exist.", async (t) => { - const res = await mockApi.get("/api/v1/users/someguy"); - - t.hasStrict(res.body, { - success: false, - description: "The user someguy does not exist.", - }); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("PATCH /api/v1/users/:userID", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should require authentication", async (t) => { - const res = await mockApi.patch("/api/v1/users/1"); - - t.equal(res.statusCode, 401); - - // needs self key auth - await MONGODB_KILL["api-tokens"].insert({ - token: "noperm", - permissions: {}, - identifier: "No permissions token", - userID: 1, - fromAPIClient: null, - }); - - const res2 = await mockApi.patch("/api/v1/users/1").set("Authorization", "Bearer noperm"); - - t.equal(res2.statusCode, 403); - - t.end(); - }); - - t.test("Should reject empty updates.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should update the user doc.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - status: "Hello World!", - }); - - t.equal(res.body.body.status, "Hello World!"); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.status, "Hello World!"); - - t.end(); - }); - - t.test("Should allow empty strings for about me.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - about: "", - }); - - t.equal(res.statusCode, 200); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.about, ""); - - t.end(); - }); - - t.test("Shouldn't allow about me to be set to null.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - about: null, - }); - - t.equal(res.statusCode, 400); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.about, "test_user_not_real"); - - t.end(); - }); - - t.test("Shouldn't alter other properties.", async (t) => { - await MONGODB_KILL.users.update({ id: 1 }, { $set: { "socialMedia.discord": "foo#123" } }); - - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - status: "Hello World!", - }); - - t.equal(res.body.body.status, "Hello World!"); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.status, "Hello World!"); - t.equal(dbUser?.about, "test_user_not_real"); - t.equal(dbUser?.socialMedia.discord, "foo#123"); - - t.end(); - }); - - t.test("Should correctly strip twitter urls.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - twitter: "https://twitter.com/zkldi", - }); - - t.equal(res.body.body.socialMedia.twitter, "zkldi"); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.socialMedia.twitter, "zkldi"); - - t.end(); - }); - - t.test("Should correctly strip youtube urls.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - youtube: "https://youtube.com/user/zkldi", - }); - - t.equal(res.body.body.socialMedia.youtube, "zkldi"); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.socialMedia.youtube, "zkldi"); - - const res2 = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - youtube: "https://youtube.com/channel/zkldi", - }); - - t.equal(res2.body.body.socialMedia.youtube, "zkldi"); - - const dbUser2 = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser2?.socialMedia.youtube, "zkldi"); - - const res3 = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - youtube: "https://youtube.com/@zkldi", - }); - - t.equal(res3.body.body.socialMedia.youtube, "zkldi"); - - const dbUser3 = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser3?.socialMedia.youtube, "zkldi"); - - t.end(); - }); - - t.test("Should correctly strip github urls.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - github: "https://github.com/zkldi", - }); - - t.equal(res.body.body.socialMedia.github, "zkldi"); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.socialMedia.github, "zkldi"); - - t.end(); - }); - - t.test("Should correctly strip twitch urls.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - twitch: "https://twitch.tv/zkldi", - }); - - t.equal(res.body.body.socialMedia.twitch, "zkldi"); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.socialMedia.twitch, "zkldi"); - - t.end(); - }); - - t.test("Should correctly strip steam urls.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1").set("Cookie", cookie).send({ - steam: "https://steamcommunity.com/id/zkldi", - }); - - t.equal(res.body.body.socialMedia.steam, "zkldi"); - - const dbUser = await MONGODB_KILL.users.findOne({ id: 1 }); - - t.equal(dbUser?.socialMedia.steam, "zkldi"); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /api/v1/users/:userID/change-password", async (t) => { - t.beforeEach(ResetDBState); - const cookie = await CreateFakeAuthCookie(mockApi); - - function send(content: Record) { - return mockApi.post("/api/v1/users/1/change-password").set("Cookie", cookie).send(content); - } - - t.test("Must require !password and !oldPassword to be a valid password.", async (t) => { - const res = await send({ - "!password": null, - "!oldPassword": "heres_my_old_password", - }); - - t.equal(res.statusCode, 400); - t.match( - res.body.description, - /!password/gu, - "Should be related to the invalid password field.", - ); - - const res2 = await send({ - "!password": "heres_my_new_password", - "!oldPassword": "short", - }); - - t.equal(res2.statusCode, 400); - t.match( - res2.body.description, - /!oldPassword/gu, - "Should be related to the invalid !oldPassword field.", - ); - - t.end(); - }); - - t.test("Must require !oldPassword to match.", async (t) => { - const res = await send({ - "!password": "new_password", - "!oldPassword": "NOT_MY_PASSWORD", - }); - - t.equal(res.statusCode, 401); - t.match(res.body.description, /old password doesn't match/iu); - - t.end(); - }); - - t.test("Should update password if everything checks out.", async (t) => { - const res = await send({ - "!password": "NEW_PASSWORD", - "!oldPassword": "password", - }); - - t.equal(res.statusCode, 200); - - const newPrivateInfo = await MONGODB_KILL["user-private-information"].findOne({ - userID: 1, - }); - - t.not(newPrivateInfo, null, "newPrivateInfo should not be null."); - - t.ok( - PasswordCompare("NEW_PASSWORD", newPrivateInfo!.password), - "Should update the users password to the hash of NEW_PASSWORD.", - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/game-stats", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return all of a user's game stats.", async (t) => { - await MONGODB_KILL["game-stats"].remove({}); - - const stats: Array = [ - { - userID: 1, - game: "iidx", - playtype: "SP", - classes: {}, - ratings: { - ktLampRating: 12, - }, - }, - { - userID: 1, - game: "iidx", - playtype: "DP", - classes: {}, - ratings: { - ktLampRating: 11, - }, - }, - { - userID: 1, - game: "gitadora", - playtype: "Dora", - classes: {}, - ratings: { - naiveSkill: 4843, - }, - }, - ]; - - await MONGODB_KILL["game-stats"].insert(stats); - - const res = await mockApi.get("/api/v1/users/test_zkldi/game-stats"); - - t.hasStrict(res.body, { - success: true, - description: "Returned 3 stats objects.", - }); - - const returns = res.body.body.map((e: UserGameStats) => `${e.game}-${e.playtype}`); - - // amusing small hacks - t.equal(returns.length, 3); - t.ok(returns.includes("iidx-SP")); - t.ok(returns.includes("iidx-DP")); - t.ok(returns.includes("gitadora-Dora")); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/recent-imports", (t) => { - t.beforeEach(ResetDBState); - - function mkImport( - game: GameGroup, - importType: ImportTypes, - timeFinished: number, - userIntent: boolean, - ): ImportDocument { - return { - classDeltas: [], - createdSessions: [], - errors: [], - game, - importType, - importID: Random20Hex(), - goalInfo: [], - gptStrings: [], - questInfo: [], - playtypes: [], - scoreIDs: [], - timeFinished, - timeStarted: 0, - userID: 1, - userIntent, - }; - } - - t.test("Should work cross game and ignore imports without userIntent.", async (t) => { - await MONGODB_KILL.imports.insert([ - mkImport("iidx", "api/flo-iidx", Date.now() - ONE_DAY, true), - mkImport("iidx", "api/flo-iidx", Date.now() - ONE_DAY, true), - mkImport("sdvx", "api/flo-sdvx", Date.now() - ONE_DAY, false), - mkImport("iidx", "api/flo-iidx", Date.now() - ONE_DAY, false), - mkImport("sdvx", "api/flo-sdvx", Date.now() - ONE_DAY, true), - ]); - - const res = await mockApi.get("/api/v1/users/1/recent-imports"); - - t.equal(res.statusCode, 200); - - t.strictSame(res.body.body, [ - { - importType: "api/flo-iidx", - count: 2, - }, - { - importType: "api/flo-sdvx", - count: 1, - }, - ]); - - t.end(); - }); - - t.test("Should ignore imports that were too long ago.", async (t) => { - await MONGODB_KILL.imports.insert([ - mkImport("iidx", "api/flo-iidx", Date.now() - ONE_DAY, true), - mkImport("iidx", "ir/fervidex", Date.now() - ONE_YEAR, true), - mkImport("iidx", "ir/fervidex", Date.now() - ONE_YEAR, true), - mkImport("iidx", "ir/fervidex", Date.now() - ONE_YEAR, true), - mkImport("iidx", "ir/fervidex", Date.now() - ONE_YEAR, true), - mkImport("sdvx", "api/flo-sdvx", Date.now() - ONE_DAY, true), - mkImport("sdvx", "api/flo-sdvx", Date.now() - ONE_DAY, true), - ]); - - const res = await mockApi.get("/api/v1/users/1/recent-imports"); - - t.equal(res.statusCode, 200); - - t.strictSame(res.body.body, [ - { - importType: "api/flo-sdvx", - count: 2, - }, - { - importType: "api/flo-iidx", - count: 1, - }, - ]); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/router.ts index 4eec21955..c8e9bc16f 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/router.ts @@ -1,24 +1,22 @@ -import type { AnyProfileRatingAlg, GPTString, integer, MONGO_UserGameStats } from "tachi-common"; - -import { ACTION_ChangeEmail } from "#actions/change-email.js"; -import { ACTION_ChangePassword } from "#actions/change-password.js"; -import { ACTION_ChangeUsername } from "#actions/change-username.js"; -import { ACTION_UpdateUser } from "#actions/update-user.js"; +import { ACTION_ChangeEmail } from "#actions/change-email"; +import { ACTION_ChangePassword } from "#actions/change-password"; +import { ACTION_ChangeUsername } from "#actions/change-username"; +import { ACTION_UpdateUser } from "#actions/update-user"; import { GetRecentActivity } from "#lib/activity/activity"; import { ONE_MONTH } from "#lib/constants/time"; -import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js"; +import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles"; import { log } from "#lib/log/log"; import { GetRivalIDs } from "#lib/rivals/rivals"; -import prValidate from "#server/middleware/prudence-validate"; -import DB from "#services/pg/db.js"; -import { optNullFluffStrField } from "#utils/prudence"; +import { withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import DB from "#services/pg/db"; import { GetGoalSummary, GetRecentlyViewedFoldersAnyGPT, GetRecentPlaycount, GetRecentSessions, } from "#utils/queries/summary"; -import { GetUser } from "#utils/req-tachi-data"; import { CanChangeUsername, FormatUserDoc, @@ -27,52 +25,22 @@ import { GetSettingsForUser, GetUserWithIDGuaranteed, } from "#utils/user"; -import { Router } from "express"; -import { p } from "prudence"; - -import { ValidateEmail, ValidatePassword } from "../../../../../../lib/auth/auth"; -import apiTokensRouter from "./api-tokens/router"; -import bannerRouter from "./banner/router"; -import followingRouter from "./following/router"; -import gameSpecificRouter from "./games/@gameSpecificRoutes/router"; -import gamePTRouter from "./games/_game/_playtype/router"; -import importsRouter from "./imports/router"; -import integrationsRouter from "./integrations/router"; -import invitesRouter from "./invites/router"; -import { GetUserFromParam, RequireSelfRequestFromUser } from "./middleware"; -import notifsRouter from "./notifications/router"; -import pfpRouter from "./pfp/router"; -import sessionsRouter from "./sessions/router"; -import settingsRouter from "./settings/router"; - -const router: Router = Router({ mergeParams: true }); - -router.use(GetUserFromParam); +import { ExpectedErr } from "bliss"; +import { + type AnyProfileRatingAlg, + type integer, + type UserGameStats, + type V3Game, +} from "tachi-common"; /** * Get the user at this ID or name. + * * @name GET /api/v1/users/:userID */ -router.get("/", (req, res) => { - const user = GetUser(req); - - return res.status(200).json({ - success: true, - description: `Found user ${user.username}.`, - body: user, - }); -}); - -interface UserPatchBody { - about?: string; - status?: string | null; - discord?: string | null; - twitter?: string | null; - twitch?: string | null; - youtube?: string | null; - github?: string | null; - steam?: string | null; -} +API_V1_ROUTER.add("GET /users/:userID", withRequestedUser, ({ ctx }) => + success(`Found user ${ctx.requestedUser.username}.`, ctx.requestedUser), +); /** * Modify this user document. All parameters are optional. @@ -88,38 +56,25 @@ interface UserPatchBody { * * @name PATCH /api/v1/users/:userID */ -router.patch( - "/", - RequireSelfRequestFromUser, - prValidate( - { - about: p.optional(p.isBoundedString(0, 2000)), - status: optNullFluffStrField, - discord: optNullFluffStrField, - twitter: optNullFluffStrField, - github: optNullFluffStrField, - steam: optNullFluffStrField, - youtube: optNullFluffStrField, - twitch: optNullFluffStrField, - }, - { - about: "Your about me is too long.", - }, - ), - async (req, res) => { - const user = GetUser(req); - - const body = req.safeBody as UserPatchBody; +API_V1_ROUTER.add( + "PATCH /users/:userID", + withRequestedUser, + withSelf, + async ({ input, ctx, req }) => { + const { requestedUser: user } = ctx; await ACTION_UpdateUser( + { acct: { id: user.id, username: user.username }, ip: req.ip }, { - acct: { - id: user.id, - username: user.username, - }, - ip: req.ip, + about: input.about, + discord: input.discord, + github: input.github, + status: input.status, + steam: input.steam, + twitch: input.twitch, + twitter: input.twitter, + youtube: input.youtube, }, - body, ); const newUser = await GetUserWithIDGuaranteed(user.id); @@ -129,11 +84,7 @@ router.patch( req.session.save(); } - return res.status(200).json({ - success: true, - description: `Successfully updated user.`, - body: newUser, - }); + return success("Successfully updated user.", newUser); }, ); @@ -145,33 +96,27 @@ router.patch( * * @name GET /api/v1/users/:userID/game-stats */ -router.get("/game-stats", async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add("GET /users/:userID/game-stats", withRequestedUser, async ({ ctx }) => { + const { requestedUser: user } = ctx; // a user has played a game if and only if they have stats for it. const stats: Array< { __rankingData?: Record; - } & MONGO_UserGameStats + } & UserGameStats > = await DB.selectFrom("game_profile") .select(SELECT_GAME_PROFILE) - .where("user_id", "=", user.id) + .where("game_profile.user_id", "=", user.id) .execute() - .then((res) => res.map(ToGameStatsDocument)); + .then((rows) => rows.map(ToGameStatsDocument)); await Promise.all( stats.map(async (s) => { - const data = await GetAllRankings(s); - - s.__rankingData = data; + s.__rankingData = await GetAllRankings(s); }), ); - return res.status(200).json({ - success: true, - description: `Returned ${stats.length} stats objects.`, - body: stats, - }); + return success(`Returned ${stats.length} stats objects.`, stats); }); /** @@ -181,8 +126,8 @@ router.get("/game-stats", async (req, res) => { * * @name GET /api/v1/users/:userID/recent-summary */ -router.get("/recent-summary", async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add("GET /users/:userID/recent-summary", withRequestedUser, async ({ ctx }) => { + const { requestedUser: user } = ctx; const [ recentPlaycount, @@ -196,18 +141,14 @@ router.get("/recent-summary", async (req, res) => { GetGoalSummary(user.id), ]); - return res.status(200).json({ - success: true, - description: `Retrieved information about ${FormatUserDoc(user)}.`, - body: { - recentPlaycount, - recentSessions, - recentFolders, - recentFolderStats, - recentAchievedGoals: achievedGoals, - recentGoals: goals, - recentImprovedGoals: improvedGoals, - }, + return success(`Retrieved information about ${FormatUserDoc(user)}.`, { + recentAchievedGoals: achievedGoals, + recentFolderStats, + recentFolders, + recentGoals: goals, + recentImprovedGoals: improvedGoals, + recentPlaycount, + recentSessions, }); }); @@ -217,96 +158,65 @@ router.get("/recent-summary", async (req, res) => { * * @name GET /api/v1/users/:userID/is-email-verified */ -router.get("/is-email-verified", RequireSelfRequestFromUser, async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add( + "GET /users/:userID/is-email-verified", + withRequestedUser, + withSelf, + async ({ ctx }) => { + const { requestedUser: user } = ctx; - const verifyInfo = await DB.selectFrom("priv_verify_email_token") - .where("user_id", "=", user.id) - .executeTakeFirst(); + const verifyInfo = await DB.selectFrom("priv_verify_email_token") + .where("priv_verify_email_token.user_id", "=", user.id) + .executeTakeFirst(); - if (verifyInfo) { - return res.status(200).json({ - success: true, - description: `User has not verified email.`, - body: false, - }); - } + const verified = !verifyInfo; - return res.status(200).json({ - success: true, - description: `User has verified email.`, - body: true, - }); -}); + return success( + verified ? "User has verified email." : "User has not verified email.", + verified, + ); + }, +); /** * Get what email this user signed up with. * * @name GET /api/v1/users/:userID/email */ -router.get("/email", RequireSelfRequestFromUser, async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add("GET /users/:userID/email", withRequestedUser, withSelf, async ({ ctx }) => { + const { requestedUser: user } = ctx; - const email = await DB.selectFrom("priv_account_credential") - .select("email") - .where("user_id", "=", user.id) - .executeTakeFirstOrThrow(); + const row = await DB.selectFrom("priv_account_credential") + .select("priv_account_credential.email") + .where("priv_account_credential.user_id", "=", user.id) + .executeTakeFirst(); - if (email) { - return res.status(200).json({ - success: true, - description: `User signed up with this email.`, - body: email.email, - }); + if (!row) { + log.error(`User ${user.id} doesn't have private info?`); + throw new ExpectedErr(500, "Internal Server Error."); } - log.error(`User ${user.id} doesn't have private info?`); - - return res.status(500).json({ - success: false, - description: `Internal Server Error`, - }); + return success("User signed up with this email.", row.email); }); /** * Change what email is associated with this account. * - * @name GET /api/v1/users/:userID/email + * @name POST /api/v1/users/:userID/change-email */ -router.post( - "/change-email", - RequireSelfRequestFromUser, - prValidate({ - email: ValidateEmail, - "!password": ValidatePassword, - }), - async (req, res) => { - const user = GetUser(req); - - const body = req.safeBody as { - "!password": string; - email: string; - }; +API_V1_ROUTER.add( + "POST /users/:userID/change-email", + withRequestedUser, + withSelf, + async ({ input, ctx, req }) => { + const { requestedUser: user } = ctx; await ACTION_ChangeEmail( - { - acct: { - id: user.id, - username: user.username, - }, - ip: req.ip, - }, - { - email: body.email, - "!password": body["!password"], - }, + { acct: { id: user.id, username: user.username }, ip: req.ip }, + { "!password": input["!password"], email: input.email }, ); - return res.status(200).json({ - success: true, - description: `Re-sent email verification to new email`, - body: null, - }); + return success("Re-sent email verification to new email.", null); }, ); @@ -319,53 +229,25 @@ router.post( * * @name POST /api/v1/users/:userID/change-password */ -router.post( - "/change-password", - RequireSelfRequestFromUser, - prValidate({ - "!password": ValidatePassword, - "!oldPassword": ValidatePassword, - }), - async (req, res) => { - const body = req.safeBody as { - "!oldPassword": string; - "!password": string; - }; - +API_V1_ROUTER.add( + "POST /users/:userID/change-password", + withRequestedUser, + withSelf, + async ({ input, req }) => { const user = req.session.tachi?.user; /* istanbul ignore next */ if (!user) { - log.error( - `IP ${req.ip} got to /change-password without a user, but passed RequireSelfRequest?`, - ); - - // this should be a 500, but lie to them. - return res.status(403).json({ - success: false, - description: `You are not authorised to perform this action.`, - }); + log.error(`IP ${req.ip} got to /change-password without a user.`); + throw new ExpectedErr(403, "You are not authorised to perform this action."); } await ACTION_ChangePassword( - { - acct: { - id: user.id, - username: user.username, - }, - ip: req.ip, - }, - { - "!oldPassword": body["!oldPassword"], - "!password": body["!password"], - }, + { acct: { id: user.id, username: user.username }, ip: req.ip }, + { "!oldPassword": input["!oldPassword"], "!password": input["!password"] }, ); - return res.status(200).json({ - success: true, - description: `Updated Password.`, - body: {}, - }); + return success("Updated Password.", {}); }, ); @@ -378,68 +260,26 @@ router.post( * * @name POST /api/v1/users/:userID/change-username */ -router.post( - "/change-username", - RequireSelfRequestFromUser, - prValidate( - { - "!password": ValidatePassword, - newUsername: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), - }, - { - newUsername: - "Username must be between 3 and 20 characters long, can only contain alphanumeric characters and cannot start with a number.", - "!password": "Invalid password.", - }, - ), - async (req, res) => { - const body = req.safeBody as { - "!password": string; - newUsername: string; - }; - - const user = req.session.tachi?.user; - - /* istanbul ignore next */ - if (!user) { - log.error( - `IP ${req.ip} got to /change-username without a user, but passed RequireSelfRequest?`, - ); - - // this should be a 500, but lie to them. - return res.status(403).json({ - success: false, - description: `You are not authorised to perform this action.`, - }); - } +API_V1_ROUTER.add( + "POST /users/:userID/change-username", + withRequestedUser, + withSelf, + async ({ input, ctx, req }) => { + const { requestedUser: user } = ctx; await ACTION_ChangeUsername( - { - acct: { - id: user.id, - username: user.username, - }, - ip: req.ip, - }, - { - newUsername: body.newUsername, - "!password": body["!password"], - }, + { acct: { id: user.id, username: user.username }, ip: req.ip }, + { "!password": input["!password"], newUsername: input.newUsername }, ); + const newUser = await GetUserWithIDGuaranteed(user.id); + if (req.session.tachi?.user) { - req.session.tachi.user = { - ...user, - username: body.newUsername, - }; + req.session.tachi.user = newUser; req.session.save(); } - return res.status(200).json({ - success: true, - description: `Updated your username!`, - body: {}, - }); + return success("Changed username.", newUser); }, ); @@ -449,62 +289,49 @@ router.post( * * @name GET /api/v1/users/:userID/last-username-change */ -router.get("/last-username-change", RequireSelfRequestFromUser, async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add( + "GET /users/:userID/last-username-change", + withRequestedUser, + withSelf, + async ({ ctx }) => { + const { requestedUser: user } = ctx; - const nextAvailableChange = await GetNextAvailableUsernameChange(DB, user.id); + const canChange = await CanChangeUsername(DB, user.id); + const nextChange = canChange ? null : await GetNextAvailableUsernameChange(DB, user.id); - const canChange = await CanChangeUsername(DB, user.id); + return success("Returned username change eligibility.", { canChange, nextChange }); + }, +); - let body; - - if (canChange) { - body = { - canChange: true, - }; - } else { - body = { - canChange: false, - nextAvailableChange, - }; - } - - return res.status(200).json({ - success: true, - description: `User can ${canChange ? "change" : "not change"} their username.`, - body, - }); -}); /** * Get the recent import types this user has used. * * @name GET /api/v1/users/:userID/recent-imports */ -router.get("/recent-imports", async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add("GET /users/:userID/recent-imports", withRequestedUser, async ({ ctx }) => { + const { requestedUser: user } = ctx; + + const oneMonthAgo = new Date(Date.now() - ONE_MONTH).toISOString(); const rows = await DB.selectFrom("import") - .select(["import_type", (eb) => eb.fn.countAll().as("count")]) - .where("user_id", "=", user.id) - .where("time_finished", ">", new Date(Date.now() - ONE_MONTH).toISOString()) - .where("user_intent", "=", true) - .where("import_type", "not in", [ + .select(["import.import_type as importType", DB.fn.count("import.id").as("count")]) + .where("import.user_id", "=", user.id) + .where("import.user_intent", "=", true) + .where("import.time_finished", ">=", oneMonthAgo) + .where("import.import_type", "not in", [ "file/mypagescraper-records-csv", "file/mypagescraper-player-csv", ]) - .groupBy("import_type") + .groupBy("import.import_type") + .orderBy("count", "desc") .execute(); - const imports = rows.map((row) => ({ - importType: row.import_type, - count: Number(row.count), + const body = rows.map((r) => ({ + importType: r.importType, + count: Number(r.count), })); - return res.status(200).json({ - success: true, - description: `Found ${imports.length} imports.`, - body: imports.sort((a, b) => b.count - a.count), - }); + return success("Returned recent import types.", body); }); /** @@ -512,29 +339,25 @@ router.get("/recent-imports", async (req, res) => { * * @name GET /api/v1/users/:userID/stats */ -router.get("/stats", async (req, res) => { - const user = GetUser(req); +API_V1_ROUTER.add("GET /users/:userID/stats", withRequestedUser, async ({ ctx }) => { + const { requestedUser: user } = ctx; const [scoreCount, sessionCount] = await Promise.all([ DB.selectFrom("score") - .select((eb) => eb.fn.countAll().as("count")) - .where("user_id", "=", user.id) + .select(DB.fn.countAll().as("count")) + .where("score.user_id", "=", user.id) .executeTakeFirstOrThrow() .then((r) => Number(r.count)), DB.selectFrom("session") - .select((eb) => eb.fn.countAll().as("count")) - .where("user_id", "=", user.id) + .select(DB.fn.countAll().as("count")) + .where("session.user_id", "=", user.id) .executeTakeFirstOrThrow() .then((r) => Number(r.count)), ]); - return res.status(200).json({ - success: true, - description: `Retrieved stats.`, - body: { - scores: scoreCount, - sessions: sessionCount, - }, + return success("Returned user stats.", { + scores: scoreCount, + sessions: sessionCount, }); }); @@ -543,101 +366,48 @@ router.get("/stats", async (req, res) => { * * @name GET /api/v1/users/:userID/activity */ -router.get( - "/activity", - prValidate({ - startTime: "*string", - includeRivals: p.optional(p.isIn("true", "false")), - includeFollowers: p.optional(p.isIn("true", "false")), - }), - async (req, res) => { - const qStartTime = req.query.startTime as string | undefined; +API_V1_ROUTER.add("GET /users/:userID/activity", withRequestedUser, async ({ input, ctx }) => { + const { requestedUser: user } = ctx; - const includeRivals = req.query.includeRivals === "true"; - const includeFollowers = req.query.includeFollowers === "true"; + const startTime = input.startTime ?? null; + const includeRivals = input.includeRivals === "true"; + const includeFollowers = input.includeFollowers === "true"; - const startTime = qStartTime ? Number(qStartTime) : null; + const gameProfileRows = await DB.selectFrom("game_profile") + .select(SELECT_GAME_PROFILE) + .where("game_profile.user_id", "=", user.id) + .execute(); - if (Number.isNaN(startTime)) { - return res.status(400).json({ - success: false, - description: `Invalid startTime, got a non number.`, - }); - } + const gameStats = gameProfileRows.map(ToGameStatsDocument); + const data: Partial> = {}; + const settings = await GetSettingsForUser(user.id); - const user = GetUser(req); + await Promise.all( + gameStats.map(async (e) => { + const userIDs: Array = [user.id]; - const gameProfileRows = await DB.selectFrom("game_profile") - .select(SELECT_GAME_PROFILE) - .where("user_id", "=", user.id) - .execute(); + if (includeRivals) { + const rivalIDs = await GetRivalIDs(user.id, e.game); - const gpts = gameProfileRows.map(ToGameStatsDocument); + userIDs.push(...rivalIDs); + } - const data: Partial> = {}; + // n.b. it is intentional behaviour that you only get updates for + // people you follow on games you play. + if (includeFollowers) { + userIDs.push(...settings.following); + } - const settings = await GetSettingsForUser(user.id); + const activity = await GetRecentActivity( + e.game, + { userID: { $in: userIDs } }, + 30, + startTime, + ); - await Promise.all( - gpts.map(async (e) => { - const userIDs: Array = [user.id]; + data[e.game] = activity; + }), + ); - if (includeRivals) { - const rivalIDs = await GetRivalIDs(user.id, e.game, e.playtype); - - userIDs.push(...rivalIDs); - } - - // n.b. it is intentional behaviour that you only get updates for - // people you follow on games you play. - if (includeFollowers) { - userIDs.push(...settings.following); - } - - const activity = await GetRecentActivity( - e.game, - { - game: e.game, - playtype: e.playtype, - userID: { $in: userIDs }, - }, - 30, - startTime, - ); - - data[`${e.game}:${e.playtype}` as GPTString] = activity; - }), - ); - - return res.status(200).json({ - success: true, - description: `Returned recent activity.`, - body: data, - }); - }, -); - -router.use("/games", gameSpecificRouter); -router.use("/games/:game/:playtype", gamePTRouter); -router.use("/pfp", pfpRouter); - -router.use("/banner", bannerRouter); -router.use("/integrations", integrationsRouter); -router.use("/settings", settingsRouter); -router.use("/api-tokens", apiTokensRouter); -router.use("/invites", invitesRouter); -router.use("/imports", importsRouter); -router.use("/notifications", notifsRouter); -router.use("/following", followingRouter); -router.use("/sessions", sessionsRouter); - -// Shims for discord functionality; discord checks that a url ends with ".png" -// to use as an image -router.get("/pfp.png", (_req, res) => { - res.redirect("./pfp"); + return success("Returned recent activity.", data); }); -router.get("/banner.png", (_req, res) => { - res.redirect("./banner"); -}); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/sessions/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/sessions/router.ts index 7084a381d..512ec2153 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/sessions/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/sessions/router.ts @@ -1,32 +1,23 @@ import { SELECT_SESSION_CALENDAR, ToSessionCalendarDocument } from "#lib/db-formats/session"; +import { withRequestedUser } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import DB from "#services/pg/db"; -import { GetUser } from "#utils/req-tachi-data"; -import { Router } from "express"; - -const router: Router = Router({ mergeParams: true }); /** * Returns all sessions, FOR ALL GPTs - * but with unecessary properties removed so as to reduce + * but with unnecessary properties removed so as to reduce * bandwidth. This is used for the calendar view in tachi-client, hence the name. * * @name GET /api/v1/users/:userID/sessions/calendar */ -router.get("/calendar", async (req, res) => { - const user = GetUser(req); - +API_V1_ROUTER.add("GET /users/:userID/sessions/calendar", withRequestedUser, async ({ ctx }) => { const rows = await DB.selectFrom("session") .select(SELECT_SESSION_CALENDAR) - .where("user_id", "=", user.id) + .where("session.user_id", "=", ctx.requestedUser.id) .execute(); const sessions = rows.map(ToSessionCalendarDocument); - return res.status(200).json({ - success: true, - description: `Found ${sessions.length} events.`, - body: sessions, - }); + return success(`Found ${sessions.length} events.`, sessions); }); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/settings/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/settings/router.oldtest.ts deleted file mode 100644 index 3cfd5d54a..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/settings/router.oldtest.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import deepmerge from "deepmerge"; -import t from "tap"; - -t.test("GET /api/v1/users/:userID/settings", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the users settings.", async (t) => { - const res = await mockApi.get("/api/v1/users/1/settings"); - - t.strictSame(res.body.body, { - userID: 1, - preferences: { - invisible: false, - developerMode: true, - advancedMode: false, - contentiousContent: false, - deletableScores: false, - }, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("PATCH /api/v1/users/:userID/settings", async (t) => { - t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - t.test("Should mutate the users settings.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1/settings").set("Cookie", cookie).send({ - developerMode: false, - invisible: true, - }); - - t.strictSame(res.body.body, { - userID: 1, - preferences: { - invisible: true, - developerMode: false, - advancedMode: false, - contentiousContent: false, - deletableScores: false, - }, - }); - - const dbRes = await MONGODB_KILL["user-settings"].findOne({ userID: 1 }); - - t.strictSame(dbRes, { - userID: 1, - preferences: { - invisible: true, - developerMode: false, - advancedMode: false, - contentiousContent: false, - deletableScores: false, - }, - }); - - t.end(); - }); - - t.test("Should 400 if body is empty.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1/settings").set("Cookie", cookie).send({}); - - t.equal(res.statusCode, 400); - - const dbRes = await MONGODB_KILL["user-settings"].findOne({ userID: 1 }); - - t.strictSame( - dbRes, - { - userID: 1, - preferences: { - invisible: false, - developerMode: true, - advancedMode: false, - contentiousContent: false, - deletableScores: false, - }, - }, - "User Settings should be unmodified.", - ); - - t.end(); - }); - - t.test("Should validate input.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1/settings").set("Cookie", cookie).send({ - developerMode: "true", - }); - - t.equal(res.statusCode, 400); - - const res2 = await mockApi.patch("/api/v1/users/1/settings").set("Cookie", cookie).send({ - invalid_prop: true, - }); - - t.equal(res2.statusCode, 400); - - const res3 = await mockApi - .patch("/api/v1/users/1/settings") - .set("Cookie", cookie) - .send({ - invisible: { $where: "alert(1)" }, - }); - - t.equal(res3.statusCode, 400); - - t.end(); - }); - - t.test("Must be authenticated as that user.", async (t) => { - const user = await MONGODB_KILL.users.findOne({}); - - await MONGODB_KILL.users.insert( - deepmerge(user!, { - id: 2, - username: "something_else", - usernameLowercase: "something_else", - }) as UserDocument, - ); - - const res = await mockApi - .patch("/api/v1/users/2/settings") - - // this token is for user 1, not 2 - .set("Cookie", cookie) - .send({ - developerMode: false, - }); - - t.equal(res.statusCode, 403); - - const dbRes = await MONGODB_KILL["user-settings"].findOne({ userID: 1 }); - - t.equal( - dbRes?.preferences.developerMode, - true, - "Settings should not be modified in the database.", - ); - - t.end(); - }); - - t.test("Must not work with an API key.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - identifier: "no perm", - permissions: { - // has relevant permissions - customise_profile: true, - }, - token: "no_perm", - userID: 1, - fromAPIClient: null, - }); - - const res = await mockApi - .patch("/api/v1/users/1/settings") - .set("Authorization", "Bearer no_perm") - .send({ - developerMode: false, - }); - - t.equal(res.statusCode, 403); - - const dbRes = await MONGODB_KILL["user-settings"].findOne({ userID: 1 }); - - t.equal( - dbRes?.preferences.developerMode, - true, - "Settings should not be modified in the database.", - ); - - t.end(); - }); - - t.test("Must be authenticated.", async (t) => { - const res = await mockApi.patch("/api/v1/users/1/settings").send({ - developerMode: false, - }); - - t.equal(res.statusCode, 401); - - const dbRes = await MONGODB_KILL["user-settings"].findOne({ userID: 1 }); - - t.equal( - dbRes?.preferences.developerMode, - true, - "Settings should not be modified in the database.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts index 018ebcce4..21d7f58ce 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts @@ -1,28 +1,18 @@ import { ACTION_UpdateUserSettings } from "#actions/update-user-settings"; -import prValidate from "#server/middleware/prudence-validate"; -import { GetTachiData } from "#utils/req-tachi-data"; +import { withRequestedUser, withSelf } from "#lib/router/middleware"; +import { success } from "#lib/router/typed-router"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; import { GetSettingsForUser } from "#utils/user"; -import { Router } from "express"; - -import { RequireSelfRequestFromUser } from "../middleware"; - -const router: Router = Router({ mergeParams: true }); /** * Retrieve this user's settings. Note that these settings are NOT private. * * @name GET /api/v1/users/:userID/settings */ -router.get("/", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); +API_V1_ROUTER.add("GET /users/:userID/settings", withRequestedUser, async ({ ctx }) => { + const settings = await GetSettingsForUser(ctx.requestedUser.id); - const settings = await GetSettingsForUser(user.id); - - return res.status(200).json({ - success: true, - description: `Retrieved settings.`, - body: settings, - }); + return success("Retrieved settings.", settings); }); /** @@ -36,29 +26,21 @@ router.get("/", async (req, res) => { * * @name PATCH /api/v1/users/:userID/settings */ -router.patch( - "/", - RequireSelfRequestFromUser, - prValidate({ - invisible: "*boolean", - developerMode: "*boolean", - contentiousContent: "*boolean", - advancedMode: "*boolean", - deletableScores: "*boolean", - }), - async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; +API_V1_ROUTER.add( + "PATCH /users/:userID/settings", + withRequestedUser, + withSelf, + async ({ input, ctx, req }) => { + const { requestedUser: user } = ctx; + const taker = { acct: { id: user.id, username: user.username }, ip: req.ip }; - const body = req.safeBody as { - advancedMode?: boolean; - contentiousContent?: boolean; - deletableScores?: boolean; - developerMode?: boolean; - invisible?: boolean; - }; - - await ACTION_UpdateUserSettings(taker, body); + await ACTION_UpdateUserSettings(taker, { + advancedMode: input.advancedMode, + contentiousContent: input.contentiousContent, + deletableScores: input.deletableScores, + developerMode: input.developerMode, + invisible: input.invisible, + }); const settings = await GetSettingsForUser(user.id); @@ -66,12 +48,6 @@ router.patch( req.session.tachi.settings = settings; } - return res.status(200).json({ - success: true, - description: `Updated settings.`, - body: settings, - }); + return success("Updated settings.", settings); }, ); - -export default router; diff --git a/typescript/server/src/server/router/api/v1/users/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/router.oldtest.ts deleted file mode 100644 index 2932e156f..000000000 --- a/typescript/server/src/server/router/api/v1/users/router.oldtest.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -t.test("GET /api/v1/users", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should search users if search param is set.", async (t) => { - const res = await mockApi.get("/api/v1/users?search=zkldi"); - - t.equal(res.body.body.length, 1); - t.equal(res.body.body[0].username, "test_zkldi"); - - const res2 = await mockApi.get("/api/v1/users?search=nothing"); - - t.equal(res2.body.body.length, 0); - - const res3 = await mockApi.get("/api/v1/users?search=ZklDI"); - - t.equal(res3.body.body.length, 1); - t.equal(res3.body.body[0].username, "test_zkldi"); - - t.end(); - }); - - t.test("Should sanitise input for regex stuff.", async (t) => { - const res = await mockApi.get("/api/v1/users?search=.*"); - - t.equal(res.body.body.length, 0); - - t.end(); - }); - - t.test("Should restrict returns to only online users if online is set.", async (t) => { - const res = await mockApi.get("/api/v1/users?online=true"); - - t.equal(res.body.body.length, 0); - - await MONGODB_KILL.users.insert({ - username: "online_dude", - usernameLowercase: "online_dude", - lastSeen: Date.now(), - id: 2, - } as UserDocument); - - const res2 = await mockApi.get("/api/v1/users?online=true"); - - t.equal(res2.body.body.length, 1); - t.equal(res2.body.body[0].usernameLowercase, "online_dude"); - - t.end(); - }); - - t.test("Should return users sorted by lastSeen if no arguments are passed.", async (t) => { - await MONGODB_KILL.users.insert({ - username: "online_dude", - usernameLowercase: "online_dude", - lastSeen: Date.now(), - id: 2, - } as UserDocument); - - const res = await mockApi.get("/api/v1/users"); - - t.equal(res.body.body.length, 2); - t.strictSame( - res.body.body.map((e: UserDocument) => e.usernameLowercase), - ["online_dude", "test_zkldi"], - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/api/v1/users/router.test.ts b/typescript/server/src/server/router/api/v1/users/router.test.ts index 8047c2061..57ff0f723 100644 --- a/typescript/server/src/server/router/api/v1/users/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/router.test.ts @@ -1,7 +1,7 @@ import DB from "#services/pg/db"; import mockApi from "#test-utils/mock-api"; import { seedUser } from "#test-utils/pg-fixtures"; -import { type MONGO_UserDocument } from "tachi-common"; +import { type UserDocument } from "tachi-common"; import { beforeEach, describe, expect, it } from "vitest"; async function insertUserWithLastSeen(username: string, lastSeenMs: number) { @@ -41,7 +41,7 @@ describe("GET /api/v1/users", () => { const res = await mockApi.get("/api/v1/users"); expect(res.status).toBe(200); - const usernames = res.body.body.map((u: MONGO_UserDocument) => u.username); + const usernames = res.body.body.map((u: UserDocument) => u.username); expect(usernames[0]).toBe("test_user"); expect(usernames[1]).toBe("older_user"); }); @@ -151,7 +151,7 @@ describe("GET /api/v1/users?online", () => { const res = await mockApi.get("/api/v1/users?online"); expect(res.status).toBe(200); - const usernames = res.body.body.map((u: MONGO_UserDocument) => u.username); + const usernames = res.body.body.map((u: UserDocument) => u.username); expect(usernames[0]).toBe("user_a"); expect(usernames[1]).toBe("user_b"); }); diff --git a/typescript/server/src/server/router/api/v1/users/router.ts b/typescript/server/src/server/router/api/v1/users/router.ts index 477cf0e16..031545183 100644 --- a/typescript/server/src/server/router/api/v1/users/router.ts +++ b/typescript/server/src/server/router/api/v1/users/router.ts @@ -1,16 +1,11 @@ import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; +import { success } from "#lib/router/typed-router"; import { SearchUsersRegExp } from "#lib/search/search"; import DB from "#services/pg/db"; -import { IsString } from "#utils/misc"; -import { apiSuccess } from "#utils/response"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { GetOnlineCutoff } from "#utils/user"; -import { Router } from "express"; -import { type MONGO_UserDocument } from "tachi-common"; -import userIDRouter from "./_userID/router"; - -const router: Router = Router({ mergeParams: true }); +import { API_V1_ROUTER } from "../router"; /** * Search users. @@ -21,45 +16,32 @@ const router: Router = Router({ mergeParams: true }); * * @name GET /api/v1/users */ -router.get("/", async (req, res) => { - const onlyOnline = req.query.online !== undefined; +API_V1_ROUTER.add("GET /users", async ({ input }) => { + const onlyOnline = input.online !== undefined; + const search = input.search; let users; - const search = req.query.search; - if (search !== undefined) { - if (!IsString(search)) { - return res.status(400).json({ - success: false, - description: `Search parameter was invalid.`, - }); - } - users = await SearchUsersRegExp(search, onlyOnline); } else { let query = DB.selectFrom("account").select(SELECT_USER); if (onlyOnline) { const onlineCutoff = GetOnlineCutoff(); + query = query - .where("last_seen", ">", UnixMillisecondsToISO8601(onlineCutoff)) - .orderBy("last_seen", "desc") + .where("account.last_seen", ">", UnixMillisecondsToISO8601(onlineCutoff)) + .orderBy("account.last_seen", "desc") .limit(100); } users = await query - .orderBy("last_seen", "desc") + .orderBy("account.last_seen", "desc") .limit(100) .execute() - .then((res) => res.map(ToUserDocument)); + .then((rows) => rows.map(ToUserDocument)); } - return res - .status(200) - .json(apiSuccess>(`Returned ${users.length} users.`, users)); + return success(`Returned ${users.length} users.`, users); }); - -router.use("/:userID", userIDRouter); - -export default router; diff --git a/typescript/server/src/server/router/ir/barbatos/router.oldtest.ts b/typescript/server/src/server/router/ir/barbatos/router.oldtest.ts deleted file mode 100644 index d61421f85..000000000 --- a/typescript/server/src/server/router/ir/barbatos/router.oldtest.ts +++ /dev/null @@ -1,63 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import { InsertFakeTokenWithAllPerms } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { TestingBarbatosScore } from "#test-utils/test-data"; -import t from "tap"; - -t.test("POST /ir/barbatos/score/submit", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(InsertFakeTokenWithAllPerms("mock_token")); - - t.test("Should import a valid score", async (t) => { - const res = await mockApi - .post("/ir/barbatos/score/submit") - .set("Authorization", "Bearer mock_token") - .send(TestingBarbatosScore); - - t.equal(res.body.success, true, "Should be successful"); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scores = await MONGODB_KILL.scores.count({ - service: "Barbatos (vivid)", - }); - - t.equal(scores, 1, "Should import 1 score."); - - t.end(); - }); - - t.test("Should reject an invalid body", async (t) => { - const res = await mockApi - .post("/ir/barbatos/score/submit") - .set("Authorization", "Bearer mock_token") - .send({}); - - t.equal(res.body.success, false, "Should not be successful."); - t.equal(res.status, 400, "Should return 400."); - - t.end(); - }); - - t.test("Should require authorisation.", async (t) => { - const res = await mockApi.post("/ir/barbatos/score/submit").send(TestingBarbatosScore); - - t.equal(res.statusCode, 401, "Should return 401 for no authorization header."); - - t.end(); - }); - - t.test("Should require valid authorisation.", async (t) => { - const res = await mockApi - .post("/ir/barbatos/score/submit") - .set("Authorization", "Bearer invalid_token") - .send(TestingBarbatosScore); - - t.equal(res.statusCode, 401, "Should return 401 for invalid authorization header."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/barbatos/router.test.ts b/typescript/server/src/server/router/ir/barbatos/router.test.ts new file mode 100644 index 000000000..a1b9d2eb9 --- /dev/null +++ b/typescript/server/src/server/router/ir/barbatos/router.test.ts @@ -0,0 +1,104 @@ +import { seedApiToken, seedUser } from "#actions/test-utils/api-tokens"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { + TestingBarbatosScore, + TestingSDVXAlbidaChart, + TestingSDVXAlbidaSong, +} from "#test-utils/test-data"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const MOCK_TOKEN = "mock_token"; + +async function seedBarbatosSdvxChart() { + await DB.insertInto("song") + .values({ + id: TestingSDVXAlbidaSong.id, + legacy_id: 1, + game_group: "sdvx", + title: TestingSDVXAlbidaSong.title, + artist: TestingSDVXAlbidaSong.artist, + search_terms: TestingSDVXAlbidaSong.searchTerms, + alt_titles: TestingSDVXAlbidaSong.altTitles, + data: TestingSDVXAlbidaSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: TestingSDVXAlbidaChart.chartID, + legacy_id: TestingSDVXAlbidaChart.chartID, + game: "sdvx", + song_id: TestingSDVXAlbidaSong.id, + difficulty: TestingSDVXAlbidaChart.difficulty, + level: TestingSDVXAlbidaChart.level, + level_num: TestingSDVXAlbidaChart.levelNum, + is_primary: TestingSDVXAlbidaChart.isPrimary, + versions: TestingSDVXAlbidaChart.versions, + data: TestingSDVXAlbidaChart.data, + }) + .execute(); +} + +afterAll(() => CloseServerConnection()); + +describe("POST /ir/barbatos/score/submit (Postgres)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "barbatos-ir@example.com", + withCredential: true, + withSettings: true, + }); + await seedApiToken({ + token: MOCK_TOKEN, + userId: 1, + submitScore: true, + }); + await seedBarbatosSdvxChart(); + }); + + it("imports a valid score", async () => { + const res = await mockApi + .post("/ir/barbatos/score/submit") + .set("Authorization", `Bearer ${MOCK_TOKEN}`) + .send(TestingBarbatosScore); + + expect(res.body.success).toBe(true); + expect(res.body.body.errors.length).toBe(0); + + const row = await DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("c")) + .where("user_id", "=", 1) + .where("game", "=", "sdvx") + .executeTakeFirst(); + + expect(Number(row?.c)).toBe(1); + }); + + it("rejects an invalid body", async () => { + const res = await mockApi + .post("/ir/barbatos/score/submit") + .set("Authorization", `Bearer ${MOCK_TOKEN}`) + .send({}); + + expect(res.body.success).toBe(false); + expect(res.status).toBe(400); + }); + + it("requires authorization", async () => { + const res = await mockApi.post("/ir/barbatos/score/submit").send(TestingBarbatosScore); + + expect(res.statusCode).toBe(401); + }); + + it("rejects invalid tokens", async () => { + const res = await mockApi + .post("/ir/barbatos/score/submit") + .set("Authorization", "Bearer invalid_token") + .send(TestingBarbatosScore); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/typescript/server/src/server/router/ir/beatoraja/auth.oldtest.ts b/typescript/server/src/server/router/ir/beatoraja/auth.oldtest.ts deleted file mode 100644 index c02e19fe7..000000000 --- a/typescript/server/src/server/router/ir/beatoraja/auth.oldtest.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { expressRequestMock } from "#test-utils/mock-request"; -import t from "tap"; - -import { ValidateIRClientVersion } from "./auth"; - -t.test("#ValidateIRClientVersion", (t) => { - t.test("Should reject clients that are not supported", async (t) => { - const { res } = await expressRequestMock(ValidateIRClientVersion, { - headers: { - "X-TachiIR-Version": "1.2.0", - }, - }); - - const json = res._getJSONData(); - - t.equal(res.statusCode, 400); - t.equal(json.success, false); - t.match(json.description, /Invalid X-TachiIR-Version/u); - - t.end(); - }); - - t.test("Should reject no client header", async (t) => { - const { res } = await expressRequestMock(ValidateIRClientVersion, {}); - - const json = res._getJSONData(); - - t.equal(res.statusCode, 400); - t.equal(json.success, false); - t.match(json.description, /Invalid X-TachiIR-Version/u); - - t.end(); - }); - - t.test("Should accept 2.0.0", async (t) => { - const { res } = await expressRequestMock(ValidateIRClientVersion, { - headers: { - "X-TachiIR-Version": "v2.0.0", - }, - }); - - t.equal(res.statusCode, 200); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/beatoraja/auth.test.ts b/typescript/server/src/server/router/ir/beatoraja/auth.test.ts new file mode 100644 index 000000000..cbf38efc2 --- /dev/null +++ b/typescript/server/src/server/router/ir/beatoraja/auth.test.ts @@ -0,0 +1,40 @@ +import { expressRequestMock } from "#test-utils/mock-request"; +import { describe, expect, it } from "vitest"; + +import { ValidateIRClientVersion } from "./auth"; + +describe("ValidateIRClientVersion", () => { + it("rejects unsupported client versions", async () => { + const { res } = await expressRequestMock(ValidateIRClientVersion, { + headers: { + "X-TachiIR-Version": "1.2.0", + }, + }); + + const json = res._getJSONData(); + + expect(res.statusCode).toBe(400); + expect(json.success).toBe(false); + expect(json.description).toMatch(/Invalid X-TachiIR-Version/u); + }); + + it("rejects missing client header", async () => { + const { res } = await expressRequestMock(ValidateIRClientVersion, {}); + + const json = res._getJSONData(); + + expect(res.statusCode).toBe(400); + expect(json.success).toBe(false); + expect(json.description).toMatch(/Invalid X-TachiIR-Version/u); + }); + + it("accepts v2.0.0", async () => { + const { res } = await expressRequestMock(ValidateIRClientVersion, { + headers: { + "X-TachiIR-Version": "v2.0.0", + }, + }); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.oldtest.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.oldtest.ts deleted file mode 100644 index dd36b8738..000000000 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.oldtest.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { PBScoreDocument } from "tachi-common"; - -import { dmf } from "#test-utils/misc.js"; -import ResetDBState from "#test-utils/resets"; -import { BMSGazerChart } from "#test-utils/test-data"; -import t from "tap"; - -import { TachiScoreDataToBeatorajaFormat } from "./convert-scores"; - -const pbScore = { - composedFrom: [{ name: "Best Score", scoreID: "mock_lampPB" }], - scoreData: { - enumIndexes: { - lamp: 4, - }, - score: 1234, - optional: {}, - }, - playtype: "7K", - scoreMeta: {}, - chartID: BMSGazerChart.chartID, - userID: 1, -} as unknown as PBScoreDocument<"bms:7K" | "bms:14K">; - -t.test("#TachiScoreDataToBeatorajaFormat", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should convert score.", (t) => { - const res = TachiScoreDataToBeatorajaFormat( - pbScore, - BMSGazerChart.data.hashSHA256, - "", - BMSGazerChart.data.notecount, - 0, - ); - - t.strictSame( - res, - { - sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", - player: "", - playcount: 0, - clear: 5, - date: 0, - maxcombo: 0, - deviceType: null, - gauge: 0, - random: null, - passnotes: 0, - minbp: 0, - notes: 2256, - epg: 617, - lpg: 0, - egr: 0, - lgr: 0, - egd: 0, - lgd: 0, - ebd: 0, - lbd: 0, - epr: 0, - lpr: 0, - ems: 0, - lms: 0, - }, - "Should return the beatoraja score format.", - ); - - t.end(); - }); - - t.test("Should fake epg/egr data if the score doesn't have it..", (t) => { - const res = TachiScoreDataToBeatorajaFormat( - dmf(pbScore, { scoreData: { score: 999 } }), - BMSGazerChart.data.hashSHA256, - "", - BMSGazerChart.data.notecount, - 0, - ); - - t.strictSame( - res, - { - sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", - player: "", - playcount: 0, - clear: 5, - date: 0, - maxcombo: 0, - deviceType: null, - gauge: 0, - random: null, - passnotes: 0, - minbp: 0, - notes: 2256, - epg: Math.floor(999 / 2), - lpg: 0, - egr: 1, - lgr: 0, - egd: 0, - lgd: 0, - ebd: 0, - lbd: 0, - epr: 0, - lpr: 0, - ems: 0, - lms: 0, - }, - "Should return the beatoraja score format.", - ); - - t.end(); - }); - - t.test("Should emplace username if requestingUserID is not the pbscore owner", (t) => { - const res = TachiScoreDataToBeatorajaFormat( - pbScore, - BMSGazerChart.data.hashSHA256, - "test_zkldi", - BMSGazerChart.data.notecount, - 0, - ); - - t.strictSame( - res, - { - sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", - player: "test_zkldi", - playcount: 0, - clear: 5, - date: 0, - maxcombo: 0, - deviceType: null, - gauge: 0, - random: null, - passnotes: 0, - minbp: 0, - notes: 2256, - epg: 617, - lpg: 0, - egr: 0, - lgr: 0, - egd: 0, - lgd: 0, - ebd: 0, - lbd: 0, - epr: 0, - lpr: 0, - ems: 0, - lms: 0, - }, - "Should return the beatoraja score format.", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts new file mode 100644 index 000000000..e54aaf774 --- /dev/null +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts @@ -0,0 +1,111 @@ +import type { PBScoreDocument } from "tachi-common"; + +import { dmf } from "#test-utils/misc"; +import { BMSGazerChart } from "#test-utils/test-data"; +import { describe, expect, it } from "vitest"; + +import { TachiScoreDataToBeatorajaFormat } from "./convert-scores"; + +const pbScore = { + composedFrom: [{ name: "Best Score", scoreID: "mock_lampPB" }], + scoreData: { + enumIndexes: { + lamp: 4, + }, + score: 1234, + optional: {}, + }, + scoreMeta: {}, + chartID: BMSGazerChart.chartID, + userID: 1, + game: "bms-7k", +} as unknown as PBScoreDocument<"bms-7k">; + +describe("TachiScoreDataToBeatorajaFormat (ported from convert-scores.oldtest.ts)", () => { + it("converts score to Beatoraja format", () => { + const res = TachiScoreDataToBeatorajaFormat( + pbScore, + BMSGazerChart.data.hashSHA256, + "", + BMSGazerChart.data.notecount, + 0, + ); + + expect(res).toEqual({ + sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", + player: "", + playcount: 0, + clear: 5, + date: 0, + maxcombo: 0, + deviceType: null, + gauge: 0, + random: null, + passnotes: 0, + minbp: 0, + notes: 2256, + epg: 617, + lpg: 0, + egr: 0, + lgr: 0, + egd: 0, + lgd: 0, + ebd: 0, + lbd: 0, + epr: 0, + lpr: 0, + ems: 0, + lms: 0, + }); + }); + + it("fakes epg/egr when score metrics are missing", () => { + const res = TachiScoreDataToBeatorajaFormat( + dmf(pbScore, { scoreData: { ...pbScore.scoreData, score: 999 } } as never), + BMSGazerChart.data.hashSHA256, + "", + BMSGazerChart.data.notecount, + 0, + ); + + expect(res).toEqual({ + sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", + player: "", + playcount: 0, + clear: 5, + date: 0, + maxcombo: 0, + deviceType: null, + gauge: 0, + random: null, + passnotes: 0, + minbp: 0, + notes: 2256, + epg: Math.floor(999 / 2), + lpg: 0, + egr: 1, + lgr: 0, + egd: 0, + lgd: 0, + ebd: 0, + lbd: 0, + epr: 0, + lpr: 0, + ems: 0, + lms: 0, + }); + }); + + it("sets player name when provided", () => { + const res = TachiScoreDataToBeatorajaFormat( + pbScore, + BMSGazerChart.data.hashSHA256, + "test_zkldi", + BMSGazerChart.data.notecount, + 0, + ); + + expect(res.player).toBe("test_zkldi"); + expect(res.epg).toBe(617); + }); +}); diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.ts index 31d944a37..1bc8c03a4 100644 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.ts +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.ts @@ -1,4 +1,4 @@ -import type { integer, MONGO_PBScoreDocument } from "tachi-common"; +import type { BMSGames, integer, PBScoreDocument } from "tachi-common"; const LAMP_TO_BEATORAJA = [0, 1, 3, 4, 5, 6, 7, 8] as const; @@ -45,7 +45,7 @@ export type BeatorajaIRScoreFormat = BeatorajaPartialScoreFormat & BeatorajaScor * @returns A Beatoraja Score Document. */ export function TachiScoreDataToBeatorajaFormat( - pbScore: MONGO_PBScoreDocument<"bms:7K" | "bms:14K" | "pms:Controller" | "pms:Keyboard">, + pbScore: PBScoreDocument, sha256: string, username: string, notecount: integer, diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.oldtest.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.oldtest.ts deleted file mode 100644 index e08d7a76f..000000000 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.oldtest.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { PBScoreDocument, ScoreDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => - MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "Mock API Beatoraja Token", - permissions: { - submit_score: true, - }, - token: "mock_token", - fromAPIClient: null, - }), - ); - - const GAZER_SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d"; - const GAZER_CHARTID = "88eb6cc5683e2740cbd07f588a5f3db1db8d467b"; - - t.test("Should return PB scores on a chart", async (t) => { - // very lazy fake scores - await MONGODB_KILL["personal-bests"].insert({ - composedFrom: [{ name: "Best Lamp", scoreID: "mock_lampPB" }], - scoreData: { - score: 1234, - enumIndexes: { - lamp: 4, - }, - optional: { - enumIndexes: {}, - }, - }, - scoreMeta: {}, - chartID: GAZER_CHARTID, - userID: 1, - } as unknown as PBScoreDocument); - - await MONGODB_KILL.scores.insert({ - scoreID: "mock_lampPB", - scoreMeta: { - inputDevice: "BM_CONTROLLER", - random: "MIRROR", - }, - } as ScoreDocument); - - const res = await mockApi - .get(`/ir/beatoraja/charts/${GAZER_SHA256}/scores`) - .set("Authorization", "Bearer mock_token") - .set("X-TachiIR-Version", "v2.0.0"); - - t.equal(res.status, 200); - - t.hasStrict(res.body.body, [ - { - player: "", - clear: 5, - epg: 617, - lpg: 0, - }, - ]); - - t.end(); - }); - - t.test("Should return 404 if chart doesnt exist", async (t) => { - const res = await mockApi - .get(`/ir/beatoraja/charts/INVALID/scores`) - .set("Authorization", "Bearer mock_token") - .set("X-TachiIR-Version", "v2.0.0"); - - t.equal(res.status, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.test.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.test.ts new file mode 100644 index 000000000..da02fd6e4 --- /dev/null +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.test.ts @@ -0,0 +1,125 @@ +import { seedApiToken, seedUser } from "#actions/test-utils/api-tokens"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { BMSGazerChart, BMSGazerSong } from "#test-utils/test-data"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const GAZER_SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d"; +const GAZER_CHARTID = "88eb6cc5683e2740cbd07f588a5f3db1db8d467b"; + +afterAll(() => CloseServerConnection()); + +async function seedGazer() { + await DB.insertInto("song") + .values({ + id: BMSGazerSong.id, + legacy_id: 27_339, + game_group: "bms", + title: BMSGazerSong.title, + artist: BMSGazerSong.artist, + search_terms: BMSGazerSong.searchTerms, + alt_titles: BMSGazerSong.altTitles, + data: BMSGazerSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: BMSGazerChart.chartID, + legacy_id: BMSGazerChart.chartID, + game: "bms-7k", + song_id: BMSGazerSong.id, + difficulty: BMSGazerChart.difficulty, + level: BMSGazerChart.level, + level_num: BMSGazerChart.levelNum, + is_primary: BMSGazerChart.isPrimary, + versions: BMSGazerChart.versions, + data: BMSGazerChart.data, + }) + .execute(); +} + +describe("GET /ir/beatoraja/charts/:chartSHA256/scores (Postgres)", () => { + beforeEach(async () => { + await seedUser({ username: "bms_ir_u", email: "beatoraja-ir@example.com" }); + await seedApiToken({ + token: "mock_token", + userId: 1, + submitScore: true, + }); + await seedGazer(); + + const sd = { + score: 1234, + enumIndexes: { lamp: 4 }, + optional: { enumIndexes: {} }, + judgements: {}, + }; + const { data, derived, judgements } = mongoScoreDataToPg("bms-7k", sd as never); + + await DB.insertInto("pb") + .values({ + user_id: 1, + chart_id: GAZER_CHARTID, + lens: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + ranking_value: 1234, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: false, + time_achieved: null, + }) + .execute(); + + await DB.insertInto("score") + .values({ + id: "mock_lampPB", + user_id: 1, + chart_id: GAZER_CHARTID, + game: "bms-7k", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({ inputDevice: "BM_CONTROLLER", random: "MIRROR" }), + time_achieved: null, + time_added: new Date().toISOString(), + highlight: false, + comment: null, + }) + .execute(); + }); + + it("returns PB-derived scores for the chart", async () => { + const res = await mockApi + .get(`/ir/beatoraja/charts/${GAZER_SHA256}/scores`) + .set("Authorization", "Bearer mock_token") + .set("X-TachiIR-Version", "v2.0.0"); + + expect(res.status).toBe(200); + expect(res.body.body[0]).toMatchObject({ + epg: 617, + lpg: 0, + player: "", + }); + }); + + it("returns 404 when the chart hash is unknown", async () => { + const res = await mockApi + .get("/ir/beatoraja/charts/INVALID/scores") + .set("Authorization", "Bearer mock_token") + .set("X-TachiIR-Version", "v2.0.0"); + + expect(res.status).toBe(404); + }); +}); diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts index 921565720..f59213da3 100644 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts @@ -1,11 +1,11 @@ -import type { integer, MONGO_PBScoreDocument } from "tachi-common"; +import type { BMSGames, integer, PBScoreDocument } from "tachi-common"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { LoadAllPbsForChartPgId } from "#lib/db-formats/pb"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; import { FindBeatorajaChartOnHashSHA256 } from "#utils/queries/charts"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; +import { REQ_AssignToReqTachiData, REQ_GetTachiData } from "#utils/req-tachi-data"; import { type RequestHandler, Router } from "express"; import { TachiScoreDataToBeatorajaFormat } from "./convert-scores"; @@ -23,7 +23,7 @@ const GetChartDocument: RequestHandler = async (req, res, next) => { }); } - AssignToReqTachiData(req, { beatorajaChartDoc: chart }); + REQ_AssignToReqTachiData(req, { beatorajaChartDoc: chart }); next(); }; @@ -36,7 +36,7 @@ router.use(GetChartDocument); * @name GET /ir/beatoraja/charts/:chartSHA256/scores */ router.get("/scores", async (req, res) => { - const chart = GetTachiData(req, "beatorajaChartDoc"); + const chart = REQ_GetTachiData(req, "beatorajaChartDoc"); const requestingUserID = req[SYMBOL_TACHI_API_AUTH].userID; const scores = await LoadAllPbsForChartPgId(chart.chartID); @@ -69,9 +69,7 @@ router.get("/scores", async (req, res) => { beatorajaScores.push( TachiScoreDataToBeatorajaFormat( - score as MONGO_PBScoreDocument< - "bms:7K" | "bms:14K" | "pms:Controller" | "pms:Keyboard" - >, + score as PBScoreDocument, chart.data.hashSHA256, score.userID === requestingUserID ? "" : username, chart.data.notecount, diff --git a/typescript/server/src/server/router/ir/beatoraja/router.oldtest.ts b/typescript/server/src/server/router/ir/beatoraja/router.oldtest.ts deleted file mode 100644 index 4051ee835..000000000 --- a/typescript/server/src/server/router/ir/beatoraja/router.oldtest.ts +++ /dev/null @@ -1,609 +0,0 @@ -import type { UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { MockBeatorajaBMSScore, MockBeatorajaPMSScore } from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -const NEW_SHA256 = "769359ebb55d3d6dff3b5c6a07ec03be9b87beda1ffb0c07d7ea99590605a732"; -const NEW_MD5 = "d0f497c0f955e7edfb0278f446cdb6f8"; - -t.test("POST /ir/beatoraja/submit-score", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => - MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "Mock API Beatoraja Token", - permissions: { - submit_score: true, - }, - token: "mock_token", - fromAPIClient: null, - }), - ); - - t.test("Should import a valid BMS score.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(MockBeatorajaBMSScore); - - t.equal(res.status, 200); - - t.equal(res.body.success, true); - t.hasStrict(res.body.body, { - score: { - game: "bms", - scoreData: { - score: 1004, - }, - importType: "ir/beatoraja", - }, - chart: { - chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", - }, - song: { - id: 27339, - }, - }); - - const score = await MONGODB_KILL.scores.findOne( - { scoreID: res.body.body.score.scoreID }, - { projection: { _id: 0 } }, - ); - - t.not(score, null); - - t.hasStrict(res.body.body.score, score); - t.hasStrict(res.body.body.score, score); - - t.end(); - }); - - t.test("Should import a valid PMS score.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(MockBeatorajaPMSScore); - - t.equal(res.status, 200); - - t.equal(res.body.success, true); - t.hasStrict(res.body.body, { - score: { - game: "pms", - playtype: "Controller", - scoreData: { - score: 1004, - }, - importType: "ir/beatoraja", - }, - chart: { - chartID: "0446f1b54e90d631ff9fe98419ebaea9481fab1f", - }, - song: { - id: 1, - }, - }); - - const score = await MONGODB_KILL.scores.findOne( - { scoreID: res.body.body.score.scoreID }, - { projection: { _id: 0 } }, - ); - - t.not(score, null); - - t.hasStrict(res.body.body.score, score); - - t.end(); - }); - - t.test("Should infer playtype from the PMS score.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(MockBeatorajaPMSScore, { score: { deviceType: "KEYBOARD" } })); - - t.equal(res.status, 200); - - t.equal(res.body.success, true); - t.hasStrict(res.body.body, { - score: { - game: "pms", - playtype: "Keyboard", - scoreData: { - score: 1004, - }, - importType: "ir/beatoraja", - }, - chart: { - chartID: "ca553d77cbf8b3e9e7709dad6123ffed1695a1dd", - }, - song: { - id: 1, - }, - }); - - const score = await MONGODB_KILL.scores.findOne( - { scoreID: res.body.body.score.scoreID }, - { projection: { _id: 0 } }, - ); - - t.not(score, null); - - t.hasStrict(res.body.body.score, score); - - t.end(); - }); - - t.test("Should return an error if invalid client.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(MockBeatorajaBMSScore, { client: "INVALID" })); - - t.equal(res.status, 400); - - t.equal(res.body.success, false); - t.match(res.body.description, /Unsupported client/u); - - t.end(); - }); - - t.test("Should return an error if BMS scores try to use beatoraja.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(MockBeatorajaBMSScore, { client: "beatoraja 0.8.0" })); - - t.equal(res.status, 400); - - t.equal(res.body.success, false); - t.match(res.body.description, /Unsupported client/u); - - t.end(); - }); - - t.test("Should return an error if PMS scores try to use lr2oraja.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(MockBeatorajaPMSScore, { client: "LR2oraja 0.8.0" })); - - t.equal(res.status, 400); - - t.equal(res.body.success, false); - t.match(res.body.description, /Unsupported client/u); - - t.end(); - }); - - t.test("Should return an error if invalid score.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(MockBeatorajaBMSScore, { score: { exscore: -1 } })); - - t.equal(res.status, 400); - - t.equal(res.body.success, false); - t.match(res.body.description, /Invalid Beatoraja Import - Score/u); - - t.end(); - }); - - t.test("Should return an error if invalid chart.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(MockBeatorajaBMSScore, { chart: { title: null } })); - - t.equal(res.status, 400); - - t.equal(res.body.success, false); - t.match(res.body.description, /Invalid Beatoraja Import - Chart/u); - - t.end(); - }); - - t.test("Should defer a chart to the orphan queue if not found.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send( - deepmerge(MockBeatorajaBMSScore, { - chart: { sha256: NEW_SHA256, md5: NEW_MD5 }, - score: { sha256: NEW_SHA256, md5: NEW_MD5 }, - }), - ); - - t.equal(res.status, 202); - - t.equal(res.body.success, true); - t.match(res.body.description, /Chart and score have been orphaned/u); - - const orphanChart = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA256": NEW_SHA256, - }); - - t.hasStrict(orphanChart?.chartDoc, { - data: { - hashSHA256: NEW_SHA256, - hashMD5: NEW_MD5, - }, - }); - - t.end(); - }); - - t.test("Should eventually unorphan a chart.", async (t) => { - await MONGODB_KILL["api-tokens"].insert([ - { - userID: 2, - identifier: "token2", - permissions: { submit_score: true }, - token: "token2", - fromAPIClient: null, - }, - { - userID: 3, - identifier: "token3", - permissions: { submit_score: true }, - token: "token3", - fromAPIClient: null, - }, - ]); - - await MONGODB_KILL.users.insert([ - { - id: 2, - username: "foo", - usernameLowercase: "foo", - }, - { - id: 3, - username: "bar", - usernameLowercase: "bar", - }, - ] as Array); - - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send( - deepmerge(MockBeatorajaBMSScore, { - chart: { sha256: NEW_SHA256, md5: NEW_MD5 }, - score: { sha256: NEW_SHA256, md5: NEW_MD5 }, - }), - ); - - t.equal(res.statusCode, 202); - - const res2 = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer token2") - .send( - deepmerge(MockBeatorajaBMSScore, { - chart: { sha256: NEW_SHA256, md5: NEW_MD5 }, - score: { sha256: NEW_SHA256, md5: NEW_MD5 }, - }), - ); - - t.equal(res2.statusCode, 202); - - const orphanData = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA256": NEW_SHA256, - }); - - t.strictSame(orphanData?.userIDs, [1, 2]); - - const res3 = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer token3") - .send( - deepmerge(MockBeatorajaBMSScore, { - chart: { sha256: NEW_SHA256, md5: NEW_MD5 }, - score: { sha256: NEW_SHA256, md5: NEW_MD5 }, - }), - ); - - t.equal(res3.statusCode, 200); - - const orphanData2 = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA256": NEW_SHA256, - }); - - t.equal(orphanData2, null, "Orphan data should be removed from the database."); - - const score = await MONGODB_KILL.scores.findOne({ - game: "bms", - userID: 3, - }); - - t.hasStrict(score, { - scoreData: { - score: 1004, - }, - importType: "ir/beatoraja", - }); - - t.end(); - }); - - t.test("Should require authentication.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .send(MockBeatorajaBMSScore); - - t.equal(res.status, 401); - - t.end(); - }); - - t.test("Should reject non-corresponding tokens.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer invalid_token") - - .send(MockBeatorajaBMSScore); - - t.equal(res.status, 401); - - t.end(); - }); - - t.end(); -}); - -const courseScore = { - course: { - name: "GENOSIDE 2018 段位認定 発狂皆伝", - charts: [ - { - md5: "cfad3baadce9e02c45021963453d7c94", - }, - { - md5: "77d23be22b2370925c573d922276bce0", - }, - { - md5: "188a99f74ab71804f2e360dcf484545c", - }, - { - md5: "c46a81cb184f5a804c119930d6eba748", - }, - ], - constraint: ["MIRROR", "GAUGE_LR2", "LN"], - trophy: [{}, {}], - lntype: 0, - }, - score: { - sha256: "", - lntype: 0, - player: "unknown", - clear: "Normal", - date: 0, - epg: 1334, - lpg: 788, - egr: 1634, - lgr: 382, - egd: 239, - lgd: 142, - ebd: 34, - lbd: 8, - epr: 0, - lpr: 93, - ems: 63, - lms: 74, - maxcombo: 225, - notes: 6005, - passnotes: 4654, - minbp: 1623, - option: 0, - assist: 0, - gauge: 0, - deviceType: "BM_CONTROLLER", - judgeAlgorithm: "Combo", - rule: "Beatoraja_7", - exscore: 6260, - }, -}; - -t.test("POST /ir/beatoraja/submit-course", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => - MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "Mock API Beatoraja Token", - permissions: { - submit_score: true, - }, - token: "mock_token", - fromAPIClient: null, - }), - ); - - t.test("Should accept a valid clear", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(courseScore); - - t.equal(res.status, 200); - t.equal(res.body.success, true); - t.equal(res.body.description, "Successfully updated class."); - - const ugs = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "bms", - playtype: "7K", - }); - - t.equal(ugs?.classes.genocideDan, "OVERJOY", "Should set their dan to overjoy."); - - t.end(); - }); - - t.test("Should silently reject a fail", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(courseScore, { score: { clear: "Failed" } })); - - t.equal(res.status, 200); - t.equal(res.body.success, true); - t.equal(res.body.description, "Class not updated, as you didn't clear this course."); - - t.end(); - }); - - t.test("Should reject scores with no charts", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(courseScore, { course: { charts: [] } }, { arrayMerge: (d, s) => s })); - - t.equal(res.status, 400); - t.equal(res.body.success, false); - t.equal( - res.body.description, - "[course.charts] Expected an array of 4 objects with MD5 properties.", - ); - - t.end(); - }); - - t.test("Should reject scores with invalid chart documents", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send(deepmerge(courseScore, { course: { charts: [1, 2, 3, 4] } })); - - t.equal(res.status, 400); - t.equal(res.body.success, false); - t.equal( - res.body.description, - "[course.charts] Expected an array of 4 objects with MD5 properties.", - ); - - t.end(); - }); - - t.test("Should reject scores with too many chart documents", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send( - deepmerge(courseScore, { - course: { - charts: [ - { md5: "a" }, - { md5: "a" }, - { md5: "a" }, - { md5: "a" }, - { md5: "a" }, - ], - }, - }), - ); - - t.equal(res.status, 400); - t.equal(res.body.success, false); - t.equal( - res.body.description, - "[course.charts] Expected an array of 4 objects with MD5 properties.", - ); - - t.end(); - }); - - t.test("Should reject scores not on LN mode", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send( - deepmerge(courseScore, { - score: { - lntype: 1, - }, - }), - ); - - t.equal(res.status, 400); - t.equal(res.body.success, false); - t.equal(res.body.description, "LN mode is the only supported mode for dans."); - - t.end(); - }); - - t.test("Should reject non-array constraints", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send( - deepmerge( - courseScore, - { - course: { - constraint: "foo", - }, - }, - { arrayMerge: (d, s) => s }, - ), - ); - - t.equal(res.status, 400); - t.equal(res.body.success, false); - t.equal(res.body.description, "[course.constraint] Value was not an array. (Received foo)"); - - t.end(); - }); - - t.test("Should reject invalid non lr2 gauge", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "v2.0.0") - .set("Authorization", "Bearer mock_token") - .send( - deepmerge( - courseScore, - { - course: { - constraint: ["GAUGE_5KEY", "MIRROR"], - }, - }, - { arrayMerge: (d, s) => s }, - ), - ); - - t.equal(res.status, 400); - t.equal(res.body.success, false); - t.equal(res.body.description, "Dan GAUGE mode must be GAUGE_LR2."); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/beatoraja/router.test.ts b/typescript/server/src/server/router/ir/beatoraja/router.test.ts new file mode 100644 index 000000000..da4f11355 --- /dev/null +++ b/typescript/server/src/server/router/ir/beatoraja/router.test.ts @@ -0,0 +1,191 @@ +import { seedApiToken, seedUser } from "#actions/test-utils/api-tokens"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { + BMSGazerChart, + BMSGazerSong, + MockBeatorajaBMSScore, + MockBeatorajaPMSScore, +} from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { sql } from "kysely"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const NEW_SHA256 = "769359ebb55d3d6dff3b5c6a07ec03be9b87beda1ffb0c07d7ea99590605a732"; +const NEW_MD5 = "d0f497c0f955e7edfb0278f446cdb6f8"; + +const IR_HEADERS = { + "X-TachiIR-Version": "v2.0.0", +} as const; + +afterAll(() => CloseServerConnection()); + +async function seedPmsGazerController() { + const songId = "s_pms_gazer"; + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: 1, + game_group: "pms", + title: "GPMSAZER [MANIAQ]", + artist: "Rocky", + search_terms: [], + alt_titles: [], + data: {}, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: "0446f1b54e90d631ff9fe98419ebaea9481fab1f", + legacy_id: "0446f1b54e90d631ff9fe98419ebaea9481fab1f", + game: "pms-controller", + song_id: songId, + difficulty: "CHART", + level: "?", + level_num: 0, + is_primary: true, + versions: [], + data: { + hashMD5: "d1253dd56bb2087d0b0d474f0d562aae", + hashSHA256: "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d", + notecount: 568, + tableFolders: {}, + }, + }) + .execute(); +} + +async function seedBmsGazer() { + await DB.insertInto("song") + .values({ + id: BMSGazerSong.id, + legacy_id: 27_339, + game_group: "bms", + title: BMSGazerSong.title, + artist: BMSGazerSong.artist, + search_terms: BMSGazerSong.searchTerms, + alt_titles: BMSGazerSong.altTitles, + data: BMSGazerSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: BMSGazerChart.chartID, + legacy_id: BMSGazerChart.chartID, + game: "bms-7k", + song_id: BMSGazerSong.id, + difficulty: BMSGazerChart.difficulty, + level: BMSGazerChart.level, + level_num: BMSGazerChart.levelNum, + is_primary: BMSGazerChart.isPrimary, + versions: BMSGazerChart.versions, + data: BMSGazerChart.data, + }) + .execute(); +} + +describe("POST /ir/beatoraja/submit-score (Postgres)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "beatoraja-score@example.com", + withCredential: true, + withSettings: true, + }); + await seedApiToken({ + token: "mock_token", + userId: 1, + submitScore: true, + }); + await seedBmsGazer(); + await seedPmsGazerController(); + }); + + it("imports a valid BMS score", async () => { + const res = await mockApi + .post("/ir/beatoraja/submit-score") + .set(IR_HEADERS) + .set("Authorization", "Bearer mock_token") + .send(MockBeatorajaBMSScore); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + const body = res.body.body as { + chart: { chartID: string }; + score: { game: string; importType: string | null; scoreData: { score: number } }; + song: { id: string }; + }; + expect(body.score.game).toBe("bms-7k"); + expect(body.score.scoreData.score).toBe(1004); + expect(body.score.importType).toBe("ir/beatoraja"); + expect(body.chart.chartID).toBe(BMSGazerChart.chartID); + expect(body.song.id).toBe("s27339"); + + const scoreId = res.body.body.score.scoreID as string; + const row = await DB.selectFrom("score") + .selectAll() + .where("id", "=", scoreId) + .executeTakeFirst(); + + expect(row).toBeDefined(); + }); + + it("imports a valid PMS score", async () => { + const res = await mockApi + .post("/ir/beatoraja/submit-score") + .set(IR_HEADERS) + .set("Authorization", "Bearer mock_token") + .send(MockBeatorajaPMSScore); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body?.score?.game).toBe("pms-controller"); + }); + + it("returns 400 for unsupported clients", async () => { + const res = await mockApi + .post("/ir/beatoraja/submit-score") + .set(IR_HEADERS) + .set("Authorization", "Bearer mock_token") + .send(deepmerge(MockBeatorajaBMSScore, { client: "INVALID" })); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("orphans unknown charts (202)", async () => { + const res = await mockApi + .post("/ir/beatoraja/submit-score") + .set(IR_HEADERS) + .set("Authorization", "Bearer mock_token") + .send( + deepmerge(MockBeatorajaBMSScore, { + chart: { sha256: NEW_SHA256, md5: NEW_MD5 }, + score: { sha256: NEW_SHA256, md5: NEW_MD5 }, + }), + ); + + expect(res.status).toBe(202); + expect(String(res.body.description)).toMatch(/orphan/iu); + + const o = await DB.selectFrom("orphan_chart") + .select("id") + .where(sql`(orphan_chart.chart_doc::jsonb->'data'->>'hashSHA256')`, "=", NEW_SHA256) + .executeTakeFirst(); + + expect(o).toBeDefined(); + }); + + it("requires authentication", async () => { + const res = await mockApi + .post("/ir/beatoraja/submit-score") + .set(IR_HEADERS) + .send(MockBeatorajaBMSScore); + + expect(res.status).toBe(401); + }); +}); diff --git a/typescript/server/src/server/router/ir/beatoraja/router.ts b/typescript/server/src/server/router/ir/beatoraja/router.ts index dec23dc3e..eda29427d 100644 --- a/typescript/server/src/server/router/ir/beatoraja/router.ts +++ b/typescript/server/src/server/router/ir/beatoraja/router.ts @@ -6,7 +6,7 @@ import type { import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { GetChartById } from "#lib/db-formats/chart"; import { LoadScoreDocumentById } from "#lib/db-formats/score"; -import { GetSongByLegacyID } from "#lib/db-formats/song"; +import { GetSongByID } from "#lib/db-formats/song"; import { log } from "#lib/log/log"; import { ExpressWrappedScoreImportMain } from "#lib/score-import/framework/express-wrapper"; import { ServerConfig } from "#lib/setup/config"; @@ -18,13 +18,7 @@ import { IsRecord, NotNullish } from "#utils/misc"; import { Router } from "express"; import { sql } from "kysely"; import { p } from "prudence"; -import { - type Classes, - GamePTToV3, - type GPTString, - type integer, - type Playtypes, -} from "tachi-common"; +import { type Classes, type GamesForGroup, GameToGameGroup, type integer } from "tachi-common"; import { ValidateIRClientVersion } from "./auth"; import chartsRouter from "./charts/_chartSHA256/router"; @@ -127,13 +121,11 @@ router.post("/submit-score", RequireNotGuest, async (req, res) => { }); } - const v3Game = GamePTToV3(scoreDoc.game, scoreDoc.playtype); - - const chart = await GetChartById(v3Game, scoreDoc.chartID); + const chart = await GetChartById(scoreDoc.chartID); if (!chart) { log.error( - `Expected to find a chart with chartID ${scoreDoc.chartID} for game ${v3Game}, but found none?`, + `Expected to find a chart with chartID ${scoreDoc.chartID} for game ${scoreDoc.game}, but found none?`, ); return res.status(500).json({ @@ -142,7 +134,7 @@ router.post("/submit-score", RequireNotGuest, async (req, res) => { }); } - const songRow = await GetSongByLegacyID(scoreDoc.game, scoreDoc.songID); + const songRow = await GetSongByID(GameToGameGroup(scoreDoc.game), scoreDoc.songID); if (!songRow) { log.error( @@ -283,7 +275,7 @@ router.post( const combinedMD5s = charts.map((e) => e.md5).join(""); const course = await DB.selectFrom("bms_course_lookup") - .select(["set", "playtype", "value"]) + .select(["set", "game", "value"]) .where("md5sums", "=", combinedMD5s) .executeTakeFirst(); @@ -298,9 +290,8 @@ router.post( const result = await UpdateClassIfGreater( userID, - "bms", - course.playtype as Playtypes["bms"], - course.set as Classes[GPTString], + course.game, + course.set as Classes[GamesForGroup["bms"]], course.value, ); diff --git a/typescript/server/src/server/router/ir/direct-manual/router.oldtest.ts b/typescript/server/src/server/router/ir/direct-manual/router.oldtest.ts deleted file mode 100644 index c51b6ff8e..000000000 --- a/typescript/server/src/server/router/ir/direct-manual/router.oldtest.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type { BatchManual, BatchManualScore } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { CreateFakeAuthCookie } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - FakeChunitachiBatchManual, - FakeSmallBatchManual, - GetKTDataJSON, -} from "#test-utils/test-data"; -import deepmerge from "deepmerge"; -import t from "tap"; - -t.test("POST /ir/direct-manual/import", async (t) => { - const cookie = await CreateFakeAuthCookie(mockApi); - - t.beforeEach(ResetDBState); - - t.test("Should require submit_score permissions", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - token: "foo", - identifier: "bar", - permissions: { - submit_score: false, - }, - userID: 1, - fromAPIClient: null, - }); - - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.success, false); - - t.match(res.body.description, /submit_score/u); - - t.end(); - }); - - t.test("Should upload BATCH-MANUAL data from the request body.", async (t) => { - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Cookie", cookie) - .send(FakeSmallBatchManual); - - t.equal(res.body.success, true, "Should be successful"); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scoreCount = await MONGODB_KILL.scores.count({ service: "foobar (DIRECT-MANUAL)" }); - - t.equal(scoreCount, 1, "Should import one score."); - - t.end(); - }); - - t.test("Should reject invalid BATCH-MANUAL data from the request body.", async (t) => { - const res = await mockApi.post("/ir/direct-manual/import").set("Cookie", cookie).send({}); - - t.equal(res.body.success, false, "Should not be successful"); - - t.end(); - }); - - t.test("Should require authentication.", async (t) => { - const res = await mockApi.post("/ir/direct-manual/import").send(FakeSmallBatchManual); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("Should require a valid auth token.", async (t) => { - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", "Bearer invalid_token") - .send(FakeSmallBatchManual); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.beforeEach(() => - MONGODB_KILL["api-tokens"].insert({ - token: "mock_token", - fromAPIClient: null, - identifier: "Mock CHUNITACHI Token", - permissions: { submit_score: true }, - userID: 1, - }), - ); - - t.test("Should work for CHUNITACHI requests", async (t) => { - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", `Bearer mock_token`) - .send(FakeChunitachiBatchManual); - - t.equal(res.body.success, true, "Should be successful"); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scoreCount = await MONGODB_KILL.scores.count({ - service: "ChunItachi (DIRECT-MANUAL)", - }); - - t.equal(scoreCount, 1, "Should import one score."); - - t.end(); - }); - - t.test("Should reject invalid batch-manual", async (t) => { - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", `Bearer mock_token`) - .send({}); - - t.equal(res.body.success, false, "Should not be successful"); - - t.end(); - }); - - t.test("Should require authentication.", async (t) => { - const res = await mockApi.post("/ir/direct-manual/import").send(FakeChunitachiBatchManual); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("Should require a valid auth token.", async (t) => { - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", "Bearer invalid_token") - .send(FakeChunitachiBatchManual); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.test("End To End Tests", (t) => { - const baseBatchManual: BatchManual = { - meta: { - game: "iidx", - playtype: "SP", - service: "Foo", - }, - scores: [], - }; - - t.test("Should reject decimal values for score.", async (t) => { - const bmScore: BatchManualScore = { - identifier: "1", - lamp: "CLEAR", - matchType: "tachiSongID", - difficulty: "ANOTHER", - score: 123.5, - }; - - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", `Bearer mock_token`) - .send(deepmerge(baseBatchManual, { scores: [bmScore] })); - - t.equal(res.body.success, false, "Should not be successful"); - t.match( - res.body.description, - /Invalid BATCH-MANUAL: scores\[0\].score \| Expected an integer. \| Received 123\.5/iu, - ); - - t.end(); - }); - - t.test("Should apply chart specific validators for scores.", async (t) => { - const bmScore: BatchManualScore = { - identifier: "1", - lamp: "CLEAR", - matchType: "tachiSongID", - difficulty: "ANOTHER", - // greater than the max possible score on this chart - score: 9000, - }; - - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", `Bearer mock_token`) - .send(deepmerge(baseBatchManual, { scores: [bmScore] })); - - t.equal(res.body.success, true, "Should be successful but with one failed score"); - t.strictSame( - res.body.body.errors, - [ - { - type: "InvalidDatapoint", - message: `Got 2 errors when validating score: -Invalid value for score, EX Score cannot be greater than 1572 for this chart. Got 9000. -Invalid value for percent, Expected a number between 0 and 100. Got 572.5190839694657.`, - }, - ], - "Should have one failed score.", - ); - - t.end(); - }); - - t.test("Should reject negative values for jubeat scores.", async (t) => { - const bmScore: BatchManualScore = { - identifier: "1", - lamp: "CLEAR", - matchType: "tachiSongID", - difficulty: "EXT", - score: -100, - musicRate: 50, - }; - - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", `Bearer mock_token`) - .send( - deepmerge(baseBatchManual, { - scores: [bmScore], - meta: { - game: "jubeat", - playtype: "Single", - }, - }), - ); - - t.equal(res.body.success, false, "Should not be successful"); - t.match( - res.body.description, - "Invalid BATCH-MANUAL: scores[0].score | Expected a number between 0 and 1000000. | Received -100 [type: number].", - ); - - t.end(); - }); - - t.test("Should reject unknown games.", async (t) => { - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", `Bearer mock_token`) - .send(deepmerge(baseBatchManual, { meta: { game: "nonsense" } })); - - t.equal(res.body.success, false, "Should not be successful"); - t.match(res.body.description, /Invalid game 'nonsense'/iu); - - t.end(); - }); - - t.test("Should reject invalid playtypes.", async (t) => { - const res = await mockApi - .post("/ir/direct-manual/import") - .set("Authorization", `Bearer mock_token`) - .send(deepmerge(baseBatchManual, { meta: { playtype: "nonsense" } })); - - t.equal(res.body.success, false, "Should not be successful"); - t.match(res.body.description, /Invalid playtype 'nonsense'/iu); - - t.end(); - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/direct-manual/router.test.ts b/typescript/server/src/server/router/ir/direct-manual/router.test.ts new file mode 100644 index 000000000..7ecad4768 --- /dev/null +++ b/typescript/server/src/server/router/ir/direct-manual/router.test.ts @@ -0,0 +1,317 @@ +import type { BatchManual, BatchManualScore, V3Game } from "tachi-common"; + +import { seedApiToken, seedUser } from "#actions/test-utils/api-tokens"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { + CHUNITHMBBKKChart, + CHUNITHMBBKKSong, + FakeChunitachiBatchManual, + FakeSmallBatchManual, + Testing511Song, + Testing511SPA, +} from "#test-utils/test-data"; +import deepmerge from "deepmerge"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +async function seed511() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 1, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: Testing511SPA.difficulty, + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: Testing511SPA.data, + }) + .execute(); +} + +async function seedChunithmBbkk() { + await DB.insertInto("song") + .values({ + id: CHUNITHMBBKKSong.id, + legacy_id: 900_001, + game_group: "chunithm", + title: CHUNITHMBBKKSong.title, + artist: CHUNITHMBBKKSong.artist, + search_terms: CHUNITHMBBKKSong.searchTerms, + alt_titles: CHUNITHMBBKKSong.altTitles, + data: CHUNITHMBBKKSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: CHUNITHMBBKKChart.chartID, + legacy_id: CHUNITHMBBKKChart.chartID, + game: "chunithm", + song_id: CHUNITHMBBKKSong.id, + difficulty: CHUNITHMBBKKChart.difficulty, + level: CHUNITHMBBKKChart.level, + level_num: CHUNITHMBBKKChart.levelNum, + is_primary: CHUNITHMBBKKChart.isPrimary, + versions: CHUNITHMBBKKChart.versions, + data: CHUNITHMBBKKChart.data, + }) + .execute(); +} + +async function countScoresForGame(game: V3Game) { + const row = await DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("c")) + .where("user_id", "=", 1) + .where("game", "=", game) + .executeTakeFirst(); + return Number(row?.c ?? 0); +} + +describe("POST /ir/direct-manual/import (Postgres)", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ + username: "dm_ir_user", + email: "dm-ir@example.com", + withCredential: true, + withSettings: true, + }); + cookie = await loginAs("dm_ir_user"); + await seed511(); + await seedChunithmBbkk(); + }); + + it("requires submit_score on the token", async () => { + await seedApiToken({ + token: "foo", + userId: 1, + submitScore: false, + }); + + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer foo"); + + expect(res.body.success).toBe(false); + expect(String(res.body.description)).toMatch(/submit_score/iu); + }); + + it("imports BATCH-MANUAL via session cookie", async () => { + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Cookie", cookie) + .send(FakeSmallBatchManual); + + expect(res.body.success).toBe(true); + expect(res.body.body.errors.length).toBe(0); + + const n = await countScoresForGame("iidx-sp"); + expect(n).toBe(1); + }); + + it("rejects invalid BATCH-MANUAL bodies", async () => { + const res = await mockApi.post("/ir/direct-manual/import").set("Cookie", cookie).send({}); + + expect(res.body.success).toBe(false); + }); + + it("requires authentication", async () => { + const res = await mockApi.post("/ir/direct-manual/import").send(FakeSmallBatchManual); + + expect(res.statusCode).toBe(401); + }); + + it("rejects invalid bearer tokens", async () => { + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer invalid_token") + .send(FakeSmallBatchManual); + + expect(res.statusCode).toBe(401); + }); + + it("imports CHUNITACHI via bearer token", async () => { + await seedApiToken({ + token: "mock_token", + userId: 1, + submitScore: true, + }); + + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer mock_token") + .send(FakeChunitachiBatchManual); + + expect(res.body.success).toBe(true); + expect(res.body.body.errors.length).toBe(0); + + const n = await countScoresForGame("chunithm"); + expect(n).toBe(1); + }); + + it("rejects empty batch-manual with bearer token", async () => { + await seedApiToken({ + token: "mock_token", + userId: 1, + submitScore: true, + }); + + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer mock_token") + .send({}); + + expect(res.body.success).toBe(false); + }); +}); + +describe("POST /ir/direct-manual/import (end-to-end validation)", () => { + beforeEach(async () => { + await seedUser({ + username: "dm_e2e", + email: "dm-e2e@example.com", + withCredential: true, + withSettings: true, + }); + await seed511(); + await seedApiToken({ + token: "mock_token", + userId: 1, + submitScore: true, + }); + }); + + const baseBatchManual: BatchManual = { + meta: { + game: "iidx", + playtype: "SP", + service: "Foo", + }, + scores: [], + }; + + it("rejects decimal scores", async () => { + const bmScore: BatchManualScore = { + identifier: "1", + lamp: "CLEAR", + matchType: "tachiSongID", + difficulty: "ANOTHER", + score: 123.5, + }; + + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer mock_token") + .send(deepmerge(baseBatchManual, { scores: [bmScore] })); + + expect(res.body.success).toBe(false); + expect(String(res.body.description)).toMatch( + /Invalid BATCH-MANUAL: scores\[0\].score \| Expected an integer\. \| Received 123\.5/iu, + ); + }); + + it("records chart validation errors without failing the HTTP request", async () => { + const bmScore: BatchManualScore = { + identifier: "5.1.1.", + lamp: "CLEAR", + matchType: "songTitle", + difficulty: "ANOTHER", + score: 9000, + }; + + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer mock_token") + .send(deepmerge(baseBatchManual, { scores: [bmScore] })); + + expect(res.body.success).toBe(true); + expect(res.body.body.errors).toEqual([ + { + type: "InvalidDatapoint", + message: `Got 2 errors when validating score: +Invalid value for score, EX Score cannot be greater than 1572 for this chart. Got 9000. +Invalid value for percent, Expected a number between 0 and 100. Got 572.5190839694657.`, + }, + ]); + }); + + it("rejects out-of-range jubeat scores", async () => { + const bmScore: BatchManualScore = { + identifier: "1", + lamp: "CLEAR", + matchType: "tachiSongID", + difficulty: "EXT", + score: -100, + musicRate: 50, + }; + + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer mock_token") + .send( + deepmerge(baseBatchManual, { + scores: [bmScore], + meta: { + game: "jubeat", + playtype: "Single", + }, + }), + ); + + expect(res.body.success).toBe(false); + expect(String(res.body.description)).toBe( + "Invalid BATCH-MANUAL: scores[0].score | Expected a number between 0 and 1000000. | Received -100 [type: number].", + ); + }); + + it("rejects unknown games", async () => { + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer mock_token") + .send(deepmerge(baseBatchManual, { meta: { game: "nonsense" } })); + + expect(res.body.success).toBe(false); + expect(String(res.body.description)).toMatch(/Invalid game group 'nonsense'/iu); + }); + + it("rejects unknown playtypes", async () => { + const res = await mockApi + .post("/ir/direct-manual/import") + .set("Authorization", "Bearer mock_token") + .send(deepmerge(baseBatchManual, { meta: { playtype: "nonsense" } })); + + expect(res.body.success).toBe(false); + expect(String(res.body.description)).toMatch(/Invalid playtype 'nonsense'/iu); + }); +}); diff --git a/typescript/server/src/server/router/ir/fervidex/router.oldtest.ts b/typescript/server/src/server/router/ir/fervidex/router.oldtest.ts deleted file mode 100644 index 423917b4f..000000000 --- a/typescript/server/src/server/router/ir/fervidex/router.oldtest.ts +++ /dev/null @@ -1,652 +0,0 @@ -import type { ChartDocument, GPTStrings, SongDocument } from "tachi-common"; - -import DB from "#services/pg/db"; -import MONGODB_KILL from "#services/mongo/db"; -import { InsertFakeTokenWithAllPerms } from "#test-utils/fake-auth"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { - FervidexBaseGSMScore, - FervidexBaseScore, - FervidexStaticBase, - GetKTDataJSON, -} from "#test-utils/test-data"; -import { Random20Hex, Sleep } from "#utils/misc"; -import deepmerge from "deepmerge"; -import t from "tap"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function TestHeaders(url: string, data: any) { - t.test("Should validate against card filters", async (t) => { - await DB.deleteFrom("priv_svc_fer_card").where("user_id", "=", 1).execute(); - await DB.deleteFrom("svc_fer_settings").where("user_id", "=", 1).execute(); - await DB.insertInto("priv_svc_fer_card").values({ user_id: 1, card_id: "foo" }).execute(); - - const res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // rootage - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .set("X-Account-Id", "bar") - .set("User-Agent", "fervidex/1.3.0") - .send(data); - - t.equal(res.body.success, false, "Should reject invalid card."); - - const res2 = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // rootage - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .set("User-Agent", "fervidex/1.3.0") - .send(data); - - t.equal(res2.body.success, false, "Should reject no card."); - - t.end(); - }); - - t.test("Should allow certain models", async (t) => { - await DB.deleteFrom("priv_svc_fer_card").where("user_id", "=", 1).execute(); - await DB.deleteFrom("svc_fer_settings").where("user_id", "=", 1).execute(); - - for (const [ext, name] of [ - ["2022082400", "CastHour"], - ["2021091500", "Bistrover"], - ["2020092900", "HEROIC VERSE"], - ]) { - // unfathomably stupid race-condition protection - // tap *really* doesn't like tests being invoked dynamically - // like this (TestHeaders) - // so it runs them kind of in parallel - // even though we don't want them to do that - const token = `mock_token_${Random20Hex()}`; - - await InsertFakeTokenWithAllPerms(token)(); - - const res = await mockApi - .post(url) - .set("Authorization", `Bearer ${token}`) - - // CastHour - .set("X-Software-Model", `LDJ:J:B:A:${ext}`) - .set("User-Agent", "fervidex/1.3.0") - .send(data); - - t.equal(res.body.success, true, `Should allow ${name} clients`); - - await MONGODB_KILL["api-tokens"].remove({ key: token }); - } - - t.end(); - }); - - t.test("Should allow 30-omni", async (t) => { - await DB.deleteFrom("priv_svc_fer_card").where("user_id", "=", 1).execute(); - await DB.deleteFrom("svc_fer_settings").where("user_id", "=", 1).execute(); - - const token = `mock_token_${Random20Hex()}`; - - await InsertFakeTokenWithAllPerms(token)(); - - const res = await mockApi - .post(url) - .set("Authorization", `Bearer ${token}`) - .set("X-Software-Model", "LDJ:J:B:X:2023090500") - .set("User-Agent", "fervidex/1.3.0") - .send(data); - - t.equal(res.body.success, true, "Should allow 30-omni"); - - t.end(); - }); - - t.test("Should reject invalid X-Software-Models", async (t) => { - let res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // rootage - .set("X-Software-Model", "LDJ:J:B:A:2019090200") - .set("User-Agent", "fervidex/1.3.0") - .send(data); - - t.equal(res.body.success, false, "Should reject rootage clients"); - - res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // rootage old - .set("X-Software-Model", "LDJ:J:B:A:2019100700") - .set("User-Agent", "fervidex/1.3.0") - .send(data); - - t.equal(res.body.success, false, "Should reject rootage clients"); - - res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // cannonballers - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2018091900") - .send(data); - - t.equal(res.body.success, false, "Should reject cannonballers clients"); - - res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:NONSENSE") - .send(data); - - t.equal(res.body.success, false, "Should reject nonsense versions"); - - res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // BMS-iidx - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:Z:2020092900") - .send(data); - - t.equal(res.body.success, false, "Should reject BMS-iidx clients"); - - res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // BMS-iidx - .set("User-Agent", "fervidex/1.2.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(data); - - t.equal(res.body.success, false, "Should reject outdated fervidex clients"); - - res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // BMS-iidx - .set("User-Agent", "fervidex/.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(data); - - t.equal(res.body.success, false, "Should reject invalid fervidex clients"); - - res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // BMS-iidx - .set("User-Agent", "") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(data); - - t.equal(res.body.success, false, "Should reject invalid fervidex clients"); - - res = await mockApi - .post(url) - .set("Authorization", "Bearer mock_token") - - // BMS-iidx - .set("User-Agent", "invalid") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(data); - - t.equal(res.body.success, false, "Should reject invalid fervidex clients"); - - t.end(); - }); - - t.test("Should require authorization.", async (t) => { - const res = await mockApi - .post(url) - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .set("User-Agent", "fervidex/1.3.0") - .send(data); - - t.equal(res.status, 401, "Should return 401."); - t.type(res.body.error, "string", "Should have an error message."); - - const res2 = await mockApi - .post(url) - .set("Authorization", "Bearer invalid_token") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .set("User-Agent", "fervidex/1.3.0") - .send(data); - - t.equal(res2.status, 401, "Should return 401."); - t.type(res2.body.error, "string", "Should have an error message."); - - t.end(); - }); -} - -t.test("POST /ir/fervidex/class/submit", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(InsertFakeTokenWithAllPerms("mock_token")); - - TestHeaders("/ir/fervidex/class/submit", { cleared: true, course_id: 18, play_style: 0 }); - - t.test("Should update a users class.", async (t) => { - const res = await mockApi - .post("/ir/fervidex/class/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send({ cleared: true, course_id: 18, play_style: 0 }); - - t.equal(res.status, 200); - t.equal(res.body.success, true); - t.equal(res.body.description, "Dan changed!"); - - const ugs = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(ugs?.classes.dan, "KAIDEN"); - - const recentAchievement = await MONGODB_KILL["class-achievements"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.hasStrict(recentAchievement, { - userID: 1, - game: "iidx", - playtype: "SP", - classValue: "KAIDEN", - classSet: "dan", - classOldValue: null, - }); - - t.end(); - }); - - t.test("Should update a users class for DP.", async (t) => { - const res = await mockApi - .post("/ir/fervidex/class/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send({ cleared: true, course_id: 17, play_style: 1 }); - - t.equal(res.status, 200); - t.equal(res.body.success, true); - t.equal(res.body.description, "Dan changed!"); - - const ugs = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "iidx", - playtype: "DP", - }); - - t.equal(ugs?.classes.dan, "CHUUDEN"); - - t.end(); - }); - - t.test("Should ignore dans that weren't cleared.", async (t) => { - const res = await mockApi - .post("/ir/fervidex/class/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send({ cleared: false, course_id: 17, play_style: 1 }); - - t.equal(res.status, 200); - t.equal(res.body.description, "No Update Made."); - - t.end(); - }); - - t.test("Should reject invalid dans.", async (t) => { - const res = await mockApi - .post("/ir/fervidex/class/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send({ cleared: true, course_id: null, play_style: 1 }); - - t.equal(res.status, 400); - t.match( - res.body.error, - "[course_id] Expected an integer between 0 and 18. (Received null)", - ); - - t.end(); - }); - - t.test("Should reject invalid numerical dans.", async (t) => { - const res = await mockApi - .post("/ir/fervidex/class/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send({ cleared: true, course_id: 20, play_style: 1 }); - - t.equal(res.status, 400); - t.match(res.body.error, "[course_id] Expected an integer between 0 and 18. (Received 20)"); - - t.end(); - }); - - t.test("Should reject invalid negative dans.", async (t) => { - const res = await mockApi - .post("/ir/fervidex/class/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send({ cleared: true, course_id: -1, play_style: 1 }); - - t.equal(res.status, 400); - t.match(res.body.error, "[course_id] Expected an integer between 0 and 18. (Received -1)"); - - t.end(); - }); - - t.test("Should reject invalid play_styles.", async (t) => { - const res = await mockApi - .post("/ir/fervidex/class/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send({ cleared: true, course_id: 16, play_style: null }); - - t.equal(res.status, 400); - t.match(res.body.error, "[play_style] Expected any of 0, 1. (Received null)"); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /ir/fervidex/score/submit", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(InsertFakeTokenWithAllPerms("mock_token")); - - TestHeaders("/ir/fervidex/score/submit", FervidexBaseScore); - - t.test("Should import a valid score", async (t) => { - const res = await mockApi - .post("/ir/fervidex/score/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(FervidexBaseScore); - - t.equal(res.body.success, true, "Should be successful"); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scores = await MONGODB_KILL.scores.count({ - service: "Fervidex", - }); - - t.equal(scores, 1, "Should import 1 score."); - t.equal(res.body.body.userIntent, false, "Should not have user intent."); - - t.end(); - }); - - t.test("Should import a valid score with undefined options", async (t) => { - const res = await mockApi - .post("/ir/fervidex/score/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(deepmerge(FervidexBaseScore, { option: undefined })); - - t.equal(res.body.success, true, "Should be successful"); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scores = await MONGODB_KILL.scores.count({ - service: "Fervidex", - }); - - t.equal(scores, 1, "Should import 1 score."); - - t.end(); - }); - - t.test("Should import a valid score with 2dx-gsm", async (t) => { - const res = await mockApi - .post("/ir/fervidex/score/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(FervidexBaseGSMScore); - - t.equal(res.body.success, true, "Should be successful"); - - t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); - - const scores = await MONGODB_KILL.scores.count({ - service: "Fervidex", - }); - - t.equal(scores, 1, "Should import 1 score."); - - t.end(); - }); - - t.test("Should reject an invalid body", async (t) => { - const res = await mockApi - .post("/ir/fervidex/score/submit") - .set("User-Agent", "fervidex/1.3.0") - .set("Authorization", "Bearer mock_token") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send({}); - - t.equal(res.body.success, false, "Should not be successful"); - - t.type(res.body.error, "string", "Should have an error prop that is a string."); - - t.end(); - }); - - t.test("Should reject an invalid exscore", async (t) => { - const res = await mockApi - .post("/ir/fervidex/score/submit") - .set("User-Agent", "fervidex/1.3.0") - .set("Authorization", "Bearer mock_token") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send( - deepmerge(FervidexBaseScore, { - ex_score: 9999, - }), - ); - - t.equal(res.statusCode, 200, "Should pass validation."); - - await Sleep(2000); - - const imp = await MONGODB_KILL.imports.findOne({ - importID: res.body.body.importID, - }); - - t.strictSame(imp?.errors, [ - { - type: "InvalidDatapoint", - message: `Got 3 errors when validating score: -Invalid value for score, EX Score cannot be greater than 1572 for this chart. Got 9999. -Invalid value for percent, Expected a number between 0 and 100. Got 636.0687022900763. -Expected PGreat*2 + Great to equal EX score. Got 34*2 + 0 but that wasn't equal to the EX score of 9999.`, - }, - ]); - - t.end(); - }); - - t.test("Should reject an invalid gauge", async (t) => { - const res = await mockApi - .post("/ir/fervidex/score/submit") - .set("User-Agent", "fervidex/1.3.0") - .set("Authorization", "Bearer mock_token") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(deepmerge(FervidexBaseScore, { gauge: [150] })); - - t.equal(res.statusCode, 200, "Should pass validation."); - - await Sleep(2000); - - const imp = await MONGODB_KILL.imports.findOne({ - importID: res.body.body.importID, - }); - - t.strictSame(imp?.errors, [ - { - type: "InvalidDatapoint", - message: "Invalid value of gauge 150.", - }, - ]); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /ir/fervidex/profile/submit", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(InsertFakeTokenWithAllPerms("mock_token")); - - TestHeaders("/ir/fervidex/profile/submit", FervidexStaticBase); - - t.test("Should accept a fervidex-static body", async (t) => { - await MONGODB_KILL.songs.iidx.remove({}); - await MONGODB_KILL.songs.iidx.insert( - GetKTDataJSON("./tachi/tachi-songs-iidx.json") as Array>, - ); - await MONGODB_KILL.charts.iidx.remove({}); - await MONGODB_KILL.charts.iidx.insert( - GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array< - ChartDocument - >, - ); - - const res = await mockApi - .post("/ir/fervidex/profile/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "P2D:J:B:A:2020092900") - .send(FervidexStaticBase); - - t.equal(res.statusCode, 202, "Should be deferred for later processing."); - - // Not sure how long this import should take, but sleeping for 5 seconds - // seems like a very conservative estimate. - // Then we can check whether the database has properly updated. - await Sleep(5_000); - - const scores = await MONGODB_KILL.scores.count({ - service: "Fervidex Static", - }); - - t.equal(scores, 3, "Should import 3 scores."); - - const ugs = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(ugs!.classes.dan, "DAN_9", "Should successfully update dan to 9th."); - - t.end(); - }); - - t.test("Should allow requests from non INF2 if forceStaticImport is true.", async (t) => { - await MONGODB_KILL.songs.iidx.remove({}); - await MONGODB_KILL.songs.iidx.insert( - GetKTDataJSON("./tachi/tachi-songs-iidx.json") as Array>, - ); - await MONGODB_KILL.charts.iidx.remove({}); - await MONGODB_KILL.charts.iidx.insert( - GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array< - ChartDocument - >, - ); - - await DB.insertInto("svc_fer_settings") - .values({ user_id: 1, force_static_import: true }) - .onConflict((oc) => oc.column("user_id").doUpdateSet({ force_static_import: true })) - .execute(); - - const res = await mockApi - .post("/ir/fervidex/profile/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(FervidexStaticBase); - - t.equal(res.statusCode, 202, "Should be deferred for later processing."); - - // See above. - await Sleep(5_000); - - const scores = await MONGODB_KILL.scores.count({ - service: "Fervidex Static", - }); - - t.equal(scores, 3, "Should import 3 scores."); - - const ugs = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "iidx", - playtype: "SP", - }); - - t.equal(ugs!.classes.dan, "DAN_9", "Should successfully update dan to 9th."); - - const dbRes = await DB.selectFrom("svc_fer_settings") - .selectAll() - .where("user_id", "=", 1) - .executeTakeFirst(); - - t.equal( - dbRes?.force_static_import, - false, - "forceStaticImport should be set to false after the request.", - ); - - t.end(); - }); - - t.test("Should allow requests from non INF2 if forceStaticImport is false.", async (t) => { - await DB.insertInto("svc_fer_settings") - .values({ user_id: 1, force_static_import: false }) - .onConflict((oc) => oc.column("user_id").doUpdateSet({ force_static_import: false })) - .execute(); - - const res = await mockApi - .post("/ir/fervidex/profile/submit") - .set("Authorization", "Bearer mock_token") - .set("User-Agent", "fervidex/1.3.0") - .set("X-Software-Model", "LDJ:J:B:A:2020092900") - .send(FervidexStaticBase); - - t.equal(res.statusCode, 202, "Should be allowed, but no scores should be imported"); - - const scoresNow = await MONGODB_KILL.scores.count({ - service: "Fervidex Static", - }); - - t.strictSame(scoresNow, 0, "Should have imported no scores"); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/fervidex/router.test.ts b/typescript/server/src/server/router/ir/fervidex/router.test.ts new file mode 100644 index 000000000..bc143fc71 --- /dev/null +++ b/typescript/server/src/server/router/ir/fervidex/router.test.ts @@ -0,0 +1,438 @@ +import { seedApiToken, seedUser } from "#actions/test-utils/api-tokens"; +import { LoadImportDocumentById } from "#lib/db-formats/import-document"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { + FervidexBaseGSMScore, + FervidexBaseScore, + Testing511Song, + Testing511SPA, +} from "#test-utils/test-data"; +import { Random20Hex } from "#utils/misc"; +import deepmerge from "deepmerge"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const ROOTAGE_MODEL = "LDJ:J:B:A:2020092900"; +const FER_AGENT = "fervidex/1.3.0"; + +function ferHeaders(model = ROOTAGE_MODEL) { + return { + Authorization: "Bearer mock_token", + "User-Agent": FER_AGENT, + "X-Software-Model": model, + } as const; +} + +async function seedUserAndToken() { + await seedUser({ + username: "test_zkldi", + email: "fervidex-ir@example.com", + withCredential: true, + withSettings: true, + }); + await seedApiToken({ + token: "mock_token", + userId: 1, + submitScore: true, + }); +} + +/** IIDX SPA chart resolvable by fervidex `chart_sha256: "asdfasdf"`. */ +async function seedIidx511SpaWithFervidexHash() { + await DB.insertInto("song") + .values({ + id: Testing511Song.id, + legacy_id: 511, + game_group: "iidx", + title: Testing511Song.title, + artist: Testing511Song.artist, + search_terms: Testing511Song.searchTerms, + alt_titles: Testing511Song.altTitles, + data: Testing511Song.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: Testing511SPA.chartID, + legacy_id: Testing511SPA.chartID, + game: "iidx-sp", + song_id: Testing511Song.id, + difficulty: "ANOTHER", + level: Testing511SPA.level, + level_num: Testing511SPA.levelNum, + is_primary: Testing511SPA.isPrimary, + versions: Testing511SPA.versions, + data: { + ...Testing511SPA.data, + hashSHA256: "asdfasdf", + }, + }) + .execute(); +} + +async function clearFerCardsAndSettings() { + await DB.deleteFrom("priv_svc_fer_card").where("user_id", "=", 1).execute(); + await DB.deleteFrom("svc_fer_settings").where("user_id", "=", 1).execute(); +} + +afterAll(() => CloseServerConnection()); + +describe("POST /ir/fervidex/class/submit (Postgres)", () => { + describe("headers (card + model + auth)", () => { + beforeEach(async () => { + await seedUserAndToken(); + await seedIidx511SpaWithFervidexHash(); + }); + + it("rejects when card filters require X-Account-Id", async () => { + await clearFerCardsAndSettings(); + await DB.insertInto("priv_svc_fer_card") + .values({ user_id: 1, card_id: "foo" }) + .execute(); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders()) + .set("X-Account-Id", "bar") + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.body.success).toBe(false); + }); + + it("rejects when card filters exist but no card header", async () => { + await clearFerCardsAndSettings(); + await DB.insertInto("priv_svc_fer_card") + .values({ user_id: 1, card_id: "foo" }) + .execute(); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders()) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.body.success).toBe(false); + }); + + it.each([ + ["2022082400", "CastHour"], + ["2021091500", "Bistrover"], + ["2020092900", "HEROIC VERSE"], + ] as const)("allows software model ext %s (%s)", async (ext, _label) => { + await clearFerCardsAndSettings(); + const token = `mock_token_${Random20Hex()}`; + await seedApiToken({ token, userId: 1, submitScore: true }); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set("Authorization", `Bearer ${token}`) + .set("User-Agent", FER_AGENT) + .set("X-Software-Model", `LDJ:J:B:A:${ext}`) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.body.success).toBe(true); + await DB.deleteFrom("priv_api_token").where("token", "=", token).execute(); + }); + + it("allows 30-omni", async () => { + await clearFerCardsAndSettings(); + const token = `mock_token_${Random20Hex()}`; + await seedApiToken({ token, userId: 1, submitScore: true }); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set("Authorization", `Bearer ${token}`) + .set("User-Agent", FER_AGENT) + .set("X-Software-Model", "LDJ:J:B:X:2023090500") + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.body.success).toBe(true); + await DB.deleteFrom("priv_api_token").where("token", "=", token).execute(); + }); + + it.each([ + ["LDJ:J:B:A:2019090200", "rootage v1"], + ["LDJ:J:B:A:2019100700", "rootage old"], + ["LDJ:J:B:A:2018091900", "cannonballers"], + ] as const)("accepts arcade model %s (%s)", async (model, _label) => { + await clearFerCardsAndSettings(); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders(model)) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.body.success).toBe(true); + }); + + it.each([ + ["LDJ:J:B:A:NONSENSE", "nonsense"], + ["LDJ:J:B:Z:2020092900", "2dx-bms"], + ] as const)("rejects invalid X-Software-Model %s (%s)", async (model, _label) => { + await clearFerCardsAndSettings(); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders(model)) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.body.success).toBe(false); + }); + + it("rejects fervidex client older than 1.3.0", async () => { + await clearFerCardsAndSettings(); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set("Authorization", "Bearer mock_token") + .set("User-Agent", "fervidex/1.2.0") + .set("X-Software-Model", ROOTAGE_MODEL) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.body.success).toBe(false); + }); + + it.each([ + ["fervidex/.0", "invalid agent"], + ["", "empty agent"], + ["invalid", "non-fervidex agent"], + ] as const)("rejects bad User-Agent: %s", async (ua, _label) => { + await clearFerCardsAndSettings(); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set("Authorization", "Bearer mock_token") + .set("User-Agent", ua) + .set("X-Software-Model", ROOTAGE_MODEL) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.body.success).toBe(false); + }); + + it("requires authorization", async () => { + await clearFerCardsAndSettings(); + + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set("User-Agent", FER_AGENT) + .set("X-Software-Model", ROOTAGE_MODEL) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.status).toBe(401); + + const res2 = await mockApi + .post("/ir/fervidex/class/submit") + .set("Authorization", "Bearer invalid_token") + .set("User-Agent", FER_AGENT) + .set("X-Software-Model", ROOTAGE_MODEL) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res2.status).toBe(401); + }); + }); + + describe("dan & body validation", () => { + beforeEach(async () => { + await seedUserAndToken(); + await clearFerCardsAndSettings(); + }); + + it("updates SP dan to KAIDEN for course_id 18", async () => { + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders()) + .send({ cleared: true, course_id: 18, play_style: 0 }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.description).toBe("Dan changed!"); + + const gp = await DB.selectFrom("game_profile") + .select("classes") + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + + const classes = typeof gp.classes === "string" ? JSON.parse(gp.classes) : gp.classes; + expect(classes.dan).toBe("KAIDEN"); + + const ach = await DB.selectFrom("class_achievement") + .selectAll() + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .orderBy("timestamp", "desc") + .executeTakeFirstOrThrow(); + + expect(ach).toMatchObject({ + user_id: 1, + game: "iidx-sp", + class_value: "KAIDEN", + class_set: "dan", + }); + }); + + it("updates DP dan for course_id 17", async () => { + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders()) + .send({ cleared: true, course_id: 17, play_style: 1 }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const gp = await DB.selectFrom("game_profile") + .select("classes") + .where("user_id", "=", 1) + .where("game", "=", "iidx-dp") + .executeTakeFirstOrThrow(); + + const classes = typeof gp.classes === "string" ? JSON.parse(gp.classes) : gp.classes; + expect(classes.dan).toBe("CHUUDEN"); + }); + + it("returns No Update Made when cleared is false", async () => { + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders()) + .send({ cleared: false, course_id: 17, play_style: 1 }); + + expect(res.status).toBe(200); + expect(res.body.description).toBe("No Update Made."); + }); + + it.each([ + [null, "[course_id] Expected an integer between 0 and 18. (Received null)"], + [20, "[course_id] Expected an integer between 0 and 18. (Received 20)"], + [-1, "[course_id] Expected an integer between 0 and 18. (Received -1)"], + ] as const)("rejects invalid course_id %s", async (course_id, errSubstring) => { + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders()) + .send({ cleared: true, course_id, play_style: 1 }); + + expect(res.status).toBe(400); + expect(String(res.body.error)).toContain(errSubstring); + }); + + it("rejects invalid play_style", async () => { + const res = await mockApi + .post("/ir/fervidex/class/submit") + .set(ferHeaders()) + .send({ cleared: true, course_id: 16, play_style: null }); + + expect(res.status).toBe(400); + expect(String(res.body.error)).toContain("[play_style]"); + }); + }); +}); + +describe("POST /ir/fervidex/score/submit (Postgres)", () => { + beforeEach(async () => { + await seedUserAndToken(); + await seedIidx511SpaWithFervidexHash(); + await clearFerCardsAndSettings(); + }); + + it("requires authorization", async () => { + const res = await mockApi + .post("/ir/fervidex/score/submit") + .set("User-Agent", FER_AGENT) + .set("X-Software-Model", ROOTAGE_MODEL) + .send(FervidexBaseScore); + + expect(res.status).toBe(401); + }); + + it("imports a valid score", async () => { + const res = await mockApi + .post("/ir/fervidex/score/submit") + .set(ferHeaders()) + .send(FervidexBaseScore); + + expect(res.body.success).toBe(true); + expect(res.body.body.errors.length).toBe(0); + expect(res.body.body.userIntent).toBe(false); + + const row = await DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("c")) + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirst(); + + expect(Number(row?.c)).toBe(1); + }); + + it("imports when option is undefined", async () => { + const res = await mockApi + .post("/ir/fervidex/score/submit") + .set(ferHeaders()) + .send(deepmerge(FervidexBaseScore, { option: undefined })); + + expect(res.body.success).toBe(true); + expect(res.body.body.errors.length).toBe(0); + + const row = await DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("c")) + .where("user_id", "=", 1) + .where("game", "=", "iidx-sp") + .executeTakeFirst(); + + expect(Number(row?.c)).toBe(1); + }); + + it("imports 2dx-gsm score", async () => { + const res = await mockApi + .post("/ir/fervidex/score/submit") + .set(ferHeaders()) + .send(FervidexBaseGSMScore); + + expect(res.body.success).toBe(true); + expect(res.body.body.errors.length).toBe(0); + }); + + it("rejects an invalid body", async () => { + const res = await mockApi.post("/ir/fervidex/score/submit").set(ferHeaders()).send({}); + + expect(res.body.success).toBe(false); + expect(typeof res.body.error === "string" || typeof res.body.description === "string").toBe( + true, + ); + }); + + it("records import errors for invalid ex_score after import finalises", async () => { + const res = await mockApi + .post("/ir/fervidex/score/submit") + .set(ferHeaders()) + .send(deepmerge(FervidexBaseScore, { ex_score: 9999 })); + + expect(res.status).toBe(200); + const importId = res.body.body.importID as string; + const doc = await LoadImportDocumentById(importId); + + expect(doc?.errors?.[0]?.type).toBe("InvalidDatapoint"); + expect(doc?.errors?.[0]?.message).toContain("EX Score cannot be greater than 1572"); + }); + + it("records import error for invalid gauge", async () => { + const res = await mockApi + .post("/ir/fervidex/score/submit") + .set(ferHeaders()) + .send(deepmerge(FervidexBaseScore, { gauge: [150] })); + + expect(res.status).toBe(200); + const importId = res.body.body.importID as string; + const doc = await LoadImportDocumentById(importId); + + expect(doc?.errors).toEqual([ + { + type: "InvalidDatapoint", + message: "Invalid value of gauge 150.", + }, + ]); + }); +}); + +describe.skip("POST /ir/fervidex/profile/submit — deferred KT bulk seed to Postgres", () => { + it.skip("imports fervidex-static profile", () => {}); +}); diff --git a/typescript/server/src/server/router/ir/fervidex/router.ts b/typescript/server/src/server/router/ir/fervidex/router.ts index 69ab8ee25..48e4c9251 100644 --- a/typescript/server/src/server/router/ir/fervidex/router.ts +++ b/typescript/server/src/server/router/ir/fervidex/router.ts @@ -12,10 +12,11 @@ import { IsNullishOrEmptyStr } from "#utils/misc"; import { type RequestHandler, Router } from "express"; import { p } from "prudence"; import { - GetGamePTConfig, + type GamesForGroup, type integer, - type Playtypes, - type SpecificGamePTConfig, + LEGACY_GetGamePTConfig, + type LEGACY_Playtypes, + type SpecificGameConfig, } from "tachi-common"; const router: Router = Router({ mergeParams: true }); @@ -317,10 +318,12 @@ router.post("/class/submit", ValidateModelHeader, async (req, res) => { } // is 0 or 1. - const playtype: Playtypes["iidx"] = body.play_style === 0 ? "SP" : "DP"; + const playtype: LEGACY_Playtypes["iidx"] = body.play_style === 0 ? "SP" : "DP"; const dans = ( - GetGamePTConfig("iidx", playtype) as unknown as SpecificGamePTConfig<"iidx:DP" | "iidx:SP"> + LEGACY_GetGamePTConfig("iidx", playtype) as unknown as SpecificGameConfig< + GamesForGroup["iidx"] + > ).classes.dan.values; const dan = dans[body.course_id]; @@ -332,13 +335,9 @@ router.post("/class/submit", ValidateModelHeader, async (req, res) => { }); } - const r = await UpdateClassIfGreater( - req[SYMBOL_TACHI_API_AUTH].userID!, - "iidx", - playtype, - "dan", - dan.id, - ); + const game = playtype === "SP" ? "iidx-sp" : "iidx-dp"; + + const r = await UpdateClassIfGreater(req[SYMBOL_TACHI_API_AUTH].userID!, game, "dan", dan.id); return res.status(200).json({ success: true, diff --git a/typescript/server/src/server/router/ir/lr2hook/router.oldtest.ts b/typescript/server/src/server/router/ir/lr2hook/router.oldtest.ts deleted file mode 100644 index d212fb484..000000000 --- a/typescript/server/src/server/router/ir/lr2hook/router.oldtest.ts +++ /dev/null @@ -1,150 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { TestingLR2HookScore } from "#test-utils/test-data"; -import { ApplyNTimes, RFA } from "#utils/misc"; -import deepmerge from "deepmerge"; -import t from "tap"; - -t.test("POST /ir/lr2hook/import", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL["api-tokens"].insert({ - token: "foo", - permissions: { submit_score: true }, - fromAPIClient: null, - identifier: "foo", - userID: 1, - }); - }); - - t.test("Should import a score if the API token has the right permissions.", async (t) => { - const res = await mockApi - .post("/ir/lr2hook/import") - .set("Authorization", "Bearer foo") - .send(TestingLR2HookScore); - - t.equal(res.statusCode, 200); - - t.end(); - }); - - t.test("Should import a score if the API token has the right permissions.", async (t) => { - await MONGODB_KILL["api-tokens"].insert({ - token: "bar", - permissions: { submit_score: false }, - fromAPIClient: null, - identifier: "bar", - userID: 1, - }); - - const res = await mockApi - .post("/ir/lr2hook/import") - .set("Authorization", "Bearer bar") - .send(TestingLR2HookScore); - - t.equal(res.statusCode, 403); - - t.end(); - }); - - t.test("Should import a score if the API token has the right permissions.", async (t) => { - const res = await mockApi - .post("/ir/lr2hook/import") - .set("Authorization", "Bearer unknown token") - .send(TestingLR2HookScore); - - t.equal(res.statusCode, 401); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /ir/lr2hook/import/course", (t) => { - t.beforeEach(ResetDBState); - - const classBody = { - md5: "cfad3baadce9e02c45021963453d7c9477d23be22b2370925c573d922276bce0188a99f74ab71804f2e360dcf484545cc46a81cb184f5a804c119930d6eba748", - playerData: { - autoScr: 0, - gameMode: "shrug", - gauge: "EASY", - random: "MIRROR", - rseed: undefined, - }, - scoreData: { - exScore: 10, - bad: 10, - good: 10, - great: 10, - hpGraph: ApplyNTimes(1000, () => RFA([100, 50, 80, 0])), - lamp: "EASY", - maxCombo: 10, - moneyScore: 10, - notesPlayed: 10, - notesTotal: 10, - pgreat: 10, - poor: 10, - extendedJudgements: undefined, - extendedHpGraphs: undefined, - unexpectedField: "foo", - }, - unixTimestamp: undefined, - }; - - t.test("Should update a user's class if necessary.", async (t) => { - const res = await mockApi - .post("/ir/lr2hook/import/course") - .set("Authorization", "Bearer fake_api_token") - .send(classBody); - - t.equal(res.statusCode, 200); - t.equal(res.body.body.set, "genocideDan"); - t.equal(res.body.body.value, "OVERJOY"); - - const dbRes = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "bms", - playtype: "7K", - }); - - t.equal(dbRes?.classes.genocideDan, "OVERJOY"); - - t.end(); - }); - - t.test("Should not update a user's class if they didn't clear the course.", async (t) => { - const res = await mockApi - .post("/ir/lr2hook/import/course") - .set("Authorization", "Bearer fake_api_token") - .send(deepmerge(classBody, { scoreData: { notesPlayed: 1 } })); - - t.equal(res.statusCode, 200); - t.match(res.body.description, "Class not updated. You failed to clear this course."); - - const dbRes = await MONGODB_KILL["game-stats"].findOne({ - userID: 1, - game: "bms", - playtype: "7K", - }); - - t.not(dbRes?.classes.genocideDan, 22); - - t.end(); - }); - - t.test("Should 404 if the course doesn't exist.", async (t) => { - const res = await mockApi - .post("/ir/lr2hook/import/course") - .set("Authorization", "Bearer fake_api_token") - .send(deepmerge(classBody, { md5: "some nonsense" })); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/lr2hook/router.test.ts b/typescript/server/src/server/router/ir/lr2hook/router.test.ts new file mode 100644 index 000000000..bb5d37e02 --- /dev/null +++ b/typescript/server/src/server/router/ir/lr2hook/router.test.ts @@ -0,0 +1,197 @@ +import { seedApiToken, seedUser } from "#actions/test-utils/api-tokens"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { BMSGazerChart, BMSGazerSong, TestingLR2HookScore } from "#test-utils/test-data"; +import { ApplyNTimes, RFA } from "#utils/misc"; +import deepmerge from "deepmerge"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const GENOSIDE_OVERJOY_MD5SUMS = + "cfad3baadce9e02c45021963453d7c9477d23be22b2370925c573d922276bce0188a99f74ab71804f2e360dcf484545cc46a81cb184f5a804c119930d6eba748"; + +afterAll(() => CloseServerConnection()); + +async function seedBmsGazer7k() { + await DB.insertInto("song") + .values({ + id: BMSGazerSong.id, + legacy_id: 27_339, + game_group: "bms", + title: BMSGazerSong.title, + artist: BMSGazerSong.artist, + search_terms: BMSGazerSong.searchTerms, + alt_titles: BMSGazerSong.altTitles, + data: BMSGazerSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: BMSGazerChart.chartID, + legacy_id: BMSGazerChart.chartID, + game: "bms-7k", + song_id: BMSGazerSong.id, + difficulty: BMSGazerChart.difficulty, + level: BMSGazerChart.level, + level_num: BMSGazerChart.levelNum, + is_primary: BMSGazerChart.isPrimary, + versions: BMSGazerChart.versions, + data: BMSGazerChart.data, + }) + .execute(); +} + +describe("POST /ir/lr2hook/import (Postgres)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "lr2hook-ir@example.com", + withCredential: true, + withSettings: true, + }); + await seedApiToken({ + token: "foo", + userId: 1, + submitScore: true, + }); + await seedBmsGazer7k(); + }); + + it("imports when the token can submit scores", async () => { + const res = await mockApi + .post("/ir/lr2hook/import") + .set("Authorization", "Bearer foo") + .send(TestingLR2HookScore); + + expect(res.statusCode).toBe(200); + }); + + it("returns 403 when submit_score is false", async () => { + await seedApiToken({ + token: "bar", + userId: 1, + submitScore: false, + }); + + const res = await mockApi + .post("/ir/lr2hook/import") + .set("Authorization", "Bearer bar") + .send(TestingLR2HookScore); + + expect(res.statusCode).toBe(403); + }); + + it("returns 401 for unknown tokens", async () => { + const res = await mockApi + .post("/ir/lr2hook/import") + .set("Authorization", "Bearer unknown token") + .send(TestingLR2HookScore); + + expect(res.statusCode).toBe(401); + }); +}); + +describe("POST /ir/lr2hook/import/course (Postgres)", () => { + const classBody = { + md5: "cfad3baadce9e02c45021963453d7c9477d23be22b2370925c573d922276bce0188a99f74ab71804f2e360dcf484545cc46a81cb184f5a804c119930d6eba748", + playerData: { + autoScr: 0, + gameMode: "shrug", + gauge: "EASY", + random: "MIRROR", + rseed: undefined, + }, + scoreData: { + exScore: 10, + bad: 10, + good: 10, + great: 10, + hpGraph: ApplyNTimes(1000, () => RFA([100, 50, 80, 0])), + lamp: "EASY", + maxCombo: 10, + moneyScore: 10, + notesPlayed: 10, + notesTotal: 10, + pgreat: 10, + poor: 10, + extendedJudgements: undefined, + extendedHpGraphs: undefined, + unexpectedField: "foo", + }, + unixTimestamp: undefined, + }; + + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "lr2hook-course@example.com", + withCredential: true, + withSettings: true, + }); + await seedApiToken({ + token: "fake_api_token", + userId: 1, + submitScore: true, + }); + + await DB.insertInto("bms_course_lookup") + .values({ + md5sums: GENOSIDE_OVERJOY_MD5SUMS, + title: "GENOSIDE 2018 段位認定 Overjoy", + set: "genocideDan", + game: "bms-7k", + value: "OVERJOY", + }) + .execute(); + }); + + it("updates class when the course clear matches", async () => { + const res = await mockApi + .post("/ir/lr2hook/import/course") + .set("Authorization", "Bearer fake_api_token") + .send(classBody); + + expect(res.statusCode).toBe(200); + expect(res.body.body.set).toBe("genocideDan"); + expect(res.body.body.value).toBe("OVERJOY"); + + const gp = await DB.selectFrom("game_profile") + .selectAll() + .where("user_id", "=", 1) + .where("game", "=", "bms-7k") + .executeTakeFirst(); + + const classes = typeof gp?.classes === "string" ? JSON.parse(gp.classes) : gp?.classes; + expect(classes?.genocideDan).toBe("OVERJOY"); + }); + + it("does not update class on failed clear", async () => { + const res = await mockApi + .post("/ir/lr2hook/import/course") + .set("Authorization", "Bearer fake_api_token") + .send(deepmerge(classBody, { scoreData: { notesPlayed: 1 } })); + + expect(res.statusCode).toBe(200); + expect(String(res.body.description)).toContain( + "Class not updated. You failed to clear this course.", + ); + + const gp = await DB.selectFrom("game_profile") + .selectAll() + .where("user_id", "=", 1) + .where("game", "=", "bms-7k") + .executeTakeFirst(); + + expect(gp).toBeUndefined(); + }); + + it("returns 404 when course md5 is unknown", async () => { + const res = await mockApi + .post("/ir/lr2hook/import/course") + .set("Authorization", "Bearer fake_api_token") + .send(deepmerge(classBody, { md5: "some nonsense" })); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/typescript/server/src/server/router/ir/lr2hook/router.ts b/typescript/server/src/server/router/ir/lr2hook/router.ts index 3381c444c..c710d2112 100644 --- a/typescript/server/src/server/router/ir/lr2hook/router.ts +++ b/typescript/server/src/server/router/ir/lr2hook/router.ts @@ -1,5 +1,5 @@ import type { LR2HookScore } from "#lib/score-import/import-types/ir/lr2hook/types"; -import type { Classes, GPTString, Playtypes } from "tachi-common"; +import type { Classes, GamesForGroup } from "tachi-common"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { ExpressWrappedScoreImportMain } from "#lib/score-import/framework/express-wrapper"; @@ -58,7 +58,7 @@ router.post( } const course = await DB.selectFrom("bms_course_lookup") - .select(["set", "playtype", "value"]) + .select(["set", "game", "value"]) .where("md5sums", "=", score.md5) .executeTakeFirst(); @@ -73,9 +73,8 @@ router.post( const result = await UpdateClassIfGreater( userID, - "bms", - course.playtype as Playtypes["bms"], - course.set as Classes[GPTString], + course.game, + course.set as Classes[GamesForGroup["bms"]], course.value, ); diff --git a/typescript/server/src/server/router/ir/router.ts b/typescript/server/src/server/router/ir/router.ts index 38a9f8785..90ae792b6 100644 --- a/typescript/server/src/server/router/ir/router.ts +++ b/typescript/server/src/server/router/ir/router.ts @@ -1,15 +1,15 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; -import { FormatUserDoc, GetUserWithID } from "#utils/user"; -import { Router } from "express"; - import { FervidexStyleRequireNotGuest, RequireNotGuest, SetFervidexStyleRequestPermissions, SetRequestPermissions, -} from "../../middleware/auth"; -import { RequireBokutachi, RequireKamaitachi } from "../../middleware/type-require"; +} from "#server/middleware/auth"; +import { RequireBokutachi, RequireKamaitachi } from "#server/middleware/type-require"; +import { FormatUserDoc, GetUserWithID } from "#utils/user"; +import { Router } from "express"; + import barbatosIR from "./barbatos/router"; import beatorajaIR from "./beatoraja/router"; import directManualIR from "./direct-manual/router"; diff --git a/typescript/server/src/server/router/ir/usc/_playtype/router.oldtest.ts b/typescript/server/src/server/router/ir/usc/_playtype/router.oldtest.ts deleted file mode 100644 index 91cc21044..000000000 --- a/typescript/server/src/server/router/ir/usc/_playtype/router.oldtest.ts +++ /dev/null @@ -1,881 +0,0 @@ -import type { PBScoreDocument, ScoreDocument, UserDocument } from "tachi-common"; - -import { CDNRetrieve } from "#lib/cdn/cdn"; -import MONGODB_KILL from "#services/mongo/db"; -import { dmf } from "#test-utils/misc.js"; -import mockApi from "#test-utils/mock-api"; -import ResetDBState from "#test-utils/resets"; -import { GetKTDataBuffer } from "#test-utils/test-data"; -import t from "tap"; - -async function InsertFakeUSCAuth() { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "USC Token", - permissions: { - submit_score: true, - }, - token: "foo", - fromAPIClient: null, - }); -} - -function TestAuth(url: string) { - t.test(`Authorization Check ${url}`, async (t) => { - const res = await mockApi.get(url).set("Authorization", "Bearer invalid"); - - t.equal(res.body.statusCode, 41, "Should return 41 for nonsense token"); - - const res2 = await mockApi.get(url).set("Authorization", "NOTBEARER invalid"); - - t.equal(res2.body.statusCode, 40, "Should return 40 for nonsense authtype"); - - const res3 = await mockApi.get(url); - - t.equal(res3.body.statusCode, 40, "Should return 40 for no auth header."); - - const res4 = await mockApi.get(url).set("Authorization", "Bearer foo invalid"); - - t.equal(res4.body.statusCode, 40, "Should return 40 for nonsense header"); - }); -} - -// Due to how this works, as long as these tests pass, the two IRs work identically. -t.test("GET /ir/usc/Keyboard", async (t) => { - t.beforeEach(ResetDBState); - - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "USC Token", - permissions: { - submit_score: true, - }, - token: "bar", - fromAPIClient: null, - }); - - TestAuth("/ir/usc/Controller"); - - const res = await mockApi.get("/ir/usc/Controller").set("Authorization", "Bearer bar"); - - t.equal(res.body.statusCode, 20, "Should return 20"); - t.match( - res.body.body, - { - serverName: /tachi/iu, - irVersion: /^[0-9]\.[0-9]\.[0-9](-a)?$/iu, - }, - "Should return the right body.", - ); - - t.end(); -}); - -t.test("GET /ir/usc/Controller", async (t) => { - t.beforeEach(ResetDBState); - - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "USC Token", - permissions: { - submit_score: true, - }, - token: "fee", - fromAPIClient: null, - }); - - TestAuth("/ir/usc/Controller"); - - const res = await mockApi.get("/ir/usc/Controller").set("Authorization", "Bearer fee"); - - t.equal(res.body.statusCode, 20, "Should return 20"); - t.match( - res.body.body, - { - serverName: /tachi/iu, - irVersion: /^[0-9]\.[0-9]\.[0-9](-a)?$/iu, - }, - "Should return the right body.", - ); - - t.end(); -}); - -t.test("GET /ir/usc/Controller/charts/:chartHash", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(InsertFakeUSCAuth); - - t.test("Should return 20 if the chartHash matches a chart.", async (t) => { - const res = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 20, "Should return 20"); - - t.end(); - }); - - t.test("Should return 44 if the chartHash doesn't match a chart.", async (t) => { - const res = await mockApi - .get("/ir/usc/Controller/charts/INVALID_HASH") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 44, "Should return 44"); - - t.end(); - }); - - t.end(); -}); - -const USC_SCORE_PB: PBScoreDocument<"usc:Controller"> = { - chartID: "USC_CHART_ID", - rankingData: { - rank: 1, - outOf: 2, - rivalRank: null, - }, - songID: 1, - userID: 1, - timeAchieved: 0, - playtype: "Controller", - game: "usc", - highlight: false, - composedFrom: [ - { name: "Best Score", scoreID: "usc_score_pb" }, - { name: "Best Lamp", scoreID: "bar" }, - ], - calculatedData: { - VF6: null, - }, - isPrimary: true, - scoreData: { - score: 9_000_000, - grade: "A+", - lamp: "EXCESSIVE CLEAR", - - enumIndexes: { - // are these even correct? lazy. - lamp: 2, - grade: 4, - }, - - judgements: { - critical: 50, - near: 30, - miss: 10, - }, - optional: { - gauge: 50, - fast: 50, - slow: 20, - enumIndexes: {}, - }, - }, -}; - -t.test("GET /ir/usc/Controller/:chartHash/record", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(InsertFakeUSCAuth); - TestAuth("/ir/usc/Controller/:chartHash/record"); - - t.test("Should return 44 if the chartHash doesn't match a chart.", async (t) => { - const res = await mockApi - .get("/ir/usc/Controller/charts/INVALID_HASH/record") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 44, "Should return 44"); - - t.end(); - }); - - t.test("Should return 44 if there are no scores on the chart.", async (t) => { - const res = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/record") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 44, "Should return 44"); - - t.end(); - }); - - t.test("Should return 20 and the Server Record.", async (t) => { - await MONGODB_KILL["personal-bests"].insert([ - // empty deepmerge is because monk monkey-patches _id on, - // which means this crashes if you try to re-insert this document. - dmf(USC_SCORE_PB, {}), - dmf(USC_SCORE_PB, { userID: 2, rankingData: { rank: 2 } }), - ]); - - // hack for referencing - await MONGODB_KILL.scores.insert({ - scoreID: "usc_score_pb", - scoreMeta: { noteMod: "NORMAL", gaugeMod: "HARD" }, - } as ScoreDocument); - - const res = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/record") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 20, "Should return 20"); - - t.strictSame( - res.body.body.record, - { - score: 9_000_000, - timestamp: 0, - crit: 50, - near: 30, - error: 10, - ranking: 1, - lamp: 3, - username: "test_zkldi", - noteMod: "NORMAL", - gaugeMod: "HARD", - }, - "Should correctly return the right score.", - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /charts/:chartHash/leaderboard", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(InsertFakeUSCAuth); - TestAuth("/ir/usc/Controller/:chartHash/leaderboard"); - - t.test("Should return 40 if mode is invalid", async (t) => { - const res = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 40); - - const res2 = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=invalid") - .set("Authorization", "Bearer foo"); - - t.equal(res2.body.statusCode, 40); - - t.end(); - }); - - t.test("Should return 40 if N is invalid", async (t) => { - const res = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 40); - - const res2 = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=foo") - .set("Authorization", "Bearer foo"); - - t.equal(res2.body.statusCode, 40); - - t.end(); - }); - - t.test("Should return 44 if chart does not exist.", async (t) => { - const res = await mockApi - .get("/ir/usc/Controller/charts/UNKNOWN_HASH/leaderboard?mode=best") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 44); - - t.end(); - }); - - t.test("Should return empty arr for mode = best if no scores", async (t) => { - const res = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=5") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 20); - - t.end(); - }); - - t.test("Should return scorePBs for mode = best", async (t) => { - await MONGODB_KILL["personal-bests"].insert([ - dmf(USC_SCORE_PB, {}), - dmf(USC_SCORE_PB, { - userID: 2, - scoreData: { - score: 8_000_000, - }, - rankingData: { rank: 2 }, - composedFrom: [{ name: "Best Score", scoreID: "other_usc_score_pb" }], - }), - ]); - - await MONGODB_KILL.users.insert({ - id: 2, - username: "not_zkldi", - usernameLowercase: "not_zkldi", - } as UserDocument); - - await MONGODB_KILL.scores.insert([ - { - scoreID: "usc_score_pb", - scoreMeta: { noteMod: "NORMAL", gaugeMod: "HARD" }, - }, - { - scoreID: "other_usc_score_pb", - scoreMeta: { noteMod: "NORMAL", gaugeMod: "HARD" }, - }, - ] as Array); - - const res = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=2") - .set("Authorization", "Bearer foo"); - - t.equal(res.body.statusCode, 20); - t.strictSame( - res.body.body, - [ - { - score: 9000000, - timestamp: 0, - crit: 50, - near: 30, - error: 10, - ranking: 1, - lamp: 3, - username: "test_zkldi", - noteMod: "NORMAL", - gaugeMod: "HARD", - }, - { - score: 8000000, - timestamp: 0, - crit: 50, - near: 30, - error: 10, - ranking: 2, - lamp: 3, - username: "not_zkldi", - noteMod: "NORMAL", - gaugeMod: "HARD", - }, - ], - "Should return the scores.", - ); - - const res2 = await mockApi - .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=1") - .set("Authorization", "Bearer foo"); - - t.equal(res2.body.statusCode, 20); - t.strictSame( - res2.body.body, - [ - { - score: 9000000, - timestamp: 0, - crit: 50, - near: 30, - error: 10, - ranking: 1, - lamp: 3, - username: "test_zkldi", - noteMod: "NORMAL", - gaugeMod: "HARD", - }, - ], - "Should return the scores dependent on N.", - ); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /replays", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(InsertFakeUSCAuth); - - t.test("Should successfully upload a file where an identifier matches", async (t) => { - await MONGODB_KILL.scores.insert({ - game: "usc", - userID: 1, - scoreID: "MOCK_IDENTIFIER", - } as ScoreDocument); - - const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); - - const res = await mockApi - .post("/ir/usc/Controller/replays") - .field("identifier", "MOCK_IDENTIFIER") - .attach("replay", replayFile, "replay.urf") - .set("Authorization", "Bearer foo"); - - t.equal(res.status, 200); - - t.strictSame(res.body, { - statusCode: 20, - description: "Saved replay.", - body: {}, - }); - - const stored = await CDNRetrieve("/uscir/replays/MOCK_IDENTIFIER"); - - t.strictSame(stored, replayFile, "Should store the same file exactly."); - - t.end(); - }); - - t.test("Should reject a request with no identifier", async (t) => { - await MONGODB_KILL.scores.insert({ - game: "usc", - userID: 1, - scoreID: "MOCK_IDENTIFIER", - } as ScoreDocument); - - const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); - - const res = await mockApi - .post("/ir/usc/Controller/replays") - .attach("replay", replayFile, "replay.urf") - .set("Authorization", "Bearer foo"); - - t.equal(res.status, 200); - - t.strictSame(res.body, { - statusCode: 40, - description: "No Identifier Provided.", - }); - - t.end(); - }); - - t.test("Should reject a request with no file", async (t) => { - await MONGODB_KILL.scores.insert({ - game: "usc", - userID: 1, - scoreID: "MOCK_IDENTIFIER", - } as ScoreDocument); - - // const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); - - const res = await mockApi - .post("/ir/usc/Controller/replays") - .field("identifier", "MOCK_IDENTIFIER") - - // .attach("replay", replayFile, "replay.urf") - .set("Authorization", "Bearer foo"); - - t.equal(res.status, 200); - - t.strictSame(res.body, { - statusCode: 40, - description: "No File Provided.", - }); - - t.end(); - }); - - t.test("Should reject a request with an invalid identifier", async (t) => { - await MONGODB_KILL.scores.insert({ - game: "usc", - userID: 1, - scoreID: "MOCK_IDENTIFIER", - } as ScoreDocument); - - const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); - - const res = await mockApi - .post("/ir/usc/Controller/replays") - .field("identifier", "INVALID_IDENTIFIER") - .attach("replay", replayFile, "replay.urf") - .set("Authorization", "Bearer foo"); - - t.equal(res.status, 200); - - t.strictSame(res.body, { - statusCode: 44, - description: "No score corresponds to this identifier.", - }); - - t.end(); - }); - - t.end(); -}); - -t.test("POST /scores", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await MONGODB_KILL["api-tokens"].insert({ - userID: 1, - identifier: "token", - permissions: { submit_score: true }, - token: "token", - fromAPIClient: null, - }); - }); - - const validRequest = { - chart: { - chartHash: "USC_CHART_HASH", - artist: "test_artist", - title: "test", - level: 5, - difficulty: 0, - effector: "test", - illustrator: "test", - bpm: "test", - }, - score: { - score: 9_000_000, - gauge: 0.5, - timestamp: 1_000_000, - crit: 5, - near: 4, - error: 3, - early: 2, - late: 1, - options: { - gaugeType: 0, - gaugeOpt: 0, - mirror: true, - random: false, - autoFlags: 0, - }, - windows: { - perfect: 46, - good: 150, - hold: 150, - miss: 300, - slam: 84, - }, - }, - }; - - t.test("Should submit a score from a valid request.", async (t) => { - const res = await mockApi - .post("/ir/usc/Controller/scores") - .set("Authorization", "Bearer token") - .send(validRequest); - - t.equal(res.body.statusCode, 20); - - const dbScore = await MONGODB_KILL.scores.findOne({ - game: "usc", - }); - - t.hasStrict(dbScore, { - scoreData: { - score: 9_000_000, - lamp: "FAILED", - }, - scoreMeta: { - noteMod: "MIRROR", - gaugeMod: "NORMAL", - }, - }); - - t.end(); - }); - - t.skip("Should orphan a score and return 22 if chart has never been seen.", async (t) => { - const res = await mockApi - .post("/ir/usc/Controller/scores") - .set("Authorization", "Bearer token") - .send( - dmf(validRequest, { - chart: { - chartHash: "NEW_CHART", - }, - }), - ); - - t.equal(res.body.statusCode, 22); - - t.match( - res.body.description, - "(1/3)", - "Should contain (required/outOf) in the response description.", - ); - - const dbScore = await MONGODB_KILL.scores.findOne({ - game: "usc", - }); - - t.equal(dbScore, null, "Should not exist in db."); - - const orphanData = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA1": "NEW_CHART", - }); - - t.strictSame(orphanData?.userIDs, [1]); - - t.strictSame(orphanData?.songDoc, { - title: "test", - artist: "test_artist", - id: 0, - altTitles: [], - searchTerms: [], - data: {}, - }); - - t.hasStrict(orphanData?.chartDoc, { - songID: 0, - difficulty: "NOV", - data: { - hashSHA1: "NEW_CHART", - }, - isPrimary: true, - level: "5", - levelNum: 5, - }); - - t.end(); - }); - - t.skip("Should unorphan charts on their Nth unique user.", async (t) => { - await MONGODB_KILL["api-tokens"].insert([ - { - userID: 2, - identifier: "token2", - permissions: { submit_score: true }, - token: "token2", - fromAPIClient: null, - }, - { - userID: 3, - identifier: "token3", - permissions: { submit_score: true }, - token: "token3", - fromAPIClient: null, - }, - ]); - - await MONGODB_KILL.users.insert([ - { - id: 2, - username: "foo", - usernameLowercase: "foo", - }, - { - id: 3, - username: "bar", - usernameLowercase: "bar", - }, - ] as Array); - - const res = await mockApi - .post("/ir/usc/Controller/scores") - .set("Authorization", "Bearer token") - .send( - dmf(validRequest, { - chart: { - chartHash: "NEW_CHART", - }, - }), - ); - - t.equal(res.body.statusCode, 22); - - const res2 = await mockApi - .post("/ir/usc/Controller/scores") - .set("Authorization", "Bearer token2") - .send( - dmf(validRequest, { - chart: { - chartHash: "NEW_CHART", - }, - }), - ); - - t.equal(res2.body.statusCode, 22); - - const orphanData = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA1": "NEW_CHART", - }); - - t.strictSame(orphanData?.userIDs, [1, 2]); - - const res3 = await mockApi - .post("/ir/usc/Controller/scores") - .set("Authorization", "Bearer token3") - .send( - dmf(validRequest, { - chart: { - chartHash: "NEW_CHART", - }, - }), - ); - - t.equal(res3.body.statusCode, 20); - - const orphanData2 = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA1": "NEW_CHART", - }); - - t.equal(orphanData2, null, "Orphan data should be removed from the database."); - - const score = await MONGODB_KILL.scores.findOne({ - game: "usc", - userID: 3, - }); - - t.hasStrict(score, { - scoreData: { - score: 9_000_000, - lamp: "FAILED", - }, - scoreMeta: { - noteMod: "MIRROR", - gaugeMod: "NORMAL", - }, - }); - - t.end(); - }); - - t.skip("Should maintain separate orphan queues for the separate playtypes.", async (t) => { - await MONGODB_KILL["api-tokens"].insert([ - { - userID: 2, - identifier: "token2", - permissions: { submit_score: true }, - token: "token2", - fromAPIClient: null, - }, - { - userID: 3, - identifier: "token3", - permissions: { submit_score: true }, - token: "token3", - fromAPIClient: null, - }, - { - userID: 4, - identifier: "token4", - permissions: { submit_score: true }, - token: "token4", - fromAPIClient: null, - }, - ]); - - await MONGODB_KILL.users.insert([ - { - id: 2, - username: "foo", - usernameLowercase: "foo", - }, - { - id: 3, - username: "bar", - usernameLowercase: "bar", - }, - { - id: 4, - username: "baz", - usernameLowercase: "baz", - }, - ] as Array); - - const res = await mockApi - .post("/ir/usc/Controller/scores") - .set("Authorization", "Bearer token") - .send( - dmf(validRequest, { - chart: { - chartHash: "NEW_CHART", - }, - }), - ); - - t.equal(res.body.statusCode, 22); - - const res2 = await mockApi - .post("/ir/usc/Controller/scores") - .set("Authorization", "Bearer token2") - .send( - dmf(validRequest, { - chart: { - chartHash: "NEW_CHART", - }, - }), - ); - - t.equal(res2.body.statusCode, 22); - - const orphanData = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA1": "NEW_CHART", - gptString: "usc:Controller", - }); - - t.strictSame(orphanData?.userIDs, [1, 2]); - - const res3 = await mockApi - .post("/ir/usc/Keyboard/scores") - .set("Authorization", "Bearer token3") - .send( - dmf(validRequest, { - chart: { - chartHash: "NEW_CHART", - }, - }), - ); - - t.equal(res3.body.statusCode, 22); - - const orphanData2 = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA1": "NEW_CHART", - gptString: "usc:Controller", - }); - - t.strictSame( - orphanData2?.userIDs, - [1, 2], - "Should not have added userID 3 to the list of userIDs.", - ); - - const res4 = await mockApi - .post("/ir/usc/Controller/scores") - .set("Authorization", "Bearer token4") - .send( - dmf(validRequest, { - chart: { - chartHash: "NEW_CHART", - }, - }), - ); - - t.equal(res4.body.statusCode, 20); - - const orphanData3 = await MONGODB_KILL["orphan-chart-queue"].findOne({ - "chartDoc.data.hashSHA1": "NEW_CHART", - gptString: "usc:Controller", - }); - - t.equal(orphanData3, null, "Should have removed the orphan chart from the database."); - - const score = await MONGODB_KILL.scores.findOne({ - game: "usc", - userID: 4, - }); - - t.hasStrict(score, { - scoreData: { - score: 9_000_000, - lamp: "FAILED", - }, - scoreMeta: { - noteMod: "MIRROR", - gaugeMod: "NORMAL", - }, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/usc/_playtype/router.test.ts b/typescript/server/src/server/router/ir/usc/_playtype/router.test.ts new file mode 100644 index 000000000..abcc6554b --- /dev/null +++ b/typescript/server/src/server/router/ir/usc/_playtype/router.test.ts @@ -0,0 +1,141 @@ +import { seedApiToken, seedUser } from "#actions/test-utils/api-tokens"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { TestingUSCChart, TestingUSCSong } from "#test-utils/test-data"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +async function seedUscControllerChart() { + const chart = dmf(TestingUSCChart, { game: "usc-controller" } as never); + + await DB.insertInto("song") + .values({ + id: TestingUSCSong.id, + legacy_id: 1, + game_group: "usc", + title: TestingUSCSong.title, + artist: TestingUSCSong.artist, + search_terms: TestingUSCSong.searchTerms, + alt_titles: TestingUSCSong.altTitles, + data: TestingUSCSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "usc-controller", + song_id: TestingUSCSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +afterAll(() => CloseServerConnection()); + +describe("GET /ir/usc/:playtype (Postgres)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "usc-ir@example.com", + withCredential: true, + withSettings: true, + }); + await seedApiToken({ + token: "usc_ir_token", + userId: 1, + submitScore: true, + }); + }); + + it("returns 41 for an invalid token", async () => { + const res = await mockApi.get("/ir/usc/Controller").set("Authorization", "Bearer invalid"); + + expect(res.body.statusCode).toBe(41); + }); + + it("returns 40 for a non-Bearer auth header", async () => { + const res = await mockApi + .get("/ir/usc/Controller") + .set("Authorization", "NOTBEARER invalid"); + + expect(res.body.statusCode).toBe(40); + }); + + it("returns 40 when Authorization is missing", async () => { + const res = await mockApi.get("/ir/usc/Controller"); + + expect(res.body.statusCode).toBe(40); + }); + + it("returns 40 for a malformed Bearer header", async () => { + const res = await mockApi + .get("/ir/usc/Controller") + .set("Authorization", "Bearer usc_ir_token invalid"); + + expect(res.body.statusCode).toBe(40); + }); + + it("returns heartbeat payload for Controller", async () => { + const res = await mockApi + .get("/ir/usc/Controller") + .set("Authorization", "Bearer usc_ir_token"); + + expect(res.body.statusCode).toBe(20); + expect(res.body.body).toMatchObject({ + serverName: expect.stringMatching(/tachi/iu), + irVersion: expect.stringMatching(/^[0-9]\.[0-9]\.[0-9](-a)?$/iu), + }); + }); + + it("returns heartbeat payload for Keyboard", async () => { + const res = await mockApi + .get("/ir/usc/Keyboard") + .set("Authorization", "Bearer usc_ir_token"); + + expect(res.body.statusCode).toBe(20); + expect(res.body.body).toMatchObject({ + serverName: expect.stringMatching(/tachi/iu), + }); + }); +}); + +describe("GET /ir/usc/:playtype/charts/:chartHash (Postgres)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "usc-charts@example.com", + withCredential: true, + withSettings: true, + }); + await seedApiToken({ + token: "usc_ir_token", + userId: 1, + submitScore: true, + }); + await seedUscControllerChart(); + }); + + it("returns 20 when the chart hash matches", async () => { + const res = await mockApi + .get("/ir/usc/Controller/charts/USC_CHART_HASH") + .set("Authorization", "Bearer usc_ir_token"); + + expect(res.body.statusCode).toBe(20); + }); + + it("returns 44 when the chart hash does not match any chart", async () => { + const res = await mockApi + .get("/ir/usc/Controller/charts/INVALID_HASH") + .set("Authorization", "Bearer usc_ir_token"); + + expect(res.body.statusCode).toBe(44); + }); +}); diff --git a/typescript/server/src/server/router/ir/usc/_playtype/router.ts b/typescript/server/src/server/router/ir/usc/_playtype/router.ts index aa47a907b..6c9fb16e6 100644 --- a/typescript/server/src/server/router/ir/usc/_playtype/router.ts +++ b/typescript/server/src/server/router/ir/usc/_playtype/router.ts @@ -4,7 +4,7 @@ import { ONE_MEGABYTE } from "#lib/constants/filesize"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { USCIR_MAX_LEADERBOARD_N } from "#lib/constants/usc-ir"; import { SELECT_API_TOKEN, ToAPITokenDocument } from "#lib/db-formats/api-token"; -import { LoadPbRankOneOnChartLegacyId, LoadPbsOnChartByRankingValueDesc } from "#lib/db-formats/pb"; +import { LoadPbRankOneOnChartID, LoadPbsOnChartByRankingValueDesc } from "#lib/db-formats/pb"; import { LoadScoreDocumentById } from "#lib/db-formats/score"; import { log } from "#lib/log/log"; import { AssertStrAsPositiveNonZeroInt } from "#lib/score-import/framework/common/string-asserts"; @@ -14,15 +14,16 @@ import { RejectIfBanned, RequirePermissions } from "#server/middleware/auth"; import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload"; import DB from "#services/pg/db"; import { FormatPrError } from "#utils/prudence"; -import { FindUSCChartOnSHA1Playtype } from "#utils/queries/charts"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; +import { FindUSCChartOnSHA1 } from "#utils/queries/charts"; +import { REQ_AssignToReqTachiData, REQ_GetTachiData } from "#utils/req-tachi-data"; import { type RequestHandler, Router } from "express"; import { p } from "prudence"; import { - type MONGO_ChartDocument, - type MONGO_ImportDocument, - type MONGO_PBScoreDocument, - type Playtypes, + type ChartDocument, + type ImportDocument, + LEGACY_GameGroupPTToGame, + type LEGACY_Playtypes, + type PBScoreDocument, type SuccessfulAPIResponse, } from "tachi-common"; @@ -106,7 +107,7 @@ router.use(ValidateUSCRequest); * https://uscir.readthedocs.io/en/latest/endpoints/heartbeat.html * @name GET /ir/usc/:playtype */ -router.get("/", (req, res) => +router.get("/", (_req, res) => res.status(200).json({ statusCode: STATUS_CODES.SUCCESS, description: "IR Request Successful.", @@ -119,10 +120,10 @@ router.get("/", (req, res) => ); const RetrieveChart: RequestHandler = async (req, res, next) => { - const chart = await FindUSCChartOnSHA1Playtype( - req.params.chartHash, - req.params.playtype as Playtypes["usc"], - ); + const playtype = req.params.playtype as LEGACY_Playtypes["usc"]; + const game = LEGACY_GameGroupPTToGame("usc", playtype) as "usc-controller" | "usc-keyboard"; + + const chart = await FindUSCChartOnSHA1(req.params.chartHash, game); if (!chart) { return res.status(200).json({ @@ -135,8 +136,8 @@ const RetrieveChart: RequestHandler = async (req, res, next) => { }); } - AssignToReqTachiData(req, { - uscChartDoc: chart as MONGO_ChartDocument<"usc:Controller" | "usc:Keyboard">, + REQ_AssignToReqTachiData(req, { + uscChartDoc: chart as ChartDocument<"usc-controller" | "usc-keyboard">, }); next(); @@ -147,7 +148,7 @@ const RetrieveChart: RequestHandler = async (req, res, next) => { * https://uscir.readthedocs.io/en/latest/endpoints/chart-charthash.html * @name GET /ir/usc/:playtype/charts/:chartHash */ -router.get("/charts/:chartHash", RetrieveChart, (req, res) => +router.get("/charts/:chartHash", RetrieveChart, (_req, res) => res.status(200).json({ statusCode: STATUS_CODES.SUCCESS, description: "This chart is tracked by the IR.", @@ -161,11 +162,11 @@ router.get("/charts/:chartHash", RetrieveChart, (req, res) => * @name GET /ir/usc/:playtype/charts/:chartHash/record */ router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => { - const chart = GetTachiData(req, "uscChartDoc"); + const chart = REQ_GetTachiData(req, "uscChartDoc"); - const serverRecord = (await LoadPbRankOneOnChartLegacyId( - chart.chartID, - )) as MONGO_PBScoreDocument<"usc:Controller" | "usc:Keyboard"> | null; + const serverRecord = (await LoadPbRankOneOnChartID(chart.chartID)) as PBScoreDocument< + "usc-controller" | "usc-keyboard" + > | null; if (!serverRecord) { return res.status(200).json({ @@ -189,7 +190,7 @@ router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => { * @name GET /ir/usc/:playtype/charts/:chartHash/leaderboard */ router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => { - const chart = GetTachiData(req, "uscChartDoc"); + const chart = REQ_GetTachiData(req, "uscChartDoc"); if (!(typeof req.query.mode === "string" && ["best", "rivals"].includes(req.query.mode))) { return res.status(200).json({ @@ -230,7 +231,7 @@ router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => } const bestScores = (await LoadPbsOnChartByRankingValueDesc(chart.chartID, n)) as Array< - MONGO_PBScoreDocument<"usc:Controller" | "usc:Keyboard"> + PBScoreDocument<"usc-controller" | "usc-keyboard"> >; const serverScores = await Promise.all(bestScores.map(TachiScoreToServerScore)); @@ -259,7 +260,8 @@ const PR_USCIR_CHART_DOC = { * @name POST /ir/usc/:playtype/scores */ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { - const playtype = req.params.playtype as Playtypes["usc"]; + const playtype = req.params.playtype as LEGACY_Playtypes["usc"]; + const game = LEGACY_GameGroupPTToGame("usc", playtype) as "usc-controller" | "usc-keyboard"; const chartErr = p( req.safeBody.chart, @@ -280,10 +282,9 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { const uscChart = req.safeBody.chart as USCClientChart; - const chartDoc = (await FindUSCChartOnSHA1Playtype( - uscChart.chartHash, - playtype, - )) as MONGO_ChartDocument<"usc:Controller" | "usc:Keyboard"> | null; + const chartDoc = (await FindUSCChartOnSHA1(uscChart.chartHash, game)) as ChartDocument< + "usc-controller" | "usc-keyboard" + > | null; const userID = req[SYMBOL_TACHI_API_AUTH].userID!; @@ -305,7 +306,7 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { }); } - const importDoc = (importRes.body as SuccessfulAPIResponse).body as MONGO_ImportDocument; + const importDoc = (importRes.body as SuccessfulAPIResponse).body as ImportDocument; // If the import failed, AND the import failure WAS NOT that the chart didnt exist // report that error instead. @@ -407,7 +408,8 @@ router.post( if ( !correspondingScore || correspondingScore.userID !== req[SYMBOL_TACHI_API_AUTH].userID || - correspondingScore.game !== "usc" + (correspondingScore.game !== "usc-controller" && + correspondingScore.game !== "usc-keyboard") ) { return res.status(200).json({ statusCode: STATUS_CODES.NOT_FOUND, diff --git a/typescript/server/src/server/router/ir/usc/_playtype/usc.oldtest.ts b/typescript/server/src/server/router/ir/usc/_playtype/usc.oldtest.ts deleted file mode 100644 index f4a87a66b..000000000 --- a/typescript/server/src/server/router/ir/usc/_playtype/usc.oldtest.ts +++ /dev/null @@ -1,415 +0,0 @@ -import type { ChartDocument, PBScoreDocument, ScoreDocument, UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import deepmerge from "deepmerge"; -import t from "tap"; - -import { CreatePOSTScoresResponseBody, TachiScoreToServerScore } from "./usc"; - -const mockScorePB: PBScoreDocument<"usc:Controller" | "usc:Keyboard"> = { - chartID: "USC_CHART_ID", - calculatedData: { - VF6: 0, - }, - composedFrom: [ - { name: "Best Score", scoreID: "USC_EXAMPLE_SCORE_PB_ID" }, - { name: "Best Lamp", scoreID: "USC_EXAMPLE_LAMP_PB_ID" }, - ], - game: "usc", - highlight: false, - isPrimary: true, - playtype: "Controller", - rankingData: { - outOf: 2, - rank: 1, - rivalRank: null, - }, - scoreData: { - grade: "AAA+", - judgements: { - critical: 100, - miss: 15, - }, - enumIndexes: { - grade: 7, - lamp: 3, - }, - lamp: "EXCESSIVE CLEAR", - score: 9_500_000, - optional: { - enumIndexes: {}, - }, - }, - songID: 1, - timeAchieved: null, - userID: 1, -}; - -const mockScoreDocument = { - scoreID: "USC_EXAMPLE_SCORE_PB_ID", - scoreMeta: { - noteMod: "MIRROR", - gaugeMod: "HARD", - }, -} as ScoreDocument; - -t.test("#TachiScoreToServerScore", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should correctly convert a tachiScore to a serverScore", async (t) => { - await MONGODB_KILL.scores.insert(mockScoreDocument); - - const res = await TachiScoreToServerScore(mockScorePB); - - t.strictSame( - res, - { - score: 9_500_000, - timestamp: 0, - crit: 100, - near: 0, - error: 15, - ranking: 1, - lamp: 3, - username: "test_zkldi", - noteMod: "MIRROR", - gaugeMod: "HARD", - }, - "Should return the right ServerScore.", - ); - - t.end(); - }); - - t.test("Should work for timestamped scores", async (t) => { - await MONGODB_KILL.scores.insert(mockScoreDocument); - - const res = await TachiScoreToServerScore( - deepmerge(mockScorePB, { timeAchieved: 1_621_844_762_995 }), - ); - - t.strictSame( - res, - { - score: 9_500_000, - timestamp: 1_621_844_762, - crit: 100, - near: 0, - error: 15, - ranking: 1, - lamp: 3, - username: "test_zkldi", - noteMod: "MIRROR", - gaugeMod: "HARD", - }, - "Should return the right ServerScore.", - ); - - t.end(); - }); - - t.test("Should throw if user document does not exist.", (t) => { - t.rejects( - () => TachiScoreToServerScore(deepmerge(mockScorePB, { userID: 2 } as ScoreDocument)), - { - message: /User 2 from PB on chart.*has no user document\?/u, - }, - ); - - t.end(); - }); - - t.test("Should throw if scorePB document does not exist.", (t) => { - t.rejects(() => TachiScoreToServerScore(mockScorePB), { - message: - /Score USC_EXAMPLE_SCORE_PB_ID does not exist, but is referenced in 1's PBDoc on/u, - }); - - t.end(); - }); - - t.end(); -}); - -// yeah -const uscScorePBsSet = [ - { - userID: 1, - scoreData: { - score: 9_000_000, - }, - rankingData: { - rank: 5, - outOf: 10, - }, - }, - { - userID: 2, - scoreData: { - score: 9_100_000, - }, - rankingData: { - rank: 4, - outOf: 10, - }, - }, - { - userID: 3, - scoreData: { - score: 9_200_000, - }, - rankingData: { - rank: 3, - outOf: 10, - }, - }, - { - userID: 4, - scoreData: { - score: 9_300_000, - }, - rankingData: { - rank: 2, - outOf: 10, - }, - }, - { - userID: 5, - scoreData: { - score: 9_400_000, - }, - rankingData: { - rank: 1, - outOf: 10, - }, - }, - { - userID: 6, - scoreData: { - score: 8_900_000, - }, - rankingData: { - rank: 6, - outOf: 10, - }, - }, - { - userID: 7, - scoreData: { - score: 8_800_000, - }, - rankingData: { - rank: 7, - outOf: 10, - }, - }, - { - userID: 8, - scoreData: { - score: 8_700_000, - }, - rankingData: { - rank: 8, - outOf: 10, - }, - }, - { - userID: 9, - scoreData: { - score: 8_600_000, - }, - rankingData: { - rank: 9, - outOf: 10, - }, - }, - { - userID: 10, - scoreData: { - score: 8_500_000, - }, - rankingData: { - rank: 10, - outOf: 10, - }, - }, -].map((e) => deepmerge(mockScorePB, e) as unknown as PBScoreDocument); - -const mockUserDocs = [2, 3, 4, 5, 6, 7, 8, 9, 10].map((e) => ({ - id: e, - username: e.toString(), - usernameLowercase: e.toString(), -})) as Array; - -t.test("#CreatePOSTScoresResponseBody", async (t) => { - t.beforeEach(ResetDBState); - - const chartDoc = (await MONGODB_KILL.charts.usc.findOne()) as ChartDocument< - "usc:Controller" | "usc:Keyboard" - >; - - t.test("Should correctly return POSTScoresResponseBody", async (t) => { - await MONGODB_KILL.scores.insert(mockScoreDocument); - - await MONGODB_KILL["personal-bests"].insert(uscScorePBsSet); - await MONGODB_KILL.users.insert(mockUserDocs); - - await MONGODB_KILL.scores.insert({ - scoreID: "USER_1_SCORE_PB", - scoreMeta: { - replayID: "foo_bar", - }, - } as unknown as ScoreDocument); - - const res = await CreatePOSTScoresResponseBody(1, chartDoc, "USER_1_SCORE_PB"); - - t.hasStrict(res, { - score: { - score: 9_000_000, - username: "test_zkldi", - ranking: 5, - }, - serverRecord: { - score: 9_400_000, - username: "5", - ranking: 1, - }, - isServerRecord: false, - isPB: true, - sendReplay: "USER_1_SCORE_PB", - adjacentAbove: [ - { score: 9_300_000, username: "4", ranking: 2 }, - { score: 9_200_000, username: "3", ranking: 3 }, - { score: 9_100_000, username: "2", ranking: 4 }, - ], - adjacentBelow: [ - { score: 8_900_000, username: "6", ranking: 6 }, - { score: 8_800_000, username: "7", ranking: 7 }, - { score: 8_700_000, username: "8", ranking: 8 }, - ], - }); - - t.end(); - }); - - t.test("Should not return serverRecord in adjacentAbove", async (t) => { - await MONGODB_KILL.scores.insert(mockScoreDocument); - - await MONGODB_KILL["personal-bests"].insert(uscScorePBsSet); - await MONGODB_KILL.users.insert(mockUserDocs); - - await MONGODB_KILL.scores.insert({ - scoreID: "USER_4_SCORE_PB", - } as ScoreDocument); - - const res = await CreatePOSTScoresResponseBody(4, chartDoc, "USER_4_SCORE_PB"); - - t.hasStrict(res, { - score: { - score: 9_300_000, - username: "4", - ranking: 2, - }, - serverRecord: { - ranking: 1, - }, - isServerRecord: false, - isPB: true, - sendReplay: "USER_4_SCORE_PB", - }); - - t.strictSame( - res.adjacentAbove.map((e) => e.ranking), - [], - ); - - t.strictSame( - res.adjacentBelow.map((e) => e.ranking), - [3, 4, 5], - ); - - t.end(); - }); - - t.test("Should trim serverRecord in adjacentAbove", async (t) => { - await MONGODB_KILL.scores.insert(mockScoreDocument); - - await MONGODB_KILL["personal-bests"].insert(uscScorePBsSet); - await MONGODB_KILL.users.insert(mockUserDocs); - - await MONGODB_KILL.scores.insert({ - scoreID: "USER_3_SCORE_PB", - } as ScoreDocument); - - const res = await CreatePOSTScoresResponseBody(3, chartDoc, "USER_3_SCORE_PB"); - - t.hasStrict(res, { - score: { - score: 9_200_000, - username: "3", - ranking: 3, - }, - serverRecord: { - ranking: 1, - }, - isServerRecord: false, - isPB: true, - sendReplay: "USER_3_SCORE_PB", - }); - - t.strictSame( - res.adjacentAbove.map((e) => e.ranking), - [2], - ); - - t.strictSame( - res.adjacentBelow.map((e) => e.ranking), - [4, 5, 6], - ); - - t.end(); - }); - - t.test("Should throw on no ScorePB", async (t) => { - await MONGODB_KILL.scores.insert(mockScoreDocument); - - // await db["personal-bests"].insert(uscScorePBsSet); - await MONGODB_KILL.users.insert(mockUserDocs); - - await MONGODB_KILL.scores.insert({ - scoreID: "USER_1_SCORE_PB", - scoreMeta: { - replayID: "foo_bar", - }, - } as unknown as ScoreDocument); - - t.rejects(() => CreatePOSTScoresResponseBody(1, chartDoc, "USER_1_SCORE_PB"), { - message: /Score was imported for chart, but no ScorePB was available on this chart/u, - }); - - t.end(); - }); - - t.test("Should throw on no original score", async (t) => { - await MONGODB_KILL.scores.insert(mockScoreDocument); - - await MONGODB_KILL["personal-bests"].insert(uscScorePBsSet); - await MONGODB_KILL.users.insert(mockUserDocs); - - // await db.scores.insert({ - // scoreID: "USER_1_SCORE_PB", - // scoreMeta: { - // replayID: "foo_bar", - // }, - // } as any); - - t.rejects(() => CreatePOSTScoresResponseBody(1, chartDoc, "USER_1_SCORE_PB"), { - message: /Score with ID USER_1_SCORE_PB is not in the database/u, - }); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/server/router/ir/usc/_playtype/usc.test.ts b/typescript/server/src/server/router/ir/usc/_playtype/usc.test.ts new file mode 100644 index 000000000..0cfdb39e5 --- /dev/null +++ b/typescript/server/src/server/router/ir/usc/_playtype/usc.test.ts @@ -0,0 +1,169 @@ +import type { PBScoreDocument } from "tachi-common"; + +import { seedUser } from "#actions/test-utils/api-tokens"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { dmf } from "#test-utils/misc"; +import { TestingUSCChart, TestingUSCSong } from "#test-utils/test-data"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; +import deepmerge from "deepmerge"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { TachiScoreToServerScore } from "./usc"; + +const mockScorePB = { + chartID: "USC_CHART_ID", + calculatedData: { + VF6: 0, + }, + composedFrom: [ + { name: "Best Score", scoreID: "USC_EXAMPLE_SCORE_PB_ID" }, + { name: "Best Lamp", scoreID: "USC_EXAMPLE_LAMP_PB_ID" }, + ], + game: "usc-controller", + highlight: false, + isPrimary: true, + rankingData: { + outOf: 2, + rank: 1, + rivalRank: null, + }, + scoreData: { + grade: "AAA+", + judgements: { + critical: 100, + miss: 15, + }, + enumIndexes: { + grade: 7, + lamp: 3, + }, + lamp: "EXCESSIVE CLEAR", + score: 9_500_000, + optional: { + enumIndexes: {}, + }, + }, + songID: "S19d35e0e4396423789f", + timeAchieved: null, + userID: 1, +} satisfies PBScoreDocument<"usc-controller">; + +async function seedUscControllerChart() { + const chart = dmf(TestingUSCChart, { game: "usc-controller" } as never); + + await DB.insertInto("song") + .values({ + id: TestingUSCSong.id, + legacy_id: 1, + game_group: "usc", + title: TestingUSCSong.title, + artist: TestingUSCSong.artist, + search_terms: TestingUSCSong.searchTerms, + alt_titles: TestingUSCSong.altTitles, + data: TestingUSCSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chart.chartID, + legacy_id: chart.chartID, + game: "usc-controller", + song_id: TestingUSCSong.id, + difficulty: chart.difficulty, + level: chart.level, + level_num: chart.levelNum, + is_primary: chart.isPrimary, + versions: chart.versions, + data: chart.data, + }) + .execute(); +} + +async function insertReferencedScore(opts: { scoreId: string; timeMs: number }) { + const { data, derived, judgements } = mongoScoreDataToPg( + "usc-controller", + mockScorePB.scoreData, + ); + const t = UnixMillisecondsToISO8601(opts.timeMs); + + await DB.insertInto("score") + .values({ + id: opts.scoreId, + user_id: 1, + chart_id: "USC_CHART_ID", + game: "usc-controller", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({ + noteMod: "MIRROR", + gaugeMod: "HARD", + }), + time_achieved: t, + time_added: t, + highlight: false, + comment: null, + }) + .execute(); +} + +describe("TachiScoreToServerScore (Postgres)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_zkldi", + email: "usc-helpers@example.com", + withCredential: true, + withSettings: true, + }); + await seedUscControllerChart(); + }); + + it("converts a PB + score row to USC ServerScore", async () => { + await insertReferencedScore({ scoreId: "USC_EXAMPLE_SCORE_PB_ID", timeMs: 0 }); + + const res = await TachiScoreToServerScore(mockScorePB); + + expect(res).toEqual({ + score: 9_500_000, + timestamp: 0, + crit: 100, + near: 0, + error: 15, + ranking: 1, + lamp: 3, + username: "test_zkldi", + noteMod: "MIRROR", + gaugeMod: "HARD", + }); + }); + + it("uses Unix seconds for timestamp when timeAchieved is set", async () => { + await insertReferencedScore({ scoreId: "USC_EXAMPLE_SCORE_PB_ID", timeMs: 0 }); + + const res = await TachiScoreToServerScore( + deepmerge(mockScorePB, { timeAchieved: 1_621_844_762_995 }) as typeof mockScorePB, + ); + + expect(res.timestamp).toBe(1_621_844_762); + }); + + it("throws when the PB user has no account", async () => { + await expect( + TachiScoreToServerScore( + deepmerge(mockScorePB, { userID: 2 }) as PBScoreDocument<"usc-controller">, + ), + ).rejects.toThrow(/User 2 from PB on chart/u); + }); + + it("throws when the referenced score row is missing", async () => { + await expect(TachiScoreToServerScore(mockScorePB)).rejects.toThrow( + /Score USC_EXAMPLE_SCORE_PB_ID does not exist/u, + ); + }); +}); diff --git a/typescript/server/src/server/router/ir/usc/_playtype/usc.ts b/typescript/server/src/server/router/ir/usc/_playtype/usc.ts index 6a243e228..cbb33fa3a 100644 --- a/typescript/server/src/server/router/ir/usc/_playtype/usc.ts +++ b/typescript/server/src/server/router/ir/usc/_playtype/usc.ts @@ -1,24 +1,27 @@ import type { - GPTStrings, + ChartDocument, + GamesForGroup, integer, - MONGO_ChartDocument, - MONGO_PBScoreDocument, - MONGO_ScoreDocument, + PBScoreDocument, + ScoreDocument, } from "tachi-common"; import type { GetEnumValue } from "tachi-common/types/metrics"; import { USCIR_ADJACENT_SCORE_N } from "#lib/constants/usc-ir"; -import { LoadPbsAdjacentByChartRank } from "#lib/db-formats/pb"; +import { + GetPBOnChart, + GetServerRecordOnChart, + LoadPbsAdjacentByChartRank, +} from "#lib/db-formats/pb"; import { LoadScoreDocumentById } from "#lib/db-formats/score"; import { log } from "#lib/log/log"; import { MStoS } from "#utils/misc"; -import { GetPBOnChart, GetServerRecordOnChart } from "#utils/scores"; import { GetUsernameFromUserID } from "#utils/user"; import type { USCServerScore } from "./types"; export const TACHI_LAMP_TO_USC: Record< - GetEnumValue, + GetEnumValue, USCServerScore["lamp"] > = { // we don't do NO PLAY, so its not handled. @@ -36,7 +39,7 @@ export const TACHI_LAMP_TO_USC: Record< * fields are null. */ export async function TachiScoreToServerScore( - tachiScore: MONGO_PBScoreDocument<"usc:Controller" | "usc:Keyboard">, + tachiScore: PBScoreDocument, ): Promise { let username: string; try { @@ -53,7 +56,7 @@ export async function TachiScoreToServerScore( const firstScoreID = tachiScore.composedFrom[0].scoreID; const scorePB = (await LoadScoreDocumentById(firstScoreID)) as - | MONGO_ScoreDocument<"usc:Controller" | "usc:Keyboard"> + | ScoreDocument | undefined; if (!scorePB) { @@ -82,11 +85,11 @@ export async function TachiScoreToServerScore( export async function CreatePOSTScoresResponseBody( userID: integer, - chartDoc: MONGO_ChartDocument<"usc:Controller" | "usc:Keyboard">, + chartDoc: ChartDocument, scoreID: string, ): Promise { - const scorePB = (await GetPBOnChart(userID, chartDoc.chartID)) as MONGO_PBScoreDocument< - "usc:Controller" | "usc:Keyboard" + const scorePB = (await GetPBOnChart(userID, chartDoc.chartID)) as PBScoreDocument< + GamesForGroup["usc"] > | null; if (!scorePB) { @@ -102,9 +105,9 @@ export async function CreatePOSTScoresResponseBody( ); } - const ktServerRecord = (await GetServerRecordOnChart( - chartDoc.chartID, - )) as MONGO_PBScoreDocument<"usc:Controller" | "usc:Keyboard"> | null; + const ktServerRecord = (await GetServerRecordOnChart(chartDoc.chartID)) as PBScoreDocument< + GamesForGroup["usc"] + > | null; // this is impossible to trigger without making a race-condition. /* istanbul ignore next */ @@ -131,7 +134,7 @@ export async function CreatePOSTScoresResponseBody( usersRanking, "above", USCIR_ADJACENT_SCORE_N, - )) as Array>; + )) as Array>; // The specification enforces that we return them in // ascending order, though, so we reverse this after @@ -153,7 +156,7 @@ export async function CreatePOSTScoresResponseBody( usersRanking, "below", USCIR_ADJACENT_SCORE_N, - )) as Array>; + )) as Array>; const [score, serverRecord, adjacentAbove, adjacentBelow] = await Promise.all([ TachiScoreToServerScore(scorePB), @@ -163,7 +166,7 @@ export async function CreatePOSTScoresResponseBody( ]); const originalScore = (await LoadScoreDocumentById(scoreID)) as - | MONGO_ScoreDocument<"usc:Controller" | "usc:Keyboard"> + | ScoreDocument | undefined; if (!originalScore) { diff --git a/typescript/server/src/services/pg/seeds.ts b/typescript/server/src/services/pg/seeds.ts index 75cf65a77..8f86d693c 100644 --- a/typescript/server/src/services/pg/seeds.ts +++ b/typescript/server/src/services/pg/seeds.ts @@ -1,13 +1,15 @@ import { - type MONGO_BMSCourseDocument, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_GoalDocument, - type MONGO_QuestDocument, - type MONGO_QuestlineDocument, - type MONGO_SongDocument, - type MONGO_TableDocument, - V3ToGPTString, + type BMSCourseDocument, + type ChartDocument, + computeFolderSlug, + type FolderDocument, + type GoalDocument, + type QuestDocument, + type QuestlineDocument, + type SeedFolderRow, + type SongDocument, + type TableDocument, + type V3Game, } from "tachi-common"; /* eslint-disable no-await-in-loop */ import type { @@ -26,40 +28,42 @@ import type { Game as PgGame, } from "tachi-db"; -import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum"; -import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup.js"; +import { ComputeChartStabilityChecksum } from "#game-implementations/utils/derivation-checksum"; +import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup"; import fs from "fs"; import { type Insertable, type Kysely, sql } from "kysely"; import path from "path"; // ── Seed types ───────────────────────────────────────────────────────────── -type SeedSong = { id: string; legacySongID?: number } & Omit; +type SeedSong = { id: string; legacySongID?: number } & Omit; // songID is now a hex string (migrated from the old integer FK). type SeedChart = { id: string; legacyChartID?: string; songID: string; -} & Omit; +} & Omit; // After 3-migrate-folders-tables.ts: folderID → legacyFolderID + id, game+playtype → game. // After 5-folders-to-sql-queries.ts: `where` is the SQL predicate (no leading WHERE). type SeedFolder = { game: string; id: string; legacyFolderID?: string; + /** URL slug; becomes `folder.slug`. */ + slug?: string; /** Chart version filter; becomes `folder.version_filter`. */ versionFilter?: Array; /** SQL WHERE body from `5-folders-to-sql-queries.ts`; becomes `folder.where`. */ where: string; -} & Omit; -// After 3-migrate-folders-tables.ts: tableID → legacyTableID + id, game+playtype → game, -// folders array now contains new hex ids. +} & Omit; +// After 3-migrate-folders-tables.ts: tableID → legacyTableID + id, game+playtype → game. +// `folders` entries are folder slugs (unique per game); see `6-tables-folder-refs-to-slugs.ts`. type SeedTable = { folders: Array; game: string; id: string; legacyTableID?: string; -} & Omit; +} & Omit; const INSERT_CHUNK = 500; @@ -72,6 +76,40 @@ function seedFolderVersionFilter(f: SeedFolder): Array | null { return null; } +/** Canonical folder slug for a seed row (matches `folder.slug` in Postgres). */ +function seedFolderResolvedSlug(f: SeedFolder): string { + const seedRow: SeedFolderRow = { + game: f.game, + id: f.id, + title: f.title, + versionFilter: f.versionFilter, + where: f.where, + slug: f.slug, + }; + + return f.slug ?? computeFolderSlug(seedRow); +} + +/** Maps `game\\0slug` → folder id for resolving `tables.json` folder refs. */ +function buildFolderGameSlugToIdMap(folders: Array): Map { + const m = new Map(); + + for (const f of folders) { + const slug = seedFolderResolvedSlug(f); + const key = `${f.game}\0${slug}`; + + if (m.has(key)) { + throw new Error( + `Duplicate folder slug ${JSON.stringify(slug)} for game ${JSON.stringify(f.game)}`, + ); + } + + m.set(key, f.id); + } + + return m; +} + function readJsonSeed(seedsDir: string, filename: string): Array { return JSON.parse(fs.readFileSync(path.join(seedsDir, filename), "utf-8")) as Array; } @@ -165,7 +203,7 @@ async function upsertSongsForGameGroup( async function upsertChartsForPgGame( pg: Kysely, - pgGame: PgGame, + game: V3Game, charts: Array, ): Promise { if (charts.length === 0) { @@ -174,28 +212,26 @@ async function upsertChartsForPgGame( const chartRows: Array = []; - const gpt = V3ToGPTString(pgGame); - for (const c of charts) { if (!c.id) { throw new Error( `Chart ${c.legacyChartID ?? "(unknown)"}` + - ` (${pgGame}) is missing an id. Run 1-migrate-to-pg-style.ts first.`, + ` (${game}) is missing an id. Run 1-migrate-to-pg-style.ts first.`, ); } if (!c.legacyChartID) { throw new Error( - `Chart ${c.id} (${pgGame}) is missing legacyChartID. Run 1-migrate-to-pg-style.ts first.`, + `Chart ${c.id} (${game}) is missing legacyChartID. Run 1-migrate-to-pg-style.ts first.`, ); } - const checksum = computeDerivationChecksumForGPT(gpt, c as unknown as MONGO_ChartDocument); + const checksum = ComputeChartStabilityChecksum(game, c as unknown as ChartDocument); chartRows.push({ id: c.id, legacy_id: c.legacyChartID, - game: pgGame, + game: game, song_id: c.songID, level: c.level, level_num: c.levelNum, @@ -276,39 +312,16 @@ export async function importSeedsSubset( const filesForGame = chartFiles.filter((f) => f.startsWith(`charts-${gg}`)); for (const file of filesForGame) { - const pgGame = file.replace(/^charts-/u, "").replace(/\.json$/u, "") as PgGame; + const game = file.replace(/^charts-/u, "").replace(/\.json$/u, "") as V3Game; const charts = readJsonSeed(seedsDir, file).filter((c) => loadedSongIds.has(c.songID), ); - await upsertChartsForPgGame(pg, pgGame, charts); + await upsertChartsForPgGame(pg, game, charts); } } } -// ── Game helpers ─────────────────────────────────────────────────────────── - -const SINGLE_PT_GAMES = new Set([ - "arcaea", - "chunithm", - "jubeat", - "maimai", - "maimaidx", - "museca", - "ongeki", - "popn", - "sdvx", - "wacca", -]); - -export function toPgGame(gameGroup: string, legacyPlaytype: string): PgGame { - if (SINGLE_PT_GAMES.has(gameGroup)) { - return gameGroup as PgGame; - } - - return `${gameGroup}-${legacyPlaytype.toLowerCase()}` as PgGame; -} - // ── Chart ID map ─────────────────────────────────────────────────────────── /** @@ -389,11 +402,13 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi } // ── folders ──────────────────────────────────────────────────────────── + let folderSeedList: Array | undefined; + if (files.has("folders.json")) { console.log("[folder]"); - const folders = readCollection("folders.json"); + folderSeedList = readCollection("folders.json"); - const folderRows: Array = folders.map((f) => { + const folderRows: Array = folderSeedList.map((f) => { if (typeof f.where !== "string" || f.where.trim() === "") { throw new Error( `Folder "${f.title}" (${f.id}) has no non-empty "where" SQL string. ` + @@ -401,6 +416,14 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi ); } + const slug = seedFolderResolvedSlug(f); + + if (f.slug !== undefined && f.slug !== slug) { + throw new Error( + `Folder "${f.title}" (${f.id}): folders.json slug ${JSON.stringify(f.slug)} does not match computed ${JSON.stringify(slug)}`, + ); + } + return { id: f.id, legacy_id: @@ -413,6 +436,7 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi game: f.game as PgGame, inactive: f.inactive, title: f.title, + slug, where: f.where, version_filter: seedFolderVersionFilter(f), search_terms: f.searchTerms ?? [], @@ -429,6 +453,7 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi oc.column("id").doUpdateSet({ inactive: sql`excluded.inactive`, title: sql`excluded.title`, + slug: sql`excluded.slug`, where: sql`excluded.where`, version_filter: sql`excluded.version_filter`, search_terms: sql`excluded.search_terms`, @@ -437,12 +462,20 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi .execute(); } - console.log(` ${folders.length} folders\n`); + console.log(` ${folderSeedList.length} folders\n`); } // ── tables ───────────────────────────────────────────────────────────── if (files.has("tables.json")) { console.log("[table / table_folder]"); + + if (folderSeedList === undefined) { + throw new Error( + "tables.json requires folders.json to resolve folder slugs to ids for table_folder rows.", + ); + } + + const folderSlugToId = buildFolderGameSlugToIdMap(folderSeedList); const tables = readCollection("tables.json"); const tableRows: Array = tables.map((t) => ({ @@ -481,7 +514,21 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi await chunkedDeletePg(pg, "table_folder", "table_id", tableIds); const tfRows: Array = tables.flatMap((t) => - t.folders.map((folderId) => ({ table_id: t.id, folder_id: folderId })), + t.folders.map((folderSlug, ordering) => { + const folderId = folderSlugToId.get(`${t.game}\0${folderSlug}`); + + if (folderId === undefined) { + throw new Error( + `Table "${t.title}" (${t.id}): unknown folder slug ${JSON.stringify(folderSlug)} for game ${JSON.stringify(t.game)} (check tables.json / folders.json; run seeds-scripts/rerunners/v3/6-tables-folder-refs-to-slugs.ts if migrating from folder ids).`, + ); + } + + return { + table_id: t.id, + folder_id: folderId, + ordering, + }; + }), ); await batchIgnorePg(pg, "table_folder", tfRows); @@ -491,13 +538,13 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi // ── bms_course_lookup ────────────────────────────────────────────────── if (files.has("bms-course-lookup.json")) { console.log("[bms_course_lookup]"); - const courses = readCollection("bms-course-lookup.json"); + const courses = readCollection("bms-course-lookup.json"); const courseRows: Array = courses.map((c) => ({ md5sums: c.md5sums, title: c.title, set: c.set as string, - playtype: c.playtype as string, + game: c.game as PgGame, value: c.value as string, })); @@ -508,11 +555,11 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi // ── goals ────────────────────────────────────────────────────────────── if (files.has("goals.json")) { console.log("[goal]"); - const goals = readCollection("goals.json"); + const goals = readCollection("goals.json"); const goalRows: Array = goals.map((g) => ({ id: g.goalID, - game: toPgGame(g.game, g.playtype), + game: g.game, name: g.name, charts: JSON.stringify(g.charts), criteria: JSON.stringify(g.criteria), @@ -526,11 +573,11 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi // ── quests ───────────────────────────────────────────────────────────── if (files.has("quests.json")) { console.log("[quest]"); - const quests = readCollection("quests.json"); + const quests = readCollection("quests.json"); const questRows: Array = quests.map((q) => ({ id: q.questID, - game: toPgGame(q.game, q.playtype), + game: q.game, name: q.name, description: q.desc, quest_data: JSON.stringify(q.questData), @@ -558,11 +605,11 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi // ── questlines ───────────────────────────────────────────────────────── if (files.has("questlines.json")) { console.log("[questline / questline_quest]"); - const questlines = readCollection("questlines.json"); + const questlines = readCollection("questlines.json"); const qlRows: Array = questlines.map((ql) => ({ id: ql.questlineID, - game: toPgGame(ql.game, ql.playtype), + game: ql.game, name: ql.name, description: ql.desc, })); diff --git a/typescript/server/src/test-utils/misc.ts b/typescript/server/src/test-utils/misc.ts index a1e779f2f..63ded6712 100644 --- a/typescript/server/src/test-utils/misc.ts +++ b/typescript/server/src/test-utils/misc.ts @@ -2,22 +2,19 @@ import type { DeepPartial } from "#utils/types"; import deepmerge from "deepmerge"; import { - type GameGroup, - type GPTString, + type ChartDocument, + type GoalDocument, + type GoalSubscriptionDocument, + type ImportDocument, type integer, - type MONGO_ChartDocument, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_ImportDocument, - type MONGO_NotificationDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreData, - type MONGO_ScoreDocument, - type MONGO_UGPTSettingsDocument, - type MONGO_UserDocument, - type MONGO_UserGameStats, - MongoChartLegacyId, - type Playtype, + type NotificationDocument, + type PBScoreDocument, + type ScoreData, + type ScoreDocument, + type UGPTSettingsDocument, + type UserDocument, + type UserGameStats, + type V3Game, } from "tachi-common"; import { @@ -56,7 +53,7 @@ export function dmf(base: T, modifant: DeepPartial): T { // @ts-expect-error LOLDEEPMERGETYPES return deepmerge(base, modifant, { // The new array should replace the former one, instead of joining them together. - arrayMerge: (originalArray, newArray) => newArray as Array, + arrayMerge: (_originalArray, newArray) => newArray as Array, }); } @@ -66,7 +63,7 @@ export function dmf(base: T, modifant: DeepPartial): T { * * @param userID - The userID this fake user should have. */ -export function mkFakeUser(userID: integer, modifant: DeepPartial = {}) { +export function mkFakeUser(userID: integer, modifant: DeepPartial = {}) { return dmf(FakeOtherUser, { id: userID, username: `user${userID}`, @@ -77,63 +74,60 @@ export function mkFakeUser(userID: integer, modifant: DeepPartial = {}, + game: V3Game, + modifant: DeepPartial = {}, ) { return dmf(FakeGameSettings, { userID, game, - playtype, ...modifant, }); } -export function mkFakeImport(modifant: DeepPartial = {}) { +export function mkFakeImport(modifant: DeepPartial = {}) { return dmf(FakeImport, modifant); } -export function mkFakeScoreIIDXSP(modifant: DeepPartial> = {}) { +export function mkFakeScoreIIDXSP(modifant: DeepPartial> = {}) { return dmf(TestingIIDXSPScore, modifant); } -export function mkFakeScoreSDVX(modifant: DeepPartial> = {}) { +export function mkFakeScoreSDVX(modifant: DeepPartial> = {}) { return dmf(TestingSDVXScore, modifant); } -export function mkFakePBIIDXSP(modifant: DeepPartial> = {}) { +export function mkFakePBIIDXSP(modifant: DeepPartial> = {}) { return dmf(TestingIIDXSPScorePB, modifant); } -export function mkFakePBDDRSP(modifant: DeepPartial> = {}) { +export function mkFakePBDDRSP(modifant: DeepPartial> = {}) { return dmf(TestingDDRSPScorePB, modifant); } -export function mkFakePBJubeat(modifant: DeepPartial> = {}) { +export function mkFakePBJubeat(modifant: DeepPartial> = {}) { return dmf(TestingJubeatPB, modifant); } -export function mkFakeNotification(modifant: DeepPartial = {}) { +export function mkFakeNotification(modifant: DeepPartial = {}) { return dmf(FakeNotification, modifant); } -export function mkFakeGoal(modifant: DeepPartial = {}) { +export function mkFakeGoal(modifant: DeepPartial = {}) { return dmf(HC511Goal, modifant); } -export function mkFakeGoalSub(modifant: DeepPartial = {}) { +export function mkFakeGoalSub(modifant: DeepPartial = {}) { return dmf(HC511UserGoal, modifant); } export function mkFakeGameStats( userID: integer, - modifant: DeepPartial = {}, -): MONGO_UserGameStats { + modifant: DeepPartial = {}, +): UserGameStats { return dmf( { userID, - game: "iidx", - playtype: "SP", + game: "iidx-sp", classes: {}, ratings: {}, }, @@ -144,7 +138,7 @@ export function mkFakeGameStats( export function mkFakeSDVXChart( chartID: string, - modifant: DeepPartial> = {}, + modifant: DeepPartial> = {}, ) { return dmf(TestingSDVXAlbidaChart, { chartID, @@ -152,53 +146,48 @@ export function mkFakeSDVXChart( }); } -export function mkFakeSDVXPB(modifant: DeepPartial> = {}) { +export function mkFakeSDVXPB(modifant: DeepPartial> = {}) { return dmf(TestingSDVXPB, modifant); } -export function mkMockPB( - game: GameGroup, - playtype: Playtype, - chart: MONGO_ChartDocument, - scoreData: MONGO_ScoreData, -): MONGO_PBScoreDocument { +export function mkMockPB( + game: TGame, + chart: ChartDocument, + scoreData: ScoreData, +): PBScoreDocument { return { userID: 1, - composedFrom: [{ name: "Best Percent", scoreID: `TEST_${game}:${playtype}_SCORE` }], + composedFrom: [{ name: "Best Percent", scoreID: `TEST_${game}_SCORE` }], game, - playtype, highlight: false, isPrimary: true, rankingData: { outOf: 1, rank: 1, rivalRank: null }, - songID: chart.songID, - chartID: MongoChartLegacyId(chart), + songID: chart.song.id, + chartID: chart.chartID, calculatedData: {}, scoreData, timeAchieved: null, }; } -export function mkMockScore( - game: GameGroup, - playtype: Playtype, - chart: MONGO_ChartDocument, - scoreData: MONGO_ScoreData, -): MONGO_ScoreDocument { - // @ts-expect-error whatever lol +export function mkMockScore( + game: TGame, + chart: ChartDocument, + scoreData: ScoreData, +): ScoreDocument { return { userID: 1, game, - playtype, highlight: false, isPrimary: true, - songID: chart.songID, - chartID: MongoChartLegacyId(chart), + songID: chart.song.id, + chartID: chart.chartID, calculatedData: {}, scoreData, timeAchieved: null, comment: null, importType: null, - scoreID: `TEST_${game}:${playtype}_SCORE`, + scoreID: `TEST_${game}_SCORE`, scoreMeta: {}, service: "TESTING", timeAdded: 1, diff --git a/typescript/server/src/test-utils/test-data.ts b/typescript/server/src/test-utils/test-data.ts index 66739b6c3..35e5992c0 100644 --- a/typescript/server/src/test-utils/test-data.ts +++ b/typescript/server/src/test-utils/test-data.ts @@ -21,22 +21,22 @@ import { ApplyNTimes, RFA } from "#utils/misc"; import fs from "fs"; import path from "path"; import { + type ChartDocument, CHUNITHM_CLEAR_LAMPS, CHUNITHM_GRADES, CHUNITHM_NOTE_LAMPS, - type MONGO_ChartDocument, - type MONGO_FolderDocument, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_ImportDocument, - type MONGO_NotificationDocument, - type MONGO_PBScoreDocument, - type MONGO_QuestDocument, - type MONGO_QuestSubscriptionDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, - type MONGO_UGPTSettingsDocument, - type MONGO_UserDocument, + type FolderDocument, + type GoalDocument, + type GoalSubscriptionDocument, + type ImportDocument, + type NotificationDocument, + type PBScoreDocument, + type QuestDocument, + type QuestSubscriptionDocument, + type ScoreDocument, + type SongDocument, + type UGPTSettingsDocument, + type UserDocument, } from "tachi-common"; import { dmf } from "./misc"; @@ -48,9 +48,9 @@ export const GetKTDataJSON = (name: string) => export const GetKTDataBuffer = (name: string) => fs.readFileSync(file(name)); -export const TestingIIDXSPDryScore: DryScore<"iidx:SP"> = { +export const TestingIIDXSPDryScore: DryScore<"iidx-sp"> = { service: "e-amusement", - game: "iidx", + game: "iidx-sp", scoreData: { score: 786, lamp: "CLEAR", @@ -66,7 +66,7 @@ export const TestingIIDXSPDryScore: DryScore<"iidx:SP"> = { importType: "file/eamusement-iidx-csv", }; -export const TestingIIDXSPScorePB: MONGO_PBScoreDocument<"iidx:SP"> = { +export const TestingIIDXSPScorePB: PBScoreDocument<"iidx-sp"> = { chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", userID: 1, calculatedData: { @@ -99,13 +99,12 @@ export const TestingIIDXSPScorePB: MONGO_PBScoreDocument<"iidx:SP"> = { outOf: 2, rivalRank: null, }, - songID: 1, - game: "iidx", - playtype: "SP", + songID: "s1", + game: "iidx-sp", timeAchieved: 10000, }; -export const TestingDDRSPScorePB: MONGO_PBScoreDocument<"ddr:SP"> = { +export const TestingDDRSPScorePB: PBScoreDocument<"ddr-sp"> = { calculatedData: { flareSkill: 545, }, @@ -116,10 +115,9 @@ export const TestingDDRSPScorePB: MONGO_PBScoreDocument<"ddr:SP"> = { scoreID: "Te065000f08b49458f8b0aa3eabf0b857b79c562d7cf9eb34f6dabd7a1c3c3fa6", }, ], - game: "ddr", + game: "ddr-sp", highlight: false, isPrimary: true, - playtype: "SP", rankingData: { rank: 1, outOf: 1, @@ -138,12 +136,12 @@ export const TestingDDRSPScorePB: MONGO_PBScoreDocument<"ddr:SP"> = { grade: 13, }, }, - songID: 37691, + songID: "s37691", timeAchieved: null, userID: 1, }; -export const TestingJubeatPB: MONGO_PBScoreDocument<"jubeat:Single"> = { +export const TestingJubeatPB: PBScoreDocument<"jubeat"> = { chartID: "b90a319f18d1a746b330b8f4cd6f74874f664421", userID: 1, calculatedData: { @@ -171,16 +169,14 @@ export const TestingJubeatPB: MONGO_PBScoreDocument<"jubeat:Single"> = { outOf: 2, rivalRank: null, }, - songID: 1, + songID: "s1", game: "jubeat", - playtype: "Single", timeAchieved: 10000, }; -export const TestingIIDXSPScore: MONGO_ScoreDocument<"iidx:SP"> = { +export const TestingIIDXSPScore: ScoreDocument<"iidx-sp"> = { service: "foo (DIRECT-MANUAL)", - game: "iidx", - playtype: "SP", + game: "iidx-sp", userID: 1, scoreData: { score: 786, @@ -204,7 +200,7 @@ export const TestingIIDXSPScore: MONGO_ScoreDocument<"iidx:SP"> = { ktLampRating: 5, }, timeAchieved: 1619454485988, - songID: 1, + songID: "s1", chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", highlight: false, isPrimary: true, @@ -214,17 +210,16 @@ export const TestingIIDXSPScore: MONGO_ScoreDocument<"iidx:SP"> = { importType: "ir/direct-manual", }; -export const TestingDDRSPScore: MONGO_ScoreDocument<"ddr:SP"> = { +export const TestingDDRSPScore: ScoreDocument<"ddr-sp"> = { calculatedData: { flareSkill: 545, }, chartID: "2fc2e0cfdda42addb7840b58be1df1f545310d66", comment: null, - game: "ddr", + game: "ddr-sp", highlight: false, importType: "file/batch-manual", isPrimary: true, - playtype: "SP", scoreData: { score: 914400, lamp: "CLEAR", @@ -241,16 +236,15 @@ export const TestingDDRSPScore: MONGO_ScoreDocument<"ddr:SP"> = { scoreID: "Te065000f08b49458f8b0aa3eabf0b857b79c562d7cf9eb34f6dabd7a1c3c3fa6", scoreMeta: {}, service: "History (BATCH-MANUAL)", - songID: 10, + songID: "s10", timeAchieved: null, timeAdded: 1722084133807, userID: 1, }; -export const TestingSDVXScore: MONGO_ScoreDocument<"sdvx:Single"> = { +export const TestingSDVXScore: ScoreDocument<"sdvx"> = { service: "foo (DIRECT-MANUAL)", game: "sdvx", - playtype: "Single", userID: 1, scoreData: { score: 786, @@ -268,7 +262,7 @@ export const TestingSDVXScore: MONGO_ScoreDocument<"sdvx:Single"> = { scoreMeta: {}, calculatedData: {}, timeAchieved: 1619454485988, - songID: 1, + songID: "s1", // albida adv chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", @@ -280,9 +274,8 @@ export const TestingSDVXScore: MONGO_ScoreDocument<"sdvx:Single"> = { importType: "ir/direct-manual", }; -export const TestingSDVXPB: MONGO_PBScoreDocument<"sdvx:Single"> = { +export const TestingSDVXPB: PBScoreDocument<"sdvx"> = { game: "sdvx", - playtype: "Single", userID: 1, scoreData: { score: 786, @@ -299,7 +292,7 @@ export const TestingSDVXPB: MONGO_PBScoreDocument<"sdvx:Single"> = { }, calculatedData: {}, timeAchieved: 1619454485988, - songID: 1, + songID: "s1", // albida adv chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", @@ -313,11 +306,22 @@ export const TestingSDVXPB: MONGO_PBScoreDocument<"sdvx:Single"> = { }, }; -export const TestingSDVXAlbidaChart: MONGO_ChartDocument<"sdvx:Single"> = { +export const TestingSDVXAlbidaSong: SongDocument<"sdvx"> = { + title: "ALBIDA Powerless Mix", + artist: "無力P", + id: "s1", + altTitles: [], + searchTerms: ["albida_muryoku", "アルビダパワーレスミックス"], + data: { + displayVersion: "booth", + }, +}; + +export const TestingSDVXAlbidaChart: ChartDocument<"sdvx"> = { + game: "sdvx", chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", difficulty: "ADV", - songID: 1, - playtype: "Single", + song: TestingSDVXAlbidaSong, levelNum: 10, level: "10", data: { @@ -356,18 +360,17 @@ export const TestingKsHookSV6CStaticScore: KsHookSV6CStaticScore = { timestamp: 1000, }; -export const TestingBMS7KScore: MONGO_ScoreDocument<"bms:7K"> = { +export const TestingBMS7KScore: ScoreDocument<"bms-7k"> = { calculatedData: { sieglinde: 20, }, chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", - songID: 27339, + songID: "s27339", comment: null, - game: "bms", + game: "bms-7k", highlight: false, importType: "ir/beatoraja", isPrimary: true, - playtype: "7K", scoreData: { grade: "A", judgements: {}, @@ -418,14 +421,25 @@ export const TestingLR2HookScore: LR2HookScore = { unixTimestamp: undefined, }; -export const TestingJubeatChart: MONGO_ChartDocument<"jubeat:Single"> = { +export const TestingJubeatSong: SongDocument<"jubeat"> = { + altTitles: [], + artist: "Ryu☆", + data: { + displayVersion: "jubeat", + }, + id: "S19d35e0ca47f0639c3b", + searchTerms: [], + title: "bass 2 bass", +}; + +export const TestingJubeatChart: ChartDocument<"jubeat"> = { + game: "jubeat", chartID: "b90a319f18d1a746b330b8f4cd6f74874f664421", - songID: 1, + song: TestingJubeatSong, level: "6", levelNum: 6, difficulty: "ADV", isPrimary: true, - playtype: "Single", data: { inGameID: 10000001, noteCount: 100, @@ -434,7 +448,17 @@ export const TestingJubeatChart: MONGO_ChartDocument<"jubeat:Single"> = { versions: ["festo"], }; -export const TestingGitadoraChart: MONGO_ChartDocument<"gitadora:Dora" | "gitadora:Gita"> = { +export const TestingGitadoraSong: SongDocument<"gitadora"> = { + altTitles: [], + artist: "ELIZABETH DOBBS", + data: {}, + id: "S19d35e0c4eebf35c1d0", + searchTerms: ["I THINK ABOUT Y"], + title: "I think about you", +}; + +export const TestingGitadoraChart: ChartDocument<"gitadora-dora"> = { + game: "gitadora-dora", chartID: "bf62dd6c78afaf6b4f95cce70b5b08af193c0c57", data: { inGameID: 0, @@ -443,12 +467,25 @@ export const TestingGitadoraChart: MONGO_ChartDocument<"gitadora:Dora" | "gitado isPrimary: true, level: "4.75", levelNum: 4.75, - playtype: "Dora", - songID: 0, + song: TestingGitadoraSong, versions: ["konaste"], }; -export const TestingMaimaiChart: MONGO_ChartDocument<"maimai:Single"> = { +export const TestingMaimaiSong: SongDocument<"maimai"> = { + altTitles: [], + artist: "Kai/classical “Air on the G String”", + data: { + artistJP: "Kai/クラシック「G線上のアリア」", + displayVersion: "maimai", + titleJP: "True Love Song", + }, + id: "S19d35e0cf682287eabb", + searchTerms: [], + title: "True Love Song", +}; + +export const TestingMaimaiChart: ChartDocument<"maimai"> = { + game: "maimai", chartID: "6e087e5f58519a2697374b9bff776a004046e952", data: { inGameID: 834, @@ -459,12 +496,24 @@ export const TestingMaimaiChart: MONGO_ChartDocument<"maimai:Single"> = { isPrimary: true, level: "13+", levelNum: 13.9, - playtype: "Single", - songID: 834, + song: TestingMaimaiSong, versions: ["finale"], }; -export const TestingMaimaiDXChart: MONGO_ChartDocument<"maimaidx:Single"> = { +export const TestingMaimaiDXSong: SongDocument<"maimaidx"> = { + altTitles: [], + artist: "Kai/クラシック「G線上のアリア」", + data: { + duration: 97.6, + genre: "maimai", + }, + id: "S19d35e0d483de1de978", + searchTerms: [], + title: "True Love Song", +}; + +export const TestingMaimaiDXChart: ChartDocument<"maimaidx"> = { + game: "maimaidx", chartID: "fbde73d543f93cbad9ec67052a7165700c5c8df6", data: { displayVersion: "maimai", @@ -474,12 +523,25 @@ export const TestingMaimaiDXChart: MONGO_ChartDocument<"maimaidx:Single"> = { isPrimary: true, level: "7", levelNum: 7, - playtype: "Single", - songID: 1, + song: TestingMaimaiDXSong, versions: ["universeplus", "festival"], }; -export const TestingMusecaChart: MONGO_ChartDocument<"museca:Single"> = { +export const TestingMusecaSong: SongDocument<"museca"> = { + altTitles: [], + artist: "Last Note. feat. GUMI", + data: { + artistJP: "ラストノートフィーチャリンググミ", + displayVersion: "1", + titleJP: "セツナトリップ", + }, + id: "S19d35e0b424a871d843", + searchTerms: [], + title: "セツナトリップ", +}; + +export const TestingMusecaChart: ChartDocument<"museca"> = { + game: "museca", chartID: "4f3a7bf907f7be54a0c531dd9077e58fd7b6bb9c", data: { inGameID: 1, @@ -488,12 +550,25 @@ export const TestingMusecaChart: MONGO_ChartDocument<"museca:Single"> = { isPrimary: true, level: "13", levelNum: 13, - playtype: "Single", - songID: 1, + song: TestingMusecaSong, versions: ["1.5", "1.5-b"], }; -export const TestingPopnChart: MONGO_ChartDocument<"popn:9B"> = { +export const TestingPopnSong: SongDocument<"popn"> = { + altTitles: [], + artist: "SUGI & REO", + data: { + displayVersion: null, + genre: "ポップス", + genreEN: "POPS", + }, + id: "S19d35e0d9d5fa4f67e0", + searchTerms: [], + title: "I REALLY WANT TO HURT YOU", +}; + +export const TestingPopnChart: ChartDocument<"popn"> = { + game: "popn", chartID: "7489166ddfb6ec780249823d5bda255611fbc528", data: { hashSHA256: "83287260cb32c5c54b06b76967ba8620dd3735063959d9fbaf2cffc0dba8a049", @@ -503,16 +578,27 @@ export const TestingPopnChart: MONGO_ChartDocument<"popn:9B"> = { isPrimary: true, level: "38", levelNum: 38, - playtype: "9B", - songID: 1, + song: TestingPopnSong, versions: ["peace", "kaimei"], }; -export const Testing511SPA: MONGO_ChartDocument<"iidx:SP"> = { +export const Testing511Song: SongDocument<"iidx"> = { + title: "5.1.1.", + artist: "dj nagureo", + id: "s1", + altTitles: [], + searchTerms: [], + data: { + genre: "PIANO AMBIENT", + displayVersion: "1", + }, +}; + +export const Testing511SPA: ChartDocument<"iidx-sp"> = { + game: "iidx-sp", chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", difficulty: "ANOTHER", - songID: 1, - playtype: "SP", + song: Testing511Song, levelNum: 10, level: "10", data: { @@ -543,19 +629,20 @@ export const Testing511SPA: MONGO_ChartDocument<"iidx:SP"> = { ], }; -export const Testing511Song: MONGO_SongDocument<"iidx"> = { - title: "5.1.1.", - artist: "dj nagureo", - id: 1, +export const TestingDDRSong: SongDocument<"ddr"> = { altTitles: [], - searchTerms: [], + artist: "UZI-LAY", data: { - genre: "PIANO AMBIENT", - displayVersion: "1", + flareCategory: "CLASSIC", + inGameID: 10, }, + id: "s10", + searchTerms: [], + title: "PUT YOUR FAITH IN ME", }; -export const TestingDDRSP: MONGO_ChartDocument<"ddr:SP"> = { +export const TestingDDRSP: ChartDocument<"ddr-sp"> = { + game: "ddr-sp", chartID: "2fc2e0cfdda42addb7840b58be1df1f545310d66", data: { inGameID: 10, @@ -564,28 +651,15 @@ export const TestingDDRSP: MONGO_ChartDocument<"ddr:SP"> = { isPrimary: true, level: "6", levelNum: 6, - playtype: "SP", - songID: 10, + song: TestingDDRSong, versions: ["a3"], }; -export const TestingDDRSong: MONGO_SongDocument<"ddr"> = { - altTitles: [], - artist: "UZI-LAY", - data: { - flareCategory: "CLASSIC", - inGameID: 10, - }, - id: 10, - searchTerms: [], - title: "PUT YOUR FAITH IN ME", -}; - -export const TestingAlbidaADV: MONGO_ChartDocument<"sdvx:Single"> = { +export const TestingAlbidaADV: ChartDocument<"sdvx"> = { + game: "sdvx", chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", difficulty: "ADV", - songID: 1, - playtype: "Single", + song: TestingSDVXAlbidaSong, levelNum: 10, level: "10", data: { @@ -596,19 +670,23 @@ export const TestingAlbidaADV: MONGO_ChartDocument<"sdvx:Single"> = { versions: ["booth", "inf", "gw", "heaven", "vivid", "exceed", "konaste"], }; -export const TestingSDVXAlbidaSong: MONGO_SongDocument<"sdvx"> = { - title: "ALBIDA Powerless Mix", - artist: "無力P", - id: 1, - altTitles: [], - searchTerms: ["albida_muryoku", "アルビダパワーレスミックス"], +export const BMSGazerSong: SongDocument<"bms"> = { + id: "s27339", + title: "gazer [MANIAQ]", + artist: "scytheleg / obj.siokaze", data: { - displayVersion: "booth", + subtitle: null, + subartist: null, + genre: "ELECTRANCE", + tableString: null, }, + searchTerms: [], + altTitles: [], }; -export const BMSGazerChart: MONGO_ChartDocument<"bms:7K"> = { - songID: 27339, +export const BMSGazerChart: ChartDocument<"bms-7k"> = { + game: "bms-7k", + song: BMSGazerSong, chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", data: { aiLevel: "0", @@ -624,30 +702,28 @@ export const BMSGazerChart: MONGO_ChartDocument<"bms:7K"> = { level: "?", levelNum: 0, difficulty: "CHART", - playtype: "7K", isPrimary: true, versions: [], }; -export const BMSGazerSong: MONGO_SongDocument<"bms"> = { - id: 27339, - title: "gazer [MANIAQ]", - artist: "scytheleg / obj.siokaze", - data: { - subtitle: null, - subartist: null, - genre: "ELECTRANCE", - tableString: null, - }, - searchTerms: [], +export const CHUNITHMBBKKSong: SongDocument<"chunithm"> = { altTitles: [], + artist: "nora2r", + data: { + displayVersion: "CHUNITHM", + duration: 128.541, + genre: "VARIETY", + }, + id: "S19d35e0b935765ded25", + searchTerms: [], + title: "B.B.K.K.B.K.K.", }; -export const CHUNITHMBBKKChart: MONGO_ChartDocument<"chunithm:Single"> = { +export const CHUNITHMBBKKChart: ChartDocument<"chunithm"> = { + game: "chunithm", chartID: "192b96bdb6150f80ba6412ce02df1249e16c0cb0", difficulty: "BASIC", - songID: 3, - playtype: "Single", + song: CHUNITHMBBKKSong, levelNum: 3, level: "3", data: { @@ -657,7 +733,7 @@ export const CHUNITHMBBKKChart: MONGO_ChartDocument<"chunithm:Single"> = { versions: ["paradiselost"], }; -export const TestingChunithmScorePB: MONGO_PBScoreDocument<"chunithm:Single"> = { +export const TestingChunithmScorePB: PBScoreDocument<"chunithm"> = { chartID: "192b96bdb6150f80ba6412ce02df1249e16c0cb0", userID: 1, calculatedData: { @@ -686,17 +762,16 @@ export const TestingChunithmScorePB: MONGO_PBScoreDocument<"chunithm:Single"> = outOf: 1, rivalRank: null, }, - songID: 3, + songID: "s3", game: "chunithm", - playtype: "Single", timeAchieved: 10000, }; -export const TestingDoraChart: MONGO_ChartDocument<"gitadora:Dora"> = { - songID: 0, +export const TestingDoraChart: ChartDocument<"gitadora-dora"> = { + game: "gitadora-dora", + song: TestingGitadoraSong, chartID: "29f0bfab357ba54e3fd0176fb3cbc578c9ec8df5", difficulty: "BASIC", - playtype: "Dora", levelNum: 1.6, level: "1.60", data: { @@ -706,22 +781,8 @@ export const TestingDoraChart: MONGO_ChartDocument<"gitadora:Dora"> = { versions: ["konaste"], }; -export const TestingWaccaPupaExp: MONGO_ChartDocument<"wacca:Single"> = { - songID: 77, - chartID: "2abcf2da86f3d968dbe07695ab7ebf6b79cb0841", - difficulty: "EXPERT", - playtype: "Single", - levelNum: 13.7, - level: "13+", - data: { - inGameID: 2085, - }, - isPrimary: true, - versions: ["reverse"], -}; - -export const TestingWaccaPupaSong: MONGO_SongDocument<"wacca"> = { - id: 77, +export const TestingWaccaPupaSong: SongDocument<"wacca"> = { + id: "s77", title: "PUPA", artist: "モリモリあつし", data: { @@ -732,7 +793,21 @@ export const TestingWaccaPupaSong: MONGO_SongDocument<"wacca"> = { altTitles: [], }; -export const TestingSDVXSingleDryScore: DryScore<"sdvx:Single"> = { +export const TestingWaccaPupaExp: ChartDocument<"wacca"> = { + game: "wacca", + song: TestingWaccaPupaSong, + chartID: "2abcf2da86f3d968dbe07695ab7ebf6b79cb0841", + difficulty: "EXPERT", + levelNum: 13.7, + level: "13+", + data: { + inGameID: 2085, + }, + isPrimary: true, + versions: ["reverse"], +}; + +export const TestingSDVXSingleDryScore: DryScore<"sdvx"> = { service: "e-amusement", game: "sdvx", scoreData: { @@ -747,9 +822,9 @@ export const TestingSDVXSingleDryScore: DryScore<"sdvx:Single"> = { importType: "file/batch-manual", }; -export const TestingGITADORADoraDryScore: DryScore<"gitadora:Dora"> = { +export const TestingGITADORADoraDryScore: DryScore<"gitadora-dora"> = { service: "fake-service", - game: "gitadora", + game: "gitadora-dora", scoreData: { lamp: "CLEAR", percent: 70.76, @@ -795,14 +870,13 @@ export const TestingBarbatosScore: BarbatosScore = { is_skill_analyzer: false, }; -export const HC511Goal: MONGO_GoalDocument = { +export const HC511Goal: GoalDocument = { charts: { type: "single", data: Testing511SPA.chartID, }, - game: "iidx", + game: "iidx-sp", goalID: "mock_goalID", - playtype: "SP", name: "HC 5.1.1. SPA", criteria: { mode: "single", @@ -811,12 +885,11 @@ export const HC511Goal: MONGO_GoalDocument = { }, }; -export const HC511UserGoal: MONGO_GoalSubscriptionDocument = { +export const HC511UserGoal: GoalSubscriptionDocument = { achieved: false, wasInstantlyAchieved: false, timeAchieved: null, - game: "iidx", - playtype: "SP", + game: "iidx-sp", goalID: "mock_goalID", lastInteraction: null, outOf: 5, @@ -827,12 +900,12 @@ export const HC511UserGoal: MONGO_GoalSubscriptionDocument = { wasAssignedStandalone: false, }; -export const TestingIIDXFolderSP10: MONGO_FolderDocument = { +export const TestingIIDXFolderSP10: FolderDocument = { title: "Level 10", - game: "iidx", - playtype: "SP", + game: "iidx-sp", type: "charts", folderID: "ed9d8c734447ce67d7135c0067441a98cc81aeaf", + slug: "level-10", data: { level: "10", }, @@ -840,10 +913,9 @@ export const TestingIIDXFolderSP10: MONGO_FolderDocument = { inactive: false, }; -export const TestingIIDXSPQuest: MONGO_QuestDocument = { +export const TestingIIDXSPQuest: QuestDocument = { desc: "testing quest", - game: "iidx", - playtype: "SP", + game: "iidx-sp", questID: "example_quest_id", name: "Example Quest", questData: [ @@ -874,25 +946,24 @@ export const TestingIIDXSPQuest: MONGO_QuestDocument = { ], }; -export const IIDXSPQuestGoals: Array = [ - dmf(HC511Goal, { goalID: "eg_goal_1" }) as MONGO_GoalDocument, +export const IIDXSPQuestGoals: Array = [ + dmf(HC511Goal, { goalID: "eg_goal_1" }) as GoalDocument, dmf(HC511Goal, { goalID: "eg_goal_2", criteria: { value: 2 } }), dmf(HC511Goal, { goalID: "eg_goal_3", criteria: { key: "score", value: 300 } }), dmf(HC511Goal, { goalID: "eg_goal_4", criteria: { key: "score", value: 1100 } }), ]; -export const IIDXSPQuestGoalSubs: Array = [ - dmf(HC511UserGoal, { goalID: "eg_goal_1" }) as MONGO_GoalSubscriptionDocument, - dmf(HC511UserGoal, { goalID: "eg_goal_2" }) as MONGO_GoalSubscriptionDocument, - dmf(HC511UserGoal, { goalID: "eg_goal_3" }) as MONGO_GoalSubscriptionDocument, - dmf(HC511UserGoal, { goalID: "eg_goal_4" }) as MONGO_GoalSubscriptionDocument, +export const IIDXSPQuestGoalSubs: Array = [ + dmf(HC511UserGoal, { goalID: "eg_goal_1" }) as GoalSubscriptionDocument, + dmf(HC511UserGoal, { goalID: "eg_goal_2" }) as GoalSubscriptionDocument, + dmf(HC511UserGoal, { goalID: "eg_goal_3" }) as GoalSubscriptionDocument, + dmf(HC511UserGoal, { goalID: "eg_goal_4" }) as GoalSubscriptionDocument, ]; -export const TestingIIDXSPQuestSub: MONGO_QuestSubscriptionDocument = { +export const TestingIIDXSPQuestSub: QuestSubscriptionDocument = { userID: 1, achieved: false, - game: "iidx", - playtype: "SP", + game: "iidx-sp", lastInteraction: null, questID: "example_quest_id", progress: 4, @@ -954,11 +1025,20 @@ export const MockBarbatosSDVX6Score: BarbatosSDVX6Score = { vol_error: 15, }; -export const TestingUSCChart: MONGO_ChartDocument<"usc:Controller" | "usc:Keyboard"> = { +export const TestingUSCSong: SongDocument<"usc"> = { + altTitles: [], + artist: "IDEA+RHYTHM", + data: {}, + id: "S19d35e0e4396423789f", + searchTerms: ["59 idearhythm"], + title: ".59 -BOOTH BOOST REMIX-", +}; + +export const TestingUSCChart: ChartDocument<"usc-keyboard"> = { + game: "usc-keyboard", chartID: "USC_CHART_ID", difficulty: "NOV", - songID: 1, - playtype: "Keyboard", + song: TestingUSCSong, levelNum: 1, level: "1", data: { @@ -997,7 +1077,7 @@ export const uscScore: USCClientScore = { }, }; -export const FakeOtherUser: MONGO_UserDocument = { +export const FakeOtherUser: UserDocument = { id: 2, username: "other_user", about: "", @@ -1012,9 +1092,8 @@ export const FakeOtherUser: MONGO_UserDocument = { usernameLowercase: "other_user", }; -export const FakeGameSettings: MONGO_UGPTSettingsDocument = { - game: "iidx", - playtype: "SP", +export const FakeGameSettings: UGPTSettingsDocument = { + game: "iidx-sp", preferences: { defaultTable: null, gameSpecific: { @@ -1032,17 +1111,16 @@ export const FakeGameSettings: MONGO_UGPTSettingsDocument = { userID: 1, }; -export const FakeImport: MONGO_ImportDocument = { +export const FakeImport: ImportDocument = { classDeltas: [], createdSessions: [], errors: [], - game: "iidx", + gameGroup: "iidx", goalInfo: [], - gptStrings: ["iidx:SP"], + games: ["iidx-sp"], importID: "fake_import", importType: "ir/direct-manual", questInfo: [], - playtypes: ["SP"], scoreIDs: [TestingIIDXSPScore.scoreID], timeFinished: 1000, timeStarted: 100, @@ -1050,7 +1128,7 @@ export const FakeImport: MONGO_ImportDocument = { userIntent: false, }; -export const FakeNotification: MONGO_NotificationDocument = { +export const FakeNotification: NotificationDocument = { title: "fake notif", notifID: "fake_notif", read: false, @@ -1060,8 +1138,7 @@ export const FakeNotification: MONGO_NotificationDocument = { type: "QUEST_CHANGED", content: { questID: "a", - game: "iidx", - playtype: "SP", + game: "iidx-sp", }, }, }; @@ -1344,14 +1421,14 @@ export const FakeChunitachiBatchManual = { ], }; -export const TestingArcaeaSheriruthSong: MONGO_SongDocument<"arcaea"> = { +export const TestingArcaeaSheriruthSong: SongDocument<"arcaea"> = { altTitles: [], artist: "Team Grimoire", data: { displayVersion: "1.0", songPack: "Eternal Core", }, - id: 19, + id: "s19", searchTerms: [ "しぇりだー", "しぇりるす", @@ -1365,7 +1442,8 @@ export const TestingArcaeaSheriruthSong: MONGO_SongDocument<"arcaea"> = { title: "Sheriruth", }; -export const TestingArcaeaSheriruthFTR: MONGO_ChartDocument<"arcaea:Touch"> = { +export const TestingArcaeaSheriruthFTR: ChartDocument<"arcaea"> = { + game: "arcaea", chartID: "48de13e0be17a282eed6ef97e6e1478eb59bc55e", data: { inGameStrID: "sheriruth", @@ -1375,12 +1453,24 @@ export const TestingArcaeaSheriruthFTR: MONGO_ChartDocument<"arcaea:Touch"> = { isPrimary: true, level: "10", levelNum: 10.1, - playtype: "Touch", - songID: 19, + song: TestingArcaeaSheriruthSong, versions: ["mobile"], }; -export const TestingOngekiChart: MONGO_ChartDocument<"ongeki:Single"> = { +export const TestingOngekiSong: SongDocument<"ongeki"> = { + altTitles: ["SENOTETOHETSUTEITSUTENNO"], + artist: "本城香澄(CV:岩橋由佳)「Re:ステージ!プリズムステップ」", + data: { + genre: "POPS&ANIME", + duration: 120, + }, + id: "s683", + searchTerms: [], + title: "せーので跳べって言ってんの!", +}; + +export const TestingOngekiChart: ChartDocument<"ongeki"> = { + game: "ongeki", chartID: "213796bdb6150f80ba6412ce69df1249e16c0cb0", data: { displayVersion: "オンゲキ bright MEMORY Act.3", @@ -1391,12 +1481,11 @@ export const TestingOngekiChart: MONGO_ChartDocument<"ongeki:Single"> = { isPrimary: true, level: "10", levelNum: 10.5, - playtype: "Single", - songID: 19, + song: TestingOngekiSong, versions: ["brightMemory3", "brightMemory3Omni"], }; -export const TestingOngekiScorePB: MONGO_PBScoreDocument<"ongeki:Single"> = { +export const TestingOngekiScorePB: PBScoreDocument<"ongeki"> = { chartID: "213796bdb6150f80ba6412ce69df1249e16c0cb0", userID: 1, calculatedData: { @@ -1427,13 +1516,13 @@ export const TestingOngekiScorePB: MONGO_PBScoreDocument<"ongeki:Single"> = { outOf: 1, rivalRank: null, }, - songID: 19, + songID: "s19", game: "ongeki", - playtype: "Single", timeAchieved: 10000, }; -export const TestingOngekiChartConverter: MONGO_ChartDocument<"ongeki:Single"> = { +export const TestingOngekiChartConverter: ChartDocument<"ongeki"> = { + game: "ongeki", chartID: "e5e4ee3d4feb233c399751b3ba3daf8ba149c9e6", data: { displayVersion: "オンゲキ bright", @@ -1444,24 +1533,25 @@ export const TestingOngekiChartConverter: MONGO_ChartDocument<"ongeki:Single"> = isPrimary: true, level: "13+", levelNum: 13.7, - playtype: "Single", - songID: 683, + song: TestingOngekiSong, versions: ["brightMemory3Omni", "brightMemory3"], }; -export const TestingOngekiSongConverter: MONGO_SongDocument<"ongeki"> = { - altTitles: ["SENOTETOHETSUTEITSUTENNO"], - artist: "本城香澄(CV:岩橋由佳)「Re:ステージ!プリズムステップ」", +export const TestingChunithmSongConverter: SongDocument<"chunithm"> = { + altTitles: [], + artist: "分島花音「selector infected WIXOSS」", data: { - genre: "POPS&ANIME", + displayVersion: "crystalplus", + genre: "POPS & ANIME", duration: 120, }, - id: 683, + id: "s956", searchTerms: [], - title: "せーので跳べって言ってんの!", + title: "killy killy JOKER", }; -export const TestingChunithmChartConverter: MONGO_ChartDocument<"chunithm:Single"> = { +export const TestingChunithmChartConverter: ChartDocument<"chunithm"> = { + game: "chunithm", chartID: "fbba26a6a223b1dd0bf8ef139cfee83114b91086", data: { inGameID: 956, @@ -1470,36 +1560,23 @@ export const TestingChunithmChartConverter: MONGO_ChartDocument<"chunithm:Single isPrimary: true, level: "12", levelNum: 12, - playtype: "Single", - songID: 956, + song: TestingChunithmSongConverter, versions: ["paradiselost", "sun", "sun-omni", "sunplus-omni", "luminous-omni"], }; -export const TestingChunithmSongConverter: MONGO_SongDocument<"chunithm"> = { - altTitles: [], - artist: "分島花音「selector infected WIXOSS」", - data: { - displayVersion: "crystalplus", - genre: "POPS & ANIME", - duration: 120, - }, - id: 956, - searchTerms: [], - title: "killy killy JOKER", -}; - -export const TestingMaimaiDXSongConverter: MONGO_SongDocument<"maimaidx"> = { +export const TestingMaimaiDXSongConverter: SongDocument<"maimaidx"> = { altTitles: [], artist: "suzu", data: { genre: "オンゲキ&CHUNITHM", }, - id: 844, + id: "s844", searchTerms: ["Shukusei", "Shukusei Shinpan", "Syukusei Shinpan"], title: "宿星審判", }; -export const TestingMaimaiDXChartConverter: MONGO_ChartDocument<"maimaidx:Single"> = { +export const TestingMaimaiDXChartConverter: ChartDocument<"maimaidx"> = { + game: "maimaidx", chartID: "fab3d632610b9b98ee1e4f68e9ecf0161f9cb8cd", data: { displayVersion: "maimaiでらっくす UNiVERSE", @@ -1509,7 +1586,6 @@ export const TestingMaimaiDXChartConverter: MONGO_ChartDocument<"maimaidx:Single isPrimary: true, level: "12", levelNum: 12.2, - playtype: "Single", - songID: 844, + song: TestingMaimaiDXSongConverter, versions: ["universeplus", "festival", "festivalplus", "buddies", "buddiesplus"], }; diff --git a/typescript/server/src/utils/chart-mongo-id.ts b/typescript/server/src/utils/chart-mongo-id.ts deleted file mode 100644 index c590bca9d..000000000 --- a/typescript/server/src/utils/chart-mongo-id.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { GetChartById } from "#lib/db-formats/chart"; -import { type GameGroup, GamePTToV3, MongoChartLegacyId, type Playtype } from "tachi-common"; - -/** - * Resolves a chart id from the API (Postgres `chart.id` or `legacy_id`) to the legacy - * string stored in Mongo (`personal-bests`, `folder-chart-lookup`, etc.). - */ -export async function ResolveLegacyChartIdForMongo( - game: GameGroup, - playtype: Playtype, - chartIdParam: string, -): Promise { - const fromPg = await GetChartById(GamePTToV3(game, playtype), chartIdParam); - - if (fromPg) { - return MongoChartLegacyId(fromPg); - } - - return null; -} diff --git a/typescript/server/src/utils/class.test.ts b/typescript/server/src/utils/class.test.ts index 442f2591c..8cf42c527 100644 --- a/typescript/server/src/utils/class.test.ts +++ b/typescript/server/src/utils/class.test.ts @@ -25,7 +25,7 @@ describe("UpdateClassIfGreater (Postgres)", () => { }) .execute(); - const result = await UpdateClassIfGreater(id, "iidx", "SP", "dan", "KYU_7"); + const result = await UpdateClassIfGreater(id, "iidx-sp", "dan", "KYU_7"); expect(result).toBe(false); const row = await DB.selectFrom("game_profile") @@ -49,7 +49,7 @@ describe("UpdateClassIfGreater (Postgres)", () => { }) .execute(); - const result = await UpdateClassIfGreater(id, "iidx", "SP", "dan", "DAN_1"); + const result = await UpdateClassIfGreater(id, "iidx-sp", "dan", "DAN_1"); expect(result).toBe(true); const row = await DB.selectFrom("game_profile") @@ -74,7 +74,7 @@ describe("UpdateClassIfGreater (Postgres)", () => { it("creates game_profile and game_settings when none exist (first class)", async () => { const { id } = await seedUser({ username: `cls_new_${Date.now()}` }); - const result = await UpdateClassIfGreater(id, "iidx", "SP", "dan", "DAN_1"); + const result = await UpdateClassIfGreater(id, "iidx-sp", "dan", "DAN_1"); expect(result).toBe(null); const profile = await DB.selectFrom("game_profile") diff --git a/typescript/server/src/utils/class.ts b/typescript/server/src/utils/class.ts index 00a835c7e..3ef9b5a90 100644 --- a/typescript/server/src/utils/class.ts +++ b/typescript/server/src/utils/class.ts @@ -4,14 +4,10 @@ import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; import DB from "#services/pg/db"; import { type Classes, - type GameGroup, - GamePTToV3, - GetGPTConfig, - GetGPTString, - type GPTString, + GetGameConfig, type integer, - type MONGO_UserGameStats, - type Playtype, + type UserGameStats, + type V3Game, } from "tachi-common"; function parseProfileJson(v: unknown): T { @@ -24,14 +20,12 @@ function parseProfileJson(v: unknown): T { export async function loadUserGameStats( userID: integer, - game: GameGroup, - playtype: Playtype, -): Promise { - const v3Game = GamePTToV3(game, playtype); + game: V3Game, +): Promise { const row = await DB.selectFrom("game_profile") .select(["ratings", "classes"]) .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst(); if (!row) { @@ -41,7 +35,6 @@ export async function loadUserGameStats( return { userID, game, - playtype, ratings: parseProfileJson(row.ratings), classes: parseProfileJson(row.classes), }; @@ -53,18 +46,18 @@ export async function loadUserGameStats( * to compare to, and FALSE if it is worse or equal. */ export function ReturnClassIfGreater( - gptString: GPTString, - classSet: Classes[GPTString], + game: V3Game, + classSet: Classes[V3Game], classVal: string, - userGameStats?: MONGO_UserGameStats | null, + userGameStats?: UserGameStats | null, ): boolean | null { - const gptConfig = GetGPTConfig(gptString); + const gameConfig = GetGameConfig(game); - const classInfo = gptConfig.classes[classSet]; + const classInfo = gameConfig.classes[classSet]; if (!classInfo) { log.warn( - `Invalid ReturnClassIfGreater call. Attempted to index set '${classSet}' on ${gptString}. No such class is defined for this game.`, + `Invalid ReturnClassIfGreater call. Attempted to index set '${classSet}' on ${game}. No such class is defined for this game.`, ); return null; @@ -80,8 +73,8 @@ export function ReturnClassIfGreater( return null; } - const previousClassIndex = ClassToIndex(gptString, classSet, prevClass); - const newClassIndex = ClassToIndex(gptString, classSet, classVal); + const previousClassIndex = ClassToIndex(game, classSet, prevClass); + const newClassIndex = ClassToIndex(game, classSet, classVal); if (previousClassIndex === null && newClassIndex === null) { return null; @@ -94,14 +87,14 @@ export function ReturnClassIfGreater( return newClassIndex > previousClassIndex; } -export function ClassToIndex(gptString: GPTString, classSet: Classes[GPTString], classVal: string) { - const gptConfig = GetGPTConfig(gptString); +export function ClassToIndex(game: V3Game, classSet: Classes[V3Game], classVal: string) { + const gameConfig = GetGameConfig(game); - const classInfo = gptConfig.classes[classSet]; + const classInfo = gameConfig.classes[classSet]; if (!classInfo) { log.warn( - `Invalid ClassToIndex call. Attempted to index set '${classSet}' on ${gptString}. No such class is defined for this game. Returning null.`, + `Invalid ClassToIndex call. Attempted to index set '${classSet}' on ${game}. No such class is defined for this game. Returning null.`, ); return null; } @@ -110,7 +103,7 @@ export function ClassToIndex(gptString: GPTString, classSet: Classes[GPTString], if (v === -1) { log.warn( - `Attempted to index a class that doesn't exist: ${classVal} on ${classSet} (${gptString}). Returning null.`, + `Attempted to index a class that doesn't exist: ${classVal} on ${classSet} (${game}). Returning null.`, ); return null; } @@ -128,16 +121,12 @@ export function ClassToIndex(gptString: GPTString, classSet: Classes[GPTString], */ export async function UpdateClassIfGreater( userID: integer, - game: GameGroup, - playtype: Playtype, - classSet: Classes[GPTString], + game: V3Game, + classSet: Classes[V3Game], classVal: string, ) { - const gptString = GetGPTString(game, playtype); - const v3Game = GamePTToV3(game, playtype); - - const userGameStats = await loadUserGameStats(userID, game, playtype); - const isGreater = ReturnClassIfGreater(gptString, classSet, classVal, userGameStats); + const userGameStats = await loadUserGameStats(userID, game); + const isGreater = ReturnClassIfGreater(game, classSet, classVal, userGameStats); if (isGreater === false) { return false; @@ -152,21 +141,21 @@ export async function UpdateClassIfGreater( await DB.updateTable("game_profile") .set({ classes: JSON.stringify(nextClasses) }) .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .execute(); } else { await DB.insertInto("game_profile") .values({ classes: JSON.stringify({ [classSet]: classVal }), - game: v3Game, + game, ratings: JSON.stringify({}), user_id: userID, }) .execute(); - log.info(`Created new player gamestats for ${userID} (${game} ${playtype})`); + log.info(`Created new player gamestats for ${userID} (${game})`); - await CreateGameSettings(userID, game, playtype); + await CreateGameSettings(userID, game); } const prevForAchievement = @@ -179,7 +168,7 @@ export async function UpdateClassIfGreater( class_prev_value: prevForAchievement, class_set: classSet, class_value: classVal, - game: v3Game, + game, timestamp: new Date().toISOString(), user_id: userID, }) @@ -188,7 +177,7 @@ export async function UpdateClassIfGreater( if (isGreater === null) { void EmitWebhookEvent({ type: "class-update/v1", - content: { userID, new: classVal, old: null, set: classSet, game, playtype }, + content: { userID, new: classVal, old: null, set: classSet, game }, }); return null; @@ -202,7 +191,6 @@ export async function UpdateClassIfGreater( old: userGameStats!.classes[classSet]!, set: classSet, game, - playtype, }, }); diff --git a/typescript/server/src/utils/db.ts b/typescript/server/src/utils/db.ts index 9c9cc9964..67fde2707 100644 --- a/typescript/server/src/utils/db.ts +++ b/typescript/server/src/utils/db.ts @@ -2,11 +2,12 @@ import type { FilterQuery } from "mongodb"; import type { Game } from "tachi-db"; import { GetChartsByIds, SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; -import { LoadFolderDocumentById } from "#lib/db-formats/folders.js"; +import { LoadFolderDocumentById } from "#lib/db-formats/folders"; import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; -import { GetSongsByLegacyIDs } from "#lib/db-formats/song"; +import { GetSongsByIDs as GetSongsByIDs } from "#lib/db-formats/song"; import { + AttachFolderSlugsToGoals, ToGoalDocument, ToGoalSubscriptionDocument, ToQuestDocument, @@ -19,16 +20,16 @@ import { type ExpressionBuilder, sql } from "kysely"; import { FormatChart, type GameGroup, - GamePTToV3, + type GoalDocument, + type GoalSubscriptionDocument, type integer, - type MONGO_GoalDocument, - type MONGO_GoalSubscriptionDocument, - type MONGO_PBScoreDocument, - type MONGO_QuestDocument, - type MONGO_QuestlineDocument, - type MONGO_QuestSubscriptionDocument, - type MONGO_ScoreDocument, - type Playtype, + LEGACY_GameGroupPTToGame, + type LEGACY_Playtype, + type PBScoreDocument, + type QuestDocument, + type QuestlineDocument, + type QuestSubscriptionDocument, + type ScoreDocument, } from "tachi-common"; type MsTimeRange = @@ -147,8 +148,9 @@ function whereUserIdOnQuestSub(userID: unknown) { /** * Next numeric `song.legacy_id` for BMS/PMS — `max(existing) + 1`, or `1` if none. - * Replaces Mongo `counters` `*-song-id` documents. Concurrent unorphans can race; - * a future pass can add locking or retire legacy ids entirely. + * Replaces Mongo `counters` `*-song-id` documents. + * + * This shit sucks and should be dropped asap: TODO(zk) */ export async function GetNextBmsPmsSongLegacyId(game: "bms" | "pms"): Promise { const row = await DB.selectFrom("song") @@ -159,43 +161,36 @@ export async function GetNextBmsPmsSongLegacyId(game: "bms" | "pms"): Promise, - game: GameGroup, -) { +export async function GetRelevantSongsAndCharts(scores: Array) { const songIDs = [...new Set(scores.map((e) => e.songID))]; - const chartKeys = [...new Set(scores.map((e) => e.chartID))]; + const chartIDs = [...new Set(scores.map((e) => e.chartID))]; - const [songs, charts] = await Promise.all([ - GetSongsByLegacyIDs(game, songIDs), - GetChartsByIds(game, chartKeys), - ]); + const [songs, charts] = await Promise.all([GetSongsByIDs(songIDs), GetChartsByIds(chartIDs)]); return { songs, charts }; } -export async function GetChartForIDGuaranteed(game: GameGroup, chartID: string) { +export async function GetChartForIDGuaranteed(chartID: string) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", game) .where("chart.id", "=", chartID) .executeTakeFirst(); if (!row) { - throw new Error(`Couldn't find chart with ID ${chartID} (${game}).`); + throw new Error(`Couldn't find chart with ID ${chartID}.`); } return ToChartDocument(row); } -export async function GetSongForIDGuaranteed(game: GameGroup, songID: integer) { - const res = await GetSongsByLegacyIDs(game, [songID]); +export async function GetSongForIDGuaranteed(songID: string) { + const res = await GetSongsByIDs([songID]); const song = res[0]; if (!song) { - throw new Error(`Couldn't find song with ID ${songID} (${game}).`); + throw new Error(`Couldn't find song with ID ${songID}.`); } return song; @@ -225,7 +220,10 @@ export async function GetGoalForIDGuaranteed(goalID: string) { throw new Error(`Couldn't find goal with ID ${goalID}`); } - return ToGoalDocument(row); + const goal = ToGoalDocument(row); + await AttachFolderSlugsToGoals([goal]); + + return goal; } export async function GetGoalSubscriptionForIDGuaranteed(goalID: string, userID: integer) { @@ -258,11 +256,10 @@ export async function GetQuestForIDGuaranteed(questID: string) { return ToQuestDocument(row); } -export async function HumaniseChartID(game: GameGroup, chartID: string) { - const chart = await GetChartForIDGuaranteed(game, chartID); - const song = await GetSongForIDGuaranteed(game, chart.songID); +export async function HumaniseChartID(chartID: string) { + const chart = await GetChartForIDGuaranteed(chartID); - return FormatChart(game, song, chart); + return FormatChart(chart); } /** @@ -273,7 +270,7 @@ export async function HumaniseChartID(game: GameGroup, chartID: string) { * @returns - The goals and their subs. */ export async function GetRecentlyAchievedGoals( - baseQuery: Omit, "achieved">, + baseQuery: Omit, "achieved">, limit = 100, ) { const b = baseQuery as Record; @@ -291,7 +288,10 @@ export async function GetRecentlyAchievedGoals( } if (b.game !== undefined && b.playtype !== undefined) { - const v3 = GamePTToV3(b.game as GameGroup, b.playtype as Playtype) as Game; + const v3 = LEGACY_GameGroupPTToGame( + b.game as GameGroup, + b.playtype as LEGACY_Playtype, + ) as Game; q = q.where("goal.game", "=", v3); } @@ -335,12 +335,13 @@ export async function GetRecentlyAchievedGoals( .execute(); const goals = goalRows.map(ToGoalDocument); + await AttachFolderSlugsToGoals(goals); return { goals, goalSubs }; } export async function GetRecentlyInteractedGoals( - baseQuery: Omit, "achieved">, + baseQuery: Omit, "achieved">, limit = 100, ) { const b = baseQuery as Record; @@ -359,7 +360,10 @@ export async function GetRecentlyInteractedGoals( } if (b.game !== undefined && b.playtype !== undefined) { - const v3 = GamePTToV3(b.game as GameGroup, b.playtype as Playtype) as Game; + const v3 = LEGACY_GameGroupPTToGame( + b.game as GameGroup, + b.playtype as LEGACY_Playtype, + ) as Game; q = q.where("goal.game", "=", v3); } @@ -403,12 +407,13 @@ export async function GetRecentlyInteractedGoals( .execute(); const goals = goalRows.map(ToGoalDocument); + await AttachFolderSlugsToGoals(goals); return { goals, goalSubs }; } export async function GetRecentlyAchievedQuests( - baseQuery: Omit, "achieved">, + baseQuery: Omit, "achieved">, limit = 100, ) { const b = baseQuery as Record; @@ -426,7 +431,10 @@ export async function GetRecentlyAchievedQuests( } if (b.game !== undefined && b.playtype !== undefined) { - const v3 = GamePTToV3(b.game as GameGroup, b.playtype as Playtype) as Game; + const v3 = LEGACY_GameGroupPTToGame( + b.game as GameGroup, + b.playtype as LEGACY_Playtype, + ) as Game; q = q.where("quest.game", "=", v3); } @@ -475,7 +483,7 @@ export async function GetRecentlyAchievedQuests( } export async function GetRecentlyInteractedQuests( - baseQuery: Omit, "achieved">, + baseQuery: Omit, "achieved">, limit = 100, ) { const b = baseQuery as Record; @@ -494,7 +502,10 @@ export async function GetRecentlyInteractedQuests( } if (b.game !== undefined && b.playtype !== undefined) { - const v3 = GamePTToV3(b.game as GameGroup, b.playtype as Playtype) as Game; + const v3 = LEGACY_GameGroupPTToGame( + b.game as GameGroup, + b.playtype as LEGACY_Playtype, + ) as Game; q = q.where("quest.game", "=", v3); } @@ -570,9 +581,9 @@ function whereUserIdOnGoalSubForAggregate(userID: unknown) { } export async function GetMostSubscribedGoals( - query: FilterQuery, + query: FilterQuery, limit = 100, -): Promise> { +): Promise> { const b = query as Record; let q = DB.selectFrom("goal_sub") @@ -588,7 +599,10 @@ export async function GetMostSubscribedGoals( } if (b.game !== undefined && b.playtype !== undefined) { - const v3 = GamePTToV3(b.game as GameGroup, b.playtype as Playtype) as Game; + const v3 = LEGACY_GameGroupPTToGame( + b.game as GameGroup, + b.playtype as LEGACY_Playtype, + ) as Game; q = q.where("goal.game", "=", v3); } @@ -621,13 +635,13 @@ export async function GetMostSubscribedGoals( ...ToGoalDocument(g), }; }) - .filter((e): e is { __subscriptions: integer } & MONGO_GoalDocument => e !== null); + .filter((e): e is { __subscriptions: integer } & GoalDocument => e !== null); } export async function GetMostSubscribedQuests( - query: FilterQuery, + query: FilterQuery, limit = 100, -): Promise> { +): Promise> { const b = query as Record; let q = DB.selectFrom("quest") @@ -637,7 +651,10 @@ export async function GetMostSubscribedQuests( .groupBy("quest.id"); if (b.game !== undefined && b.playtype !== undefined) { - const v3 = GamePTToV3(b.game as GameGroup, b.playtype as Playtype) as Game; + const v3 = LEGACY_GameGroupPTToGame( + b.game as GameGroup, + b.playtype as LEGACY_Playtype, + ) as Game; q = q.where("quest.game", "=", v3); } @@ -670,10 +687,10 @@ export async function GetMostSubscribedQuests( ...ToQuestDocument(x), }; }) - .filter((e): e is { __subscriptions: integer } & MONGO_QuestDocument => e !== null); + .filter((e): e is { __subscriptions: integer } & QuestDocument => e !== null); } -export async function GetChildQuests(questline: MONGO_QuestlineDocument) { +export async function GetChildQuests(questline: QuestlineDocument) { if (questline.quests.length === 0) { return []; } diff --git a/typescript/server/src/utils/folder.ts b/typescript/server/src/utils/folder.ts index 7bc4acc27..e36dc19ab 100644 --- a/typescript/server/src/utils/folder.ts +++ b/typescript/server/src/utils/folder.ts @@ -13,4 +13,4 @@ export { GetPBsOnFolder, GetRecentlyViewedFolders, GetTableForIDGuaranteed, -} from "#lib/folders/folders.js"; +} from "#lib/folders/folders"; diff --git a/typescript/server/src/utils/misc.ts b/typescript/server/src/utils/misc.ts index c753b255a..544bba967 100644 --- a/typescript/server/src/utils/misc.ts +++ b/typescript/server/src/utils/misc.ts @@ -2,17 +2,17 @@ import type { KtLogger } from "#lib/log/log"; import { ONE_MEGABYTE } from "#lib/constants/filesize"; import { ONE_HOUR, ONE_SECOND } from "#lib/constants/time"; -import { TachiConfig } from "#lib/setup/config"; +import { AllEnabledGames as AllEnabledGames, TachiConfig } from "#lib/setup/config"; import { exec } from "child_process"; import crypto from "crypto"; import { + type GameConfig, type GameGroup, - type GamePTConfig, + GetGameConfig, GetGameGroupConfig, - GetGamePTConfig, - type GPTString, type integer, - type Playtype, + type LEGACY_Playtype, + type V3Game, type Versions, } from "tachi-common"; import { URL } from "url"; @@ -95,20 +95,24 @@ export function HasOwnProperty(obj: T, key: number | string | symbol): key is return Object.prototype.hasOwnProperty.call(obj, key); } -export function IsValidGame(str: string): str is GameGroup { - return !!TachiConfig.GAMES.includes(str as GameGroup); +export function IsEnabledGame(str: string): str is V3Game { + return AllEnabledGames().includes(str as V3Game); } -export function IsValidPlaytype(game: GameGroup, str: string): str is Playtype { - return GetGameGroupConfig(game).playtypes.includes(str as Playtype); +export function IsEnabledGameGroup(str: string): str is GameGroup { + return !!TachiConfig.GAME_GROUPS.includes(str as GameGroup); +} + +export function IsValidPlaytype(gameGroup: GameGroup, str: string): str is LEGACY_Playtype { + return GetGameGroupConfig(gameGroup).playtypes.includes(str as LEGACY_Playtype); } export function IsValidScoreAlg( - gptConfig: GamePTConfig, + gameConfig: GameConfig, str: unknown, -): str is GamePTConfig["scoreRatingAlgs"][0] { +): str is GameConfig["scoreRatingAlgs"][0] { // @ts-expect-error i hate this feature - return Object.keys(gptConfig.scoreRatingAlgs).includes(str); + return Object.keys(gameConfig.scoreRatingAlgs).includes(str); } export function IsString(val: unknown): val is string { @@ -291,7 +295,7 @@ export function OnlyFloatToDP(num: number, points = 2) { * Returns whether this game is supported by this instance of tachi or not. */ export function IsSupported(game: GameGroup) { - return TachiConfig.GAMES.includes(game); + return TachiConfig.GAME_GROUPS.includes(game); } /** @@ -367,14 +371,10 @@ export function ClassToObject(cls: unknown) { return JSON.parse(JSON.stringify(cls)) as unknown; } -export function StringIsGameVersion( - game: GameGroup, - playtype: Playtype, - version: string, -): version is Versions[GPTString] { - const gptConfig = GetGamePTConfig(game, playtype); +export function StringIsGameVersion(game: V3Game, version: string): version is Versions[V3Game] { + const gameConfig = GetGameConfig(game); - return Object.keys(gptConfig.versions).includes(version); + return Object.keys(gameConfig.versions).includes(version); } /** diff --git a/typescript/server/src/utils/naive-csv-parser.test.ts b/typescript/server/src/utils/naive-csv-parser.test.ts index 70553e9b2..13ed40d84 100644 --- a/typescript/server/src/utils/naive-csv-parser.test.ts +++ b/typescript/server/src/utils/naive-csv-parser.test.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { TestingIIDXEamusementCSV27 } from "#test-utils/test-data"; import { describe, expect, it } from "vitest"; diff --git a/typescript/server/src/utils/naive-csv-parser.ts b/typescript/server/src/utils/naive-csv-parser.ts index b6af73ecd..66c28824e 100644 --- a/typescript/server/src/utils/naive-csv-parser.ts +++ b/typescript/server/src/utils/naive-csv-parser.ts @@ -1,4 +1,4 @@ -import { type KtLogger, log } from "#lib/log/log"; +import { type KtLogger } from "#lib/log/log"; import { IsNonEmptyString } from "./misc"; diff --git a/typescript/server/src/utils/queries/api-clients.ts b/typescript/server/src/utils/queries/api-clients.ts index 33c86671b..0cc37e920 100644 --- a/typescript/server/src/utils/queries/api-clients.ts +++ b/typescript/server/src/utils/queries/api-clients.ts @@ -1,8 +1,8 @@ import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client"; import DB from "#services/pg/db"; -import { type MONGO_TachiAPIClientDocument } from "tachi-common"; +import { type TachiAPIClientDocument } from "tachi-common"; -export function GetClientByID(clientID: string): Promise { +export function GetClientByID(clientID: string): Promise { return DB.selectFrom("priv_api_client") .select(SELECT_API_CLIENT) .where("client_id", "=", clientID) diff --git a/typescript/server/src/utils/queries/auth.ts b/typescript/server/src/utils/queries/auth.ts index f5d63bf1c..a2922c2fc 100644 --- a/typescript/server/src/utils/queries/auth.ts +++ b/typescript/server/src/utils/queries/auth.ts @@ -1,5 +1,5 @@ import type { KtLogger } from "#lib/log/log"; -import type { integer, MONGO_KaiAuthDocument } from "tachi-common"; +import type { integer, KaiAuthDocument } from "tachi-common"; import { SELECT_KAI_AUTH_TOKEN, ToKaiAuthDocument } from "#lib/db-formats/kai-auth-token"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; @@ -8,7 +8,7 @@ import DB from "#services/pg/db"; export async function GetKaiAuth( userID: integer, service: "EAG" | "FLO" | "MIN", -): Promise { +): Promise { const row = await DB.selectFrom("priv_svc_kai_auth_token") .select(SELECT_KAI_AUTH_TOKEN) .where("user_id", "=", userID) diff --git a/typescript/server/src/utils/queries/charts.test.ts b/typescript/server/src/utils/queries/charts.test.ts index b72da5a7a..1af006541 100644 --- a/typescript/server/src/utils/queries/charts.test.ts +++ b/typescript/server/src/utils/queries/charts.test.ts @@ -38,13 +38,13 @@ describe("FindChartWithChartID (Postgres)", () => { }) .execute(); - const doc = await FindChartWithChartID("ddr", chartId); + const doc = await FindChartWithChartID(chartId); expect(doc).not.toBeNull(); expect(doc?.chartID).toBe(chartId); - expect(doc?.songID).toBe(5_100_000); + expect(doc?.song.id).toBe(songId); }); it("returns null when missing", async () => { - expect(await FindChartWithChartID("popn", "no-such-chart")).toBeNull(); + expect(await FindChartWithChartID("no-such-chart")).toBeNull(); }); }); diff --git a/typescript/server/src/utils/queries/charts.ts b/typescript/server/src/utils/queries/charts.ts index 0aabbf78b..2e8f37b27 100644 --- a/typescript/server/src/utils/queries/charts.ts +++ b/typescript/server/src/utils/queries/charts.ts @@ -5,26 +5,22 @@ import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song"; import DB from "#services/pg/db"; import { sql, type SqlBool } from "kysely"; import { + type ChartDocument, type Difficulties, type GameGroup, - GamePTToV3, - type GPTString, + type GamesForGroup, type integer, - type MONGO_ChartDocument, - type MONGO_ChartDocumentData, - type MONGO_SongDocument, - type Playtype, - type Playtypes, - V3ToGamePT, + LEGACY_GameGroupPTToGame, + type LEGACY_Playtype, + type SongDocument, + type V3Game, type Versions, } from "tachi-common"; -export async function FindChartWithChartID(game: GameGroup, chartID: string) { +export async function FindChartWithChartID(chartID: string) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - - .where("song.game_group", "=", game) .where("chart.id", "=", chartID) .executeTakeFirst(); @@ -38,22 +34,18 @@ export async function FindChartWithChartID(game: GameGroup, chartID: string) { /** * Find chart with PlaytypeDifficulty. This only finds charts that have `isPrimary` set to true. * If you want to find charts that are not primary, you need to use PTDFVersion. - * @see FindChartWithPTDFVersion + * @see FindChartWithSongDifficultyVersion */ -export async function FindChartWithPTDF< - G extends GameGroup = GameGroup, - P extends Playtypes[G] = Playtypes[G], - GPT extends GPTString = GPTString, ->(game: G, songID: integer, playtype: P, difficulty: Difficulties[GPT]) { - const v3Game = GamePTToV3(game, playtype) as Game; - +export async function FindChartWithSongDifficulty( + game: TGame, + songID: string, + difficulty: Difficulties[TGame], +) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - - .where("song.game_group", "=", game) - .where("song.legacy_id", "=", songID) - .where("chart.game", "=", v3Game) + .where("song.id", "=", songID) + .where("chart.game", "=", game) .where("chart.difficulty", "=", difficulty as string) .where("chart.is_primary", "=", true) .executeTakeFirst(); @@ -69,20 +61,17 @@ export async function FindChartWithPTDF< * Find chart with Playtype, Difficulty and a given version. This does not necessarily return a chart that has * `isPrimary` set. */ -export async function FindChartWithPTDFVersion< - G extends GameGroup = GameGroup, - P extends Playtypes[G] = Playtypes[G], - GPT extends GPTString = GPTString, ->(game: G, songID: integer, playtype: P, difficulty: Difficulties[GPT], version: Versions[GPT]) { - const v3Game = GamePTToV3(game, playtype) as Game; - +export async function FindChartWithSongDifficultyVersion( + game: TGame, + songID: string, + difficulty: Difficulties[TGame], + version: Versions[TGame], +) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - - .where("song.game_group", "=", game) - .where("song.legacy_id", "=", songID) - .where("chart.game", "=", v3Game) + .where("song.id", "=", songID) + .where("chart.game", "=", game) .where("chart.difficulty", "=", difficulty as string) .where(sql`${sql.lit(String(version))} = ANY(chart.versions)`) .executeTakeFirst(); @@ -98,7 +87,6 @@ export async function FindITGChartOnHash(hash: string) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("chart.game", "=", "itg-stamina" as Game) .where(sql`(chart.data::jsonb->>'hashGSv3') = ${hash}`) .executeTakeFirst(); @@ -118,7 +106,6 @@ export async function FindBMSChartOnHash(hash: string) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", "bms") .where((eb) => eb.or([ @@ -132,19 +119,17 @@ export async function FindBMSChartOnHash(hash: string) { return null; } - return ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">; + return ToChartDocument(row) as ChartDocument; } /** * BMS charts for a playtype whose chart `data` has sieglinde EC or HC > 0 (GPT sieglinde-charts), * with joined song rows (`songs[i]` matches `charts[i]`). */ -export async function FindBMSSieglindeRatedCharts(playtype: Playtypes["bms"]): Promise<{ - charts: Array>; - songs: Array>; +export async function FindBMSSieglindeRatedCharts(game: "bms-7k" | "bms-14k"): Promise<{ + charts: Array>; + songs: Array>; }> { - const v3Game = GamePTToV3("bms", playtype) as Game; - const sglEcPositive = sql`(chart.data::jsonb->>'sglEC') IS NOT NULL AND (chart.data::jsonb->>'sglEC') <> '' AND (chart.data::jsonb->>'sglEC')::numeric > 0`; const sglHcPositive = sql`(chart.data::jsonb->>'sglHC') IS NOT NULL AND (chart.data::jsonb->>'sglHC') <> '' AND (chart.data::jsonb->>'sglHC')::numeric > 0`; @@ -152,17 +137,17 @@ export async function FindBMSSieglindeRatedCharts(playtype: Playtypes["bms"]): P .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .select(SELECT_SONG_DOCUMENT) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where((eb) => eb.or([sglEcPositive, sglHcPositive])) .orderBy("chart.id") .execute(); - const charts: Array> = []; - const songs: Array> = []; + const charts: Array> = []; + const songs: Array> = []; for (const row of rows) { - charts.push(ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">); - songs.push(ToSongDocument(row) as MONGO_SongDocument<"bms">); + charts.push(ToChartDocument(row) as ChartDocument); + songs.push(ToSongDocument(row) as SongDocument<"bms">); } return { charts, songs }; @@ -173,7 +158,6 @@ export async function FindBMSChartOnHashInGame(hash: string, v3Game: Game) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("chart.game", "=", v3Game) .where((eb) => eb.or([ @@ -187,17 +171,14 @@ export async function FindBMSChartOnHashInGame(hash: string, v3Game: Game) { return null; } - return ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">; + return ToChartDocument(row) as ChartDocument; } /** All BMS charts matching MD5 or SHA256 in chart data. Used by global chart-hash search. */ -export async function FindBMSChartsByHashMd5OrSha256( - hash: string, -): Promise> { +export async function FindBMSChartsByHashMd5OrSha256(hash: string): Promise> { const rows = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", "bms") .where((eb) => eb.or([ @@ -211,13 +192,10 @@ export async function FindBMSChartsByHashMd5OrSha256( } /** All PMS charts matching MD5 or SHA256 in chart data. Used by global chart-hash search. */ -export async function FindPMSChartsByHashMd5OrSha256( - hash: string, -): Promise> { +export async function FindPMSChartsByHashMd5OrSha256(hash: string): Promise> { const rows = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", "pms") .where((eb) => eb.or([ @@ -231,11 +209,10 @@ export async function FindPMSChartsByHashMd5OrSha256( } /** All ITG Stamina charts matching hashGSv3. Used by global chart-hash search. */ -export async function FindITGChartsByHashGSv3(hash: string): Promise> { +export async function FindITGChartsByHashGSv3(hash: string): Promise> { const rows = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("chart.game", "=", "itg-stamina" as Game) .where(sql`(chart.data::jsonb->>'hashGSv3') = ${hash}`) .execute(); @@ -248,7 +225,7 @@ export async function FindITGChartsByHashGSv3(hash: string): Promise | null> { +): Promise | null> { const bmsRow = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) @@ -257,7 +234,7 @@ export async function FindBeatorajaChartOnHashSHA256( .executeTakeFirst(); if (bmsRow) { - return ToChartDocument(bmsRow) as MONGO_ChartDocument<"bms:7K" | "bms:14K">; + return ToChartDocument(bmsRow) as ChartDocument; } const pmsRow = await DB.selectFrom("chart") @@ -271,20 +248,17 @@ export async function FindBeatorajaChartOnHashSHA256( return null; } - return ToChartDocument(pmsRow) as MONGO_ChartDocument<"pms:Controller" | "pms:Keyboard">; + return ToChartDocument(pmsRow) as ChartDocument; } /** * Find a Pop'n chart by SHA256 hash in chart data (batch-manual `popnChartHash`). */ -export async function FindPopnChartOnHashSHA256(hash: string, playtype: Playtypes["popn"]) { - const v3Game = GamePTToV3("popn", playtype) as Game; - +export async function FindPopnChartOnHashSHA256(hash: string) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", "popn") - .where("chart.game", "=", v3Game) + .where("chart.game", "=", "popn") .where(sql`(chart.data::jsonb->>'hashSHA256') = ${hash}`) .executeTakeFirst(); @@ -296,14 +270,14 @@ export async function FindPopnChartOnHashSHA256(hash: string, playtype: Playtype } /** - * Returns true if at least one chart exists for this song (legacy Tachi song id). + * Returns true if at least one chart exists for this song. */ -export async function SongHasAnyChart(game: GameGroup, songLegacyId: number): Promise { +export async function SongHasAnyChart(game: GameGroup, songID: string): Promise { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select("chart.id") .where("song.game_group", "=", game) - .where("song.legacy_id", "=", songLegacyId) + .where("song.id", "=", songID) .executeTakeFirst(); return row !== undefined; @@ -313,22 +287,14 @@ export async function SongHasAnyChart(game: GameGroup, songLegacyId: number): Pr * Find a chart on its in-game-ID, playtype and difficulty. */ export async function FindChartOnInGameID( - game: GameGroup, + game: V3Game, inGameID: number, - playtype: Playtype, - difficulty: Difficulties[GPTString], + difficulty: Difficulties[V3Game], ) { - if (game === "bms" || game === "usc") { - throw new Error(`Cannot call FindChartOnInGameID for game ${game}.`); - } - - const v3Game = GamePTToV3(game, playtype) as Game; - const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", game) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where("chart.difficulty", "=", difficulty as string) .executeTakeFirst(); @@ -344,22 +310,14 @@ export async function FindChartOnInGameID( * Like {@link FindChartOnInGameID}, but only matches charts with `isPrimary` set (batch-manual / legacy Mongo parity). */ export async function FindChartOnInGameIDPrimary( - game: GameGroup, + game: V3Game, inGameID: number, - playtype: Playtype, - difficulty: Difficulties[GPTString], + difficulty: Difficulties[V3Game], ) { - if (game === "bms" || game === "usc") { - throw new Error(`Cannot call FindChartOnInGameIDPrimary for game ${game}.`); - } - - const v3Game = GamePTToV3(game, playtype) as Game; - const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", game) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where("chart.difficulty", "=", difficulty as string) .where("chart.is_primary", "=", true) @@ -378,15 +336,12 @@ export async function FindChartOnInGameIDPrimary( */ export async function FindIIDXChartOnInGameID( inGameID: number, - playtype: Playtypes["iidx"], - difficulty: Difficulties["iidx:DP" | "iidx:SP"], + difficulty: Difficulties[GamesForGroup["iidx"]], ) { - const v3Game = GamePTToV3("iidx", playtype) as Game; - const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", "iidx-sp") .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where(sql`(chart.data->>'2dxtraSet') IS NULL`) .where("chart.is_primary", "=", true) @@ -405,17 +360,15 @@ export async function FindIIDXChartOnInGameID( * This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate. */ export async function FindIIDXChartOnInGameIDVersion( + game: "iidx-dp" | "iidx-sp", inGameID: number, - playtype: Playtypes["iidx"], - difficulty: Difficulties["iidx:DP" | "iidx:SP"], - version: Versions["iidx:DP" | "iidx:SP"], + difficulty: Difficulties[GamesForGroup["iidx"]], + version: Versions[GamesForGroup["iidx"]], ) { - const v3Game = GamePTToV3("iidx", playtype) as Game; - const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where(sql`(chart.data->>'2dxtraSet') IS NULL`) .where("chart.difficulty", "=", difficulty as string) @@ -432,21 +385,16 @@ export async function FindIIDXChartOnInGameIDVersion( /** * Find a chart on its in-game-ID, playtype, difficulty and version. */ -export async function FindChartOnInGameIDVersion( - game: GameGroup, +export async function FindChartOnInGameIDVersion( + game: V3Game, inGameID: number, - playtype: Playtype, - difficulty: Difficulties[GPT], - version: Versions[GPT], + difficulty: Difficulties[TGame], + version: Versions[TGame], ) { - const v3Game = GamePTToV3(game, playtype) as Game; - const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - - .where("song.game_group", "=", game) - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where("chart.difficulty", "=", difficulty as string) .where(sql`${sql.lit(String(version))} = ANY(chart.versions)`) @@ -463,23 +411,14 @@ export async function FindChartOnInGameIDVersion`(chart.data::jsonb->>'hashSHA256') = ${hash}`) .executeTakeFirst(); @@ -555,13 +484,12 @@ const SDVX_INF_DIFFS = ["INF", "GRV", "HVN", "VVD", "XCD"] as const; */ export async function FindSDVXChartOnInGameID( inGameID: number, - difficulty: "ANY_INF" | Difficulties["sdvx:Single"], + difficulty: "ANY_INF" | Difficulties[GamesForGroup["sdvx"]], ) { let q = DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - - .where("chart.game", "=", "sdvx" as Game) + .where("chart.game", "=", "sdvx") .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where("chart.is_primary", "=", true); @@ -581,14 +509,13 @@ export async function FindSDVXChartOnInGameID( export async function FindSDVXChartOnInGameIDVersion( inGameID: number, - difficulty: "ANY_INF" | Difficulties["sdvx:Single"], - version: Versions["sdvx:Single"], + difficulty: "ANY_INF" | Difficulties[GamesForGroup["sdvx"]], + version: Versions[GamesForGroup["sdvx"]], ) { let q = DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - - .where("chart.game", "=", "sdvx" as Game) + .where("chart.game", "=", "sdvx") .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where(sql`${sql.lit(String(version))} = ANY(chart.versions)`); @@ -607,17 +534,16 @@ export async function FindSDVXChartOnInGameIDVersion( } export async function FindSDVXChartOnDFVersion( - songID: integer, - difficulty: "ANY_INF" | Difficulties["sdvx:Single"], - version: Versions["sdvx:Single"], + songID: string, + difficulty: "ANY_INF" | Difficulties[GamesForGroup["sdvx"]], + version: Versions[GamesForGroup["sdvx"]], ) { let q = DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", "sdvx") - .where("song.legacy_id", "=", songID) - .where("chart.game", "=", "sdvx" as Game) + .where("song.id", "=", songID) + .where("chart.game", "=", "sdvx") .where(sql`${sql.lit(String(version))} = ANY(chart.versions)`); q = @@ -642,7 +568,6 @@ export async function FindChartOnSHA256(game: GameGroup, hash: string) { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", game) .where(sql`(chart.data::jsonb->>'hashSHA256') = ${hash}`) .executeTakeFirst(); @@ -654,17 +579,20 @@ export async function FindChartOnSHA256(game: GameGroup, hash: string) { return ToChartDocument(row); } -export async function FindChartOnSHA256Playtype(game: GameGroup, hash: string, playtype: Playtype) { +export async function FindChartOnSHA256Playtype( + game: GameGroup, + hash: string, + playtype: LEGACY_Playtype, +) { if (game !== "bms" && game !== "usc" && game !== "iidx" && game !== "pms") { throw new Error(`Cannot call FindChartOnSHA256 for game ${game}.`); } - const v3Game = GamePTToV3(game, playtype) as Game; + const v3Game = LEGACY_GameGroupPTToGame(game, playtype) as Game; const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - .where("song.game_group", "=", game) .where("chart.game", "=", v3Game) .where(sql`(chart.data::jsonb->>'hashSHA256') = ${hash}`) @@ -681,15 +609,11 @@ export async function FindChartOnSHA256Playtype(game: GameGroup, hash: string, p * Find a USC chart on its SHA1 hash (from chart data) and playtype. * Used by the USC IR and batch-manual uscChartHash matching. */ -export async function FindUSCChartOnSHA1Playtype(hash: string, playtype: Playtypes["usc"]) { - const v3Game = GamePTToV3("usc", playtype) as Game; - +export async function FindUSCChartOnSHA1(hash: string, game: "usc-controller" | "usc-keyboard") { const row = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - - .where("song.game_group", "=", "usc") - .where("chart.game", "=", v3Game) + .where("chart.game", "=", game) .where(sql`(chart.data::jsonb->>'hashSHA1') = ${hash}`) .executeTakeFirst(); @@ -701,12 +625,11 @@ export async function FindUSCChartOnSHA1Playtype(hash: string, playtype: Playtyp } /** All USC charts matching a SHA1 hash (any playtype). Used by global chart-hash search. */ -export async function FindUSCChartsByHashSHA1(hash: string): Promise> { +export async function FindUSCChartsByHashSHA1(hash: string): Promise> { const rows = await DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) - - .where("song.game_group", "=", "usc") + .where("song.game_group", "=", "usc") // OPTIMISATION .where(sql`(chart.data::jsonb->>'hashSHA1') = ${hash}`) .execute(); @@ -720,41 +643,41 @@ export async function FindUSCChartsByHashSHA1(hash: string): Promise, + game: V3Game, + filters?: { chartIDs: Array | undefined; songIDs: Array | undefined }, skip = 0, limit = 100, - _scoreCollection: "personal-bests" | "scores" = "personal-bests", -): Promise> { - const v3Game = GamePTToV3(game, playtype); - +): Promise> { let q = DB.selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") .leftJoin("score", "score.chart_id", "chart.id") - .where("chart.game", "=", v3Game as Game) + .where("chart.game", "=", game) .where(sql`(chart.data->>'2dxtraSet') IS NULL`); - if (songIDs && songIDs.length > 0) { - q = q.where("song.legacy_id", "in", songIDs); + if (filters?.chartIDs) { + // empty array - should be no matches, just short circuit + if (filters.chartIDs.length === 0) { + return []; + } else { + q = q.where("chart.id", "in", filters.chartIDs); + } + } + + if (filters?.songIDs) { + // empty array - should be no matches, just short circuit + if (filters.songIDs.length === 0) { + return []; + } else { + q = q.where("song.id", "in", filters.songIDs); + } } const rows = await q .select([ - "chart.id", - "chart.legacy_id", - "chart.game", - "chart.song_id", - "chart.level", - "chart.level_num", - "chart.is_primary", - "chart.difficulty", - "chart.versions", - "chart.data", - "song.legacy_id as song_legacy_id", + ...SELECT_CHART, // format-bearing comment sql`count(score.id)::int`.as("playcount"), ]) - .groupBy(["chart.id", "song.legacy_id"]) + .groupBy(["chart.id", "song.id"]) .orderBy(sql`count(score.id)`, "desc") .offset(skip) .limit(limit) @@ -765,20 +688,10 @@ export async function FindChartsOnPopularity( } return rows.map((row) => { - const { playtype: chartPlaytype } = V3ToGamePT(row.game); + const r = ToChartDocument(row) as { __playcount: integer } & ChartDocument; - return { - chartID: row.id, - legacyChartId: row.legacy_id, - songID: row.song_legacy_id, - level: row.level, - levelNum: row.level_num, - isPrimary: row.is_primary, - difficulty: row.difficulty as Difficulties[GPTString], - playtype: chartPlaytype, - data: row.data as MONGO_ChartDocumentData[GPTString], - versions: row.versions as Versions[GPTString][], - __playcount: row.playcount, - } as { __playcount: integer } & MONGO_ChartDocument; + r.__playcount = row.playcount; + + return r; }); } diff --git a/typescript/server/src/utils/queries/pbs.ts b/typescript/server/src/utils/queries/pbs.ts index 0b088eb09..9500912dc 100644 --- a/typescript/server/src/utils/queries/pbs.ts +++ b/typescript/server/src/utils/queries/pbs.ts @@ -1,21 +1,21 @@ -import type { MONGO_PBScoreDocument } from "tachi-common"; +import type { GamesForGroup, PBScoreDocument } from "tachi-common"; import { LoadPbsAdjacentByChartRank } from "#lib/db-formats/pb"; -export function GetAdjacentAbove(userPB: MONGO_PBScoreDocument, size = 5) { +export function GetAdjacentAbove(userPB: PBScoreDocument, size = 5) { return LoadPbsAdjacentByChartRank( userPB.chartID, userPB.rankingData.rank, "above", size, - ) as Promise>>; + ) as Promise>>; } -export function GetAdjacentBelow(userPB: MONGO_PBScoreDocument, size = 5) { +export function GetAdjacentBelow(userPB: PBScoreDocument, size = 5) { return LoadPbsAdjacentByChartRank( userPB.chartID, userPB.rankingData.rank, "below", size, - ) as Promise>>; + ) as Promise>>; } diff --git a/typescript/server/src/utils/queries/questlines.ts b/typescript/server/src/utils/queries/questlines.ts index 32881bd69..143f277f1 100644 --- a/typescript/server/src/utils/queries/questlines.ts +++ b/typescript/server/src/utils/queries/questlines.ts @@ -1,24 +1,14 @@ import { SELECT_QUESTLINE, ToQuestlineDocument } from "#lib/db-formats/questline"; import DB from "#services/pg/db"; -import { - type GameGroup, - GamePTToV3, - type MONGO_QuestlineDocument, - type Playtype, -} from "tachi-common"; +import { type QuestlineDocument, type V3Game } from "tachi-common"; /** - * All questlines for a game/playtype, with `quests` ordered by `questline_quest.sort_order`. + * All questlines for a game, with `quests` ordered by `questline_quest.sort_order`. */ -export async function GetQuestlinesForGamePlaytype( - game: GameGroup, - playtype: Playtype, -): Promise> { - const v3Game = GamePTToV3(game, playtype); - +export async function GetQuestlinesForGame(game: V3Game): Promise> { const qlRows = await DB.selectFrom("questline") .select(SELECT_QUESTLINE) - .where("questline.game", "=", v3Game) + .where("questline.game", "=", game) .execute(); if (qlRows.length === 0) { @@ -51,19 +41,16 @@ export async function GetQuestlinesForGamePlaytype( } /** - * One questline by id, scoped to game/playtype. `quests` are ordered by `questline_quest.sort_order`. + * One questline by id, scoped to game. `quests` are ordered by `questline_quest.sort_order`. */ export async function GetQuestlineById( - game: GameGroup, - playtype: Playtype, + game: V3Game, questlineID: string, -): Promise { - const v3Game = GamePTToV3(game, playtype); - +): Promise { const row = await DB.selectFrom("questline") .select(SELECT_QUESTLINE) .where("questline.id", "=", questlineID) - .where("questline.game", "=", v3Game) + .where("questline.game", "=", game) .executeTakeFirst(); if (!row) { diff --git a/typescript/server/src/utils/queries/scores.test.ts b/typescript/server/src/utils/queries/scores.test.ts index 7c61d7cb7..b76a6d852 100644 --- a/typescript/server/src/utils/queries/scores.test.ts +++ b/typescript/server/src/utils/queries/scores.test.ts @@ -3,7 +3,7 @@ import { seedUser } from "#test-utils/pg-fixtures"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { describe, expect, it } from "vitest"; -import { GetRecentUGPTHighlights, GetRecentUGPTScores } from "./scores"; +import { GetRecentUGPTHighlights, GetRecentUGScores } from "./scores"; describe("GetRecentUGPTScores / GetRecentUGPTHighlights (Postgres)", () => { let counter = 0; @@ -83,7 +83,7 @@ describe("GetRecentUGPTScores / GetRecentUGPTHighlights (Postgres)", () => { timeAddedMs: 9_000_000, }); - const scores = await GetRecentUGPTScores(userId, "iidx", "SP", 10); + const scores = await GetRecentUGScores(userId, "iidx-sp", 10); expect(scores.length).toBeGreaterThanOrEqual(2); expect(scores[0]?.timeAdded).toBeGreaterThanOrEqual(scores[1]?.timeAdded ?? 0); }); @@ -104,7 +104,7 @@ describe("GetRecentUGPTScores / GetRecentUGPTHighlights (Postgres)", () => { timeAddedMs: base + 2000, }); - const highlights = await GetRecentUGPTHighlights(userId, "iidx", "SP", 50); + const highlights = await GetRecentUGPTHighlights(userId, "iidx-sp", 50); const ours = highlights.filter((s) => s.scoreID.startsWith("sc-hl-")); expect(ours).toHaveLength(1); expect(ours[0]?.highlight).toBe(true); diff --git a/typescript/server/src/utils/queries/scores.ts b/typescript/server/src/utils/queries/scores.ts index c555602e1..19ac37c41 100644 --- a/typescript/server/src/utils/queries/scores.ts +++ b/typescript/server/src/utils/queries/scores.ts @@ -7,15 +7,7 @@ import { } from "#lib/db-formats/score"; import DB from "#services/pg/db"; import { sql } from "kysely"; -import { - type GameGroup, - GamePTToV3, - GetGPTConfig, - GetGPTString, - type integer, - type MONGO_ScoreDocument, - type Playtype, -} from "tachi-common"; +import { GetGameConfig, type integer, type ScoreDocument, type V3Game } from "tachi-common"; /** Shared score + chart + song + import select used by activity and UGPT score queries. */ export function scoreDocumentJoin() { @@ -26,17 +18,10 @@ export function scoreDocumentJoin() { .select(SELECT_SCORE_DOCUMENT); } -export async function GetRecentUGPTScores( - userID: integer, - game: GameGroup, - playtype: Playtype, - limit = 100, -) { - const v3Game = GamePTToV3(game, playtype) as Game; - +export async function GetRecentUGScores(userID: integer, game: V3Game, limit = 100) { const rows = await scoreDocumentJoin() .where("score.user_id", "=", userID) - .where("score.game", "=", v3Game) + .where("score.game", "=", game) .orderBy("score.time_added", "desc") .limit(limit) .execute(); @@ -50,15 +35,12 @@ export async function GetRecentUGPTScores( */ export async function GetRecentUGPTScoresByTimeAchieved( userID: integer, - game: GameGroup, - playtype: Playtype, + game: V3Game, limit = 100, ) { - const v3Game = GamePTToV3(game, playtype) as Game; - const rows = await scoreDocumentJoin() .where("score.user_id", "=", userID) - .where("score.game", "=", v3Game) + .where("score.game", "=", game) .orderBy(sql`score.time_achieved desc nulls last`) .limit(limit) .execute(); @@ -67,20 +49,20 @@ export async function GetRecentUGPTScoresByTimeAchieved( } /** Scores for a user on the given Postgres chart ids, ordered by `time_achieved` desc (nulls last). */ -export async function GetScoresForUserOnChartPgIds( +export async function GetScoresForUserOnChartIDs( userID: integer, - v3Game: Game, - chartPgIds: string[], + game: Game, + chartIDs: string[], limit: number, ) { - if (chartPgIds.length === 0) { + if (chartIDs.length === 0) { return []; } const rows = await scoreDocumentJoin() .where("score.user_id", "=", userID) - .where("score.game", "=", v3Game) - .where("chart.id", "in", chartPgIds) + .where("score.game", "=", game) + .where("chart.id", "in", chartIDs) .orderBy(sql`score.time_achieved desc nulls last`) .limit(limit) .execute(); @@ -89,33 +71,20 @@ export async function GetScoresForUserOnChartPgIds( } /** All scores for a user on primary charts only (`chart.is_primary`), unordered (Mongo `/scores/all` parity). */ -export async function GetPrimaryScoresForUserUGPT( - userID: integer, - game: GameGroup, - playtype: Playtype, -) { - const v3Game = GamePTToV3(game, playtype) as Game; - +export async function GetPrimaryScoresForUserUGPT(userID: integer, game: Game) { const rows = await scoreDocumentJoin() .where("score.user_id", "=", userID) - .where("score.game", "=", v3Game) + .where("score.game", "=", game) .where("chart.is_primary", "=", true) .execute(); return rows.map((row) => ToScoreDocument(row as ScoreDocumentJoinRow)); } -export async function GetRecentUGPTHighlights( - userID: integer, - game: GameGroup, - playtype: Playtype, - limit = 100, -) { - const v3Game = GamePTToV3(game, playtype) as Game; - +export async function GetRecentUGPTHighlights(userID: integer, game: V3Game, limit = 100) { const rows = await scoreDocumentJoin() .where("score.user_id", "=", userID) - .where("score.game", "=", v3Game) + .where("score.game", "=", game) .where("score.highlight", "=", true) .orderBy("score.time_added", "desc") .limit(limit) @@ -131,26 +100,22 @@ export async function GetRecentUGPTHighlights( */ export async function GetFolderTimelineScores( userID: integer, - game: GameGroup, - playtype: Playtype, + game: V3Game, chartIDs: string[], metric: string, criteriaValue: number, -): Promise> { +): Promise> { if (chartIDs.length === 0) { return []; } - const gpt = GetGPTString(game, playtype); - const gptConfig = GetGPTConfig(gpt); - const v3Game = GamePTToV3(game, playtype) as Game; - + const gameConfig = GetGameConfig(game); const jsonBlob = - gptConfig.derivedMetrics[metric] !== undefined ? sql`score.derived_data` : sql`score.data`; + gameConfig.derivedMetrics[metric] !== undefined ? sql`score.derived_data` : sql`score.data`; const rows = await scoreDocumentJoin() .where("score.user_id", "=", userID) - .where("score.game", "=", v3Game) + .where("score.game", "=", game) .where("chart.id", "in", chartIDs) .where(sql`(${jsonBlob}::jsonb->>${sql.lit(metric)})::numeric >= ${criteriaValue}`) .orderBy("score.chart_id") diff --git a/typescript/server/src/utils/queries/sessions.ts b/typescript/server/src/utils/queries/sessions.ts index cb21b79a8..522260836 100644 --- a/typescript/server/src/utils/queries/sessions.ts +++ b/typescript/server/src/utils/queries/sessions.ts @@ -1,16 +1,16 @@ -import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart.js"; -import { SELECT_SCORE_DOCUMENT, ToScoreDocument } from "#lib/db-formats/score.js"; -import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song.js"; -import DB from "#services/pg/db.js"; -import { GetUserWithIDGuaranteed } from "#utils/user.js"; +import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; +import { SELECT_SCORE_DOCUMENT, ToScoreDocument } from "#lib/db-formats/score"; +import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song"; +import DB from "#services/pg/db"; +import { GetUserWithIDGuaranteed } from "#utils/user"; import _ from "lodash"; import { - type MONGO_ChartDocument, - type MONGO_ScoreDocument, - type MONGO_SessionDocument, - type MONGO_SongDocument, - type MONGO_UserDocument, + type ChartDocument, + type ScoreDocument, + type SessionDocument, type SessionScoreInfo, + type SongDocument, + type UserDocument, } from "tachi-common"; /** Score ids attached to each session id (batch helper for session list endpoints). */ @@ -42,12 +42,12 @@ export async function GetScoreIdsGroupedBySessionId( return map; } -export async function GetSessionData(session: MONGO_SessionDocument): Promise<{ - charts: Array; +export async function GetSessionData(session: SessionDocument): Promise<{ + charts: Array; scoreInfo: Array; - scores: Array; - songs: Array; - user: MONGO_UserDocument; + scores: Array; + songs: Array; + user: UserDocument; }> { const user = await GetUserWithIDGuaranteed(session.userID); diff --git a/typescript/server/src/utils/queries/songs.test.ts b/typescript/server/src/utils/queries/songs.test.ts index c5efa959f..3c958165c 100644 --- a/typescript/server/src/utils/queries/songs.test.ts +++ b/typescript/server/src/utils/queries/songs.test.ts @@ -24,7 +24,7 @@ describe("FindSongOnTitle / FindSongOnTitleInsensitive / FindSongOnID (Postgres) .execute(); const byTitle = await FindSongOnTitle("iidx", title); - expect(byTitle?.id).toBe(4_200_000); + expect(byTitle?.id).toBe(songId); const songId2 = `song-ft2-${suffix}`; await DB.insertInto("song") @@ -42,7 +42,7 @@ describe("FindSongOnTitle / FindSongOnTitleInsensitive / FindSongOnID (Postgres) .execute(); const byAlt = await FindSongOnTitle("iidx", `Alt ${suffix}`); - expect(byAlt?.id).toBe(4_200_001); + expect(byAlt?.id).toBe(songId2); }); it("FindSongOnTitleInsensitive matches case-insensitively", async () => { @@ -64,10 +64,10 @@ describe("FindSongOnTitle / FindSongOnTitleInsensitive / FindSongOnID (Postgres) .execute(); const doc = await FindSongOnTitleInsensitive("popn", `cased ${suffix}`, "artistx"); - expect(doc?.id).toBe(4_200_010); + expect(doc?.id).toBe(songId); }); - it("FindSongOnID returns song by legacy_id", async () => { + it("FindSongOnID returns song by id", async () => { const suffix = `${Date.now()}`; const songId = `song-fid-${suffix}`; @@ -85,7 +85,7 @@ describe("FindSongOnTitle / FindSongOnTitleInsensitive / FindSongOnID (Postgres) }) .execute(); - const doc = await FindSongOnID("sdvx", 4_200_020); + const doc = await FindSongOnID("sdvx", songId); expect(doc?.title).toBe("T"); }); }); diff --git a/typescript/server/src/utils/queries/songs.ts b/typescript/server/src/utils/queries/songs.ts index 136c92a66..5b4fb857e 100644 --- a/typescript/server/src/utils/queries/songs.ts +++ b/typescript/server/src/utils/queries/songs.ts @@ -1,5 +1,5 @@ import type { KtLogger } from "#lib/log/log"; -import type { GameGroup, integer, MONGO_SongDocument } from "tachi-common"; +import type { GameGroup, SongDocument } from "tachi-common"; import { SELECT_SONG_ROW, ToSongDocumentFromRow } from "#lib/db-formats/song"; import { @@ -17,12 +17,12 @@ import { EscapeStringRegexp } from "../misc"; * rather difficult. Prefer other functions! * @param game - The game to search upon. * @param title - The song title to match. - * @returns MONGO_SongDocument + * @returns SongDocument */ export async function FindSongOnTitle( game: GameGroup, title: string, -): Promise { +): Promise { const res = await DB.selectFrom("song") .select(SELECT_SONG_ROW) .where("song.game_group", "=", game) @@ -50,7 +50,7 @@ export async function FindSongOnTitleInsensitive( game: GameGroup, title: string, artist?: string | null, -): Promise { +): Promise { const titlePat = `^${EscapeStringRegexp(title)}$`; const artistPat = `^${EscapeStringRegexp(artist ?? "")}$`; @@ -88,13 +88,13 @@ export async function FindSongOnTitleInsensitive( * also be the in-game-ID. * @param game - The game to search upon. * @param songID - The song ID to match. - * @returns MONGO_SongDocument + * @returns SongDocument */ -export async function FindSongOnID(game: GameGroup, songID: integer) { +export async function FindSongOnID(game: GameGroup, songID: string) { const row = await DB.selectFrom("song") .select(SELECT_SONG_ROW) .where("song.game_group", "=", game) - .where("song.legacy_id", "=", songID) + .where("song.id", "=", songID) .executeTakeFirst(); return row ? ToSongDocumentFromRow(row) : null; @@ -113,7 +113,7 @@ export async function FindDDRSongOnDDRSongHash(hash: string) { return row ? ToSongDocumentFromRow(row) : null; } -export async function FindSongOnIDGuaranteed(game: GameGroup, songID: integer, log: KtLogger) { +export async function FindSongOnIDGuaranteed(game: GameGroup, songID: string, log: KtLogger) { const song = await FindSongOnID(game, songID); if (!song) { diff --git a/typescript/server/src/utils/queries/stats.ts b/typescript/server/src/utils/queries/stats.ts index efaa816de..7beb631ae 100644 --- a/typescript/server/src/utils/queries/stats.ts +++ b/typescript/server/src/utils/queries/stats.ts @@ -7,24 +7,24 @@ import NodeCache from "node-cache"; import { type Classes, type GameGroup, - GamePTToV3, - type GPTString, type integer, - type Playtype, + LEGACY_GameGroupPTToGame, + type LEGACY_Playtype, + type V3Game, } from "tachi-common"; const classDistCache = new NodeCache(); export async function GetClassDistribution( game: GameGroup, - playtype: Playtype, - className: Classes[GPTString], + playtype: LEGACY_Playtype, + className: Classes[V3Game], ) { const cacheKey = `${game}:${playtype}:${className}`; const cache = classDistCache.get>(cacheKey); if (!cache) { - const v3Game = GamePTToV3(game, playtype) as Game; + const v3Game = LEGACY_GameGroupPTToGame(game, playtype) as Game; const rows = await sql<{ cls: string | null; count: number }>` SELECT jsonb_extract_path_text(game_profile.classes::jsonb, ${sql.lit(className)}) AS cls, diff --git a/typescript/server/src/utils/queries/summary.ts b/typescript/server/src/utils/queries/summary.ts index 69f559cc6..5d2eb825c 100644 --- a/typescript/server/src/utils/queries/summary.ts +++ b/typescript/server/src/utils/queries/summary.ts @@ -1,9 +1,13 @@ import type { integer } from "tachi-common"; -import { SELECT_FOLDER, ToFolderDocument } from "#lib/db-formats/folders.js"; +import { SELECT_FOLDER, ToFolderDocument } from "#lib/db-formats/folders"; import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { SELECT_SESSION_DOCUMENT, ToSessionDocument } from "#lib/db-formats/session"; -import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents"; +import { + AttachFolderSlugsToGoals, + ToGoalDocument, + ToGoalSubscriptionDocument, +} from "#lib/db-formats/target-documents"; import DB from "#services/pg/db"; import { GetEnumDistForFolders } from "#utils/folder"; import { GetTimeXHoursAgo } from "#utils/misc"; @@ -114,6 +118,7 @@ export async function GetGoalSummary(userID: integer) { .execute(); const goals = goalRows.map(ToGoalDocument); + await AttachFolderSlugsToGoals(goals); return { achievedGoals, improvedGoals, goals }; } diff --git a/typescript/server/src/utils/req-tachi-data.ts b/typescript/server/src/utils/req-tachi-data.ts index b14251558..f5877c19a 100644 --- a/typescript/server/src/utils/req-tachi-data.ts +++ b/typescript/server/src/utils/req-tachi-data.ts @@ -5,7 +5,7 @@ import deepmerge from "deepmerge"; import type { TachiRequestData } from "./types"; -export function AssignToReqTachiData(req: Request, data: Partial) { +export function REQ_AssignToReqTachiData(req: Request, data: Partial) { if (!req[SYMBOL_TACHI_DATA]) { req[SYMBOL_TACHI_DATA] = data; } else { @@ -17,7 +17,7 @@ export function AssignToReqTachiData(req: Request, data: Partial( +export function REQ_GetTachiData( req: Request, key: T, ): Exclude { @@ -39,23 +39,48 @@ export function GetTachiData( return value as unknown as Exclude; } -export function GetUser(req: Request) { - const user = GetTachiData(req, "requestedUser"); +export function REQ_GetUser(req: Request) { + const user = REQ_GetTachiData(req, "requestedUser"); return user; } -export function GetUGPT(req: Request) { - const user = GetTachiData(req, "requestedUser"); - const game = GetTachiData(req, "game"); - const playtype = GetTachiData(req, "playtype"); +/** + * @deprecated Use REQ_GetUserGame instead. + */ +export function LEGACY_REQ_GetUGPT(req: Request) { + const user = REQ_GetTachiData(req, "requestedUser"); + const gameGroup = REQ_GetTachiData(req, "gameGroup"); + const playtype = REQ_GetTachiData(req, "playtype"); - return { user, game, playtype }; + return { user, gameGroup, playtype }; } -export function GetGPT(req: Request) { - const game = GetTachiData(req, "game"); - const playtype = GetTachiData(req, "playtype"); +/** + * @deprecated Use REQ_GetGame instead. + */ +export function LEGACY_REQ_GetGPT(req: Request) { + const gameGroup = REQ_GetTachiData(req, "gameGroup"); + const playtype = REQ_GetTachiData(req, "playtype"); - return { game, playtype }; + return { gameGroup, playtype }; +} + +export function REQ_GetUserGame(req: Request) { + const user = REQ_GetTachiData(req, "requestedUser"); + const game = REQ_GetTachiData(req, "game"); + + return { user, game }; +} + +export function REQ_GetGame(req: Request) { + const game = REQ_GetTachiData(req, "game"); + + return game; +} + +export function REQ_GetGameGroup(req: Request) { + const gameGroup = REQ_GetTachiData(req, "gameGroup"); + + return gameGroup; } diff --git a/typescript/server/src/utils/reset-state/destroy-user-game-profile.ts b/typescript/server/src/utils/reset-state/destroy-user-game-profile.ts index c4bbd8729..b15948505 100644 --- a/typescript/server/src/utils/reset-state/destroy-user-game-profile.ts +++ b/typescript/server/src/utils/reset-state/destroy-user-game-profile.ts @@ -4,7 +4,12 @@ import { log } from "#lib/log/log"; import { RecalculatePbsForChartsFromPostgresScores } from "#lib/score-import/framework/pb/process-pbs"; import DB from "#services/pg/db"; import { sql } from "kysely"; -import { type GameGroup, GamePTToV3, type integer, type Playtype } from "tachi-common"; +import { + type GameGroup, + type integer, + LEGACY_GameGroupPTToGame, + type LEGACY_Playtype, +} from "tachi-common"; /** * Completely resets a user's game profile. @@ -14,18 +19,18 @@ import { type GameGroup, GamePTToV3, type integer, type Playtype } from "tachi-c export default async function DestroyUserGameProfile( userID: integer, game: GameGroup, - playtype: Playtype, + playtype: LEGACY_Playtype, ) { - const v3Game = GamePTToV3(game, playtype) as Game; + const v3Game = LEGACY_GameGroupPTToGame(game, playtype) as Game; await DB.deleteFrom("game_stats_snapshot") .where("user_id", "=", userID) .where("game", "=", v3Game) .execute(); - const chartLegacyRows = await DB.selectFrom("pb") + const chartRows = await DB.selectFrom("pb") .innerJoin("chart", "chart.id", "pb.chart_id") - .select("chart.legacy_id") + .select("chart.id") .where("pb.user_id", "=", userID) .where("chart.game", "=", v3Game) .where("pb.lens", "is", null) @@ -33,16 +38,13 @@ export default async function DestroyUserGameProfile( const scoreChartRows = await DB.selectFrom("score") .innerJoin("chart", "chart.id", "score.chart_id") - .select("chart.legacy_id") + .select("chart.id") .where("score.user_id", "=", userID) .where("chart.game", "=", v3Game) .execute(); - const chartLegacyIds = [ - ...new Set([ - ...chartLegacyRows.map((r) => r.legacy_id), - ...scoreChartRows.map((r) => r.legacy_id), - ]), + const chartIDs = [ + ...new Set([...chartRows.map((r) => r.id), ...scoreChartRows.map((r) => r.id)]), ]; const scoreRows = await DB.selectFrom("score") @@ -75,8 +77,8 @@ export default async function DestroyUserGameProfile( await DB.deleteFrom("pb").where("row_id", "in", pbIds).execute(); } - if (chartLegacyIds.length > 0) { - await RecalculatePbsForChartsFromPostgresScores(game, playtype, chartLegacyIds, log); + if (chartIDs.length > 0) { + await RecalculatePbsForChartsFromPostgresScores(v3Game, chartIDs, log); } await DB.deleteFrom("session") diff --git a/typescript/server/src/utils/scores.test.ts b/typescript/server/src/utils/scores.test.ts index 9280f3d6d..8b186b0ea 100644 --- a/typescript/server/src/utils/scores.test.ts +++ b/typescript/server/src/utils/scores.test.ts @@ -1,24 +1,17 @@ -import DB from "#services/pg/db"; -import { seedUser } from "#test-utils/pg-fixtures"; import { describe, expect, it } from "vitest"; -import { - FilterChartsAndSongs, - GetPBOnChart, - GetScoreIDsFromComposed, - GetServerRecordOnChart, -} from "./scores"; +import { FilterChartsAndSongs, GetScoreIDsFromComposed } from "./scores"; describe("FilterChartsAndSongs", () => { it("keeps only charts and songs referenced by scores", () => { const out = FilterChartsAndSongs( - [{ chartID: "c1", songID: 1 } as never, { chartID: "c2", songID: 2 } as never], + [{ chartID: "c1", songID: "s1" } as never, { chartID: "c2", songID: "s2" } as never], [{ chartID: "c1" } as never, { chartID: "cX" } as never], - [{ id: 1 } as never, { id: 9 } as never], + [{ id: "s1" } as never, { id: "s9" } as never], ); expect(out.charts.map((c: { chartID: string }) => c.chartID)).toEqual(["c1"]); - expect(out.songs.map((s: { id: number }) => s.id)).toEqual([1]); + expect(out.songs.map((s: { id: string }) => s.id)).toEqual(["s1"]); }); }); @@ -35,84 +28,3 @@ describe("GetScoreIDsFromComposed", () => { expect(ids.sort()).toEqual(["a", "b"]); }); }); - -describe("GetPBOnChart / GetServerRecordOnChart (Postgres)", () => { - let n = 0; - - async function seedIidxChartWithPbs(opts: { pbs: Array<{ ranking: number; userId: number }> }) { - const k = ++n; - const songId = `song-pbutil-${k}`; - const chartId = `chart-pbutil-${k}`; - - await DB.insertInto("song") - .values({ - id: songId, - legacy_id: 8_000_000 + k, - game_group: "iidx", - title: "T", - artist: "A", - search_terms: [], - alt_titles: [], - data: JSON.stringify({}), - fts_document: "", - }) - .execute(); - - await DB.insertInto("chart") - .values({ - id: chartId, - legacy_id: chartId, - game: "iidx-sp", - song_id: songId, - level: "10", - level_num: 10, - is_primary: true, - difficulty: "NORMAL", - versions: [], - data: JSON.stringify({}), - }) - .execute(); - - for (const p of opts.pbs) { - // eslint-disable-next-line no-await-in-loop - await DB.insertInto("pb") - .values({ - user_id: p.userId, - chart_id: chartId, - lens: null, - data: JSON.stringify({}), - derived_data: JSON.stringify({}), - calculated_data: JSON.stringify({}), - judgements: JSON.stringify({}), - ranking_value: p.ranking, - ranking_value_tb1: null, - ranking_value_tb2: null, - ranking_value_tb3: null, - ranking_value_tb4: null, - ranking_value_tb5: null, - highlight: false, - time_achieved: null, - }) - .execute(); - } - - return { chartId }; - } - - it("GetServerRecordOnChart picks highest ranking_value on chart", async () => { - const t = Date.now(); - const { id: low } = await seedUser({ username: `pb_low_${t}` }); - const { id: high } = await seedUser({ username: `pb_high_${t}` }); - const { chartId } = await seedIidxChartWithPbs({ - pbs: [ - { userId: low, ranking: 10 }, - { userId: high, ranking: 99.5 }, - ], - }); - - const pb = await GetServerRecordOnChart(chartId); - expect(pb).not.toBeNull(); - expect(pb?.userID).toBe(high); - expect(pb?.rankingData.rank).toBe(1); - }); -}); diff --git a/typescript/server/src/utils/scores.ts b/typescript/server/src/utils/scores.ts index 0b8d271e4..e7b98781b 100644 --- a/typescript/server/src/utils/scores.ts +++ b/typescript/server/src/utils/scores.ts @@ -1,30 +1,16 @@ import { - LoadPbByUserAndChartLegacyId, - LoadPbServerRecordForChartLegacyId, -} from "#lib/db-formats/pb"; -import { - type integer, - type MONGO_ChartDocument, - type MONGO_PBScoreDocument, - type MONGO_ScoreDocument, - type MONGO_SongDocument, - MongoChartLegacyId, + type ChartDocument, + type PBScoreDocument, + type ScoreDocument, + type SongDocument, } from "tachi-common"; import { DedupeArr } from "./misc"; -export function GetPBOnChart(userID: integer, chartID: string) { - return LoadPbByUserAndChartLegacyId(userID, chartID); -} - -export function GetServerRecordOnChart(chartID: string) { - return LoadPbServerRecordForChartLegacyId(chartID); -} - export function FilterChartsAndSongs( - scores: Array, - charts: Array, - songs: Array, + scores: Array, + charts: Array, + songs: Array, ) { const chartIDs = new Set(); const songIDs = new Set(); @@ -37,13 +23,11 @@ export function FilterChartsAndSongs( // filter out irrelevant songs and charts return { songs: songs.filter((e) => songIDs.has(e.id)), - charts: charts.filter( - (e) => chartIDs.has(e.chartID) || chartIDs.has(MongoChartLegacyId(e)), - ), + charts: charts.filter((e) => chartIDs.has(e.chartID)), }; } -export function GetScoreIDsFromComposed(pb: MONGO_PBScoreDocument) { +export function GetScoreIDsFromComposed(pb: PBScoreDocument) { const arr = pb.composedFrom.map((e) => e.scoreID); return DedupeArr(arr); diff --git a/typescript/server/src/utils/session.oldtest.ts b/typescript/server/src/utils/session.oldtest.ts deleted file mode 100644 index af5859ab1..000000000 --- a/typescript/server/src/utils/session.oldtest.ts +++ /dev/null @@ -1,31 +0,0 @@ -import MONGODB_KILL from "#services/mongo/db"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -import { GetScoresFromSession } from "./session"; - -t.test("#GetScoresFromSession", async (t) => { - t.beforeEach(ResetDBState); - - await ResetDBState(); - - const exampleSession = await MONGODB_KILL.sessions.findOne(); - - const scores = await GetScoresFromSession(exampleSession!); - - t.equal( - scores.length, - exampleSession!.scoreIDs.length, - "Should return the same amount of scores as the session.", - ); - - for (let i = 0; i < exampleSession!.scoreIDs.length; i++) { - t.equal( - scores[i]?.scoreID, - exampleSession!.scoreIDs[i], - "Should return the scores requested.", - ); - } - - t.end(); -}); diff --git a/typescript/server/src/utils/session.test.ts b/typescript/server/src/utils/session.test.ts index 4626fc89b..4f46ffa81 100644 --- a/typescript/server/src/utils/session.test.ts +++ b/typescript/server/src/utils/session.test.ts @@ -31,8 +31,7 @@ describe("GetScoresFromSession (Postgres)", () => { scoreIDs: [], name: "E", desc: null, - game: "iidx", - playtype: "SP", + game: "iidx-sp", timeInserted: 0, timeStarted: 0, timeEnded: 0, @@ -123,8 +122,7 @@ describe("GetScoresFromSession (Postgres)", () => { scoreIDs: [scoreId], name: "S", desc: null, - game: "iidx", - playtype: "SP", + game: "iidx-sp", timeInserted: 0, timeStarted: 0, timeEnded: 0, @@ -197,8 +195,7 @@ describe("GetSessionFromScore (Postgres)", () => { const session = await GetSessionFromScore({ service: "Unknown", - game: "iidx", - playtype: "SP", + game: "iidx-sp", userID: userId, scoreData: {} as never, calculatedData: {} as never, @@ -209,7 +206,7 @@ describe("GetSessionFromScore (Postgres)", () => { highlight: false, comment: null, chartID: chartId, - songID: 9_000_002, + songID: "s9_000_002", isPrimary: true, importType: "file/batch-manual", }); @@ -291,8 +288,7 @@ describe("GetSessionFromScore (Postgres)", () => { const session = await GetSessionFromScore({ service: "Unknown", - game: "iidx", - playtype: "SP", + game: "iidx-sp", userID: userId, scoreData: {} as never, calculatedData: {} as never, @@ -303,7 +299,7 @@ describe("GetSessionFromScore (Postgres)", () => { highlight: false, comment: null, chartID: chartId, - songID: 9_000_003, + songID: "s9000003", isPrimary: true, importType: "file/batch-manual", }); diff --git a/typescript/server/src/utils/session.ts b/typescript/server/src/utils/session.ts index 165db1de0..71ac51d9e 100644 --- a/typescript/server/src/utils/session.ts +++ b/typescript/server/src/utils/session.ts @@ -1,4 +1,4 @@ -import type { MONGO_ScoreDocument, MONGO_SessionDocument } from "tachi-common"; +import type { ScoreDocument, SessionDocument } from "tachi-common"; import { type ScoreDocumentJoinRow, @@ -12,7 +12,7 @@ import DB from "#services/pg/db"; * Returns all the score documents inside a session. * @param session The session to retrieve the score documents of. */ -export async function GetScoresFromSession(session: MONGO_SessionDocument) { +export async function GetScoresFromSession(session: SessionDocument) { if (session.scoreIDs.length === 0) { return []; } @@ -32,7 +32,7 @@ export async function GetScoresFromSession(session: MONGO_SessionDocument) { * Returns the session a score belongs to, if there is one. A score can only be part of one session implicitly. * @param score The score to return the associated session of. */ -export async function GetSessionFromScore(score: MONGO_ScoreDocument) { +export async function GetSessionFromScore(score: ScoreDocument) { const row = await DB.selectFrom("score") .select("session_id") .where("id", "=", score.scoreID) diff --git a/typescript/server/src/utils/string-checks.ts b/typescript/server/src/utils/string-checks.ts index bb6dbaa15..69df5660b 100644 --- a/typescript/server/src/utils/string-checks.ts +++ b/typescript/server/src/utils/string-checks.ts @@ -2,9 +2,8 @@ import { type AnyProfileRatingAlg, type AnyScoreRatingAlg, type AnySessionRatingAlg, - type GameGroup, - GetGamePTConfig, - type Playtype, + GetGameConfig, + type V3Game, } from "tachi-common"; import { IsString } from "./misc"; @@ -51,33 +50,33 @@ export function ParseStrPositiveNonZeroInt(val: unknown) { return v; } -export function CheckStrProfileAlg(game: GameGroup, playtype: Playtype, strVal: string) { - const gptConfig = GetGamePTConfig(game, playtype); +export function CheckStrProfileAlg(game: V3Game, strVal: string) { + const gameConfig = GetGameConfig(game); // @hack - if (!Object.keys(gptConfig.profileRatingAlgs).includes(strVal as AnyProfileRatingAlg)) { + if (!Object.keys(gameConfig.profileRatingAlgs).includes(strVal as AnyProfileRatingAlg)) { return null; } return strVal as AnyProfileRatingAlg; } -export function CheckStrScoreAlg(game: GameGroup, playtype: Playtype, strVal: string) { - const gptConfig = GetGamePTConfig(game, playtype); +export function CheckStrScoreAlg(game: V3Game, strVal: string) { + const gameConfig = GetGameConfig(game); // @hack - if (!Object.keys(gptConfig.scoreRatingAlgs).includes(strVal as AnyScoreRatingAlg)) { + if (!Object.keys(gameConfig.scoreRatingAlgs).includes(strVal as AnyScoreRatingAlg)) { return null; } return strVal as AnyScoreRatingAlg; } -export function CheckStrSessionAlg(game: GameGroup, playtype: Playtype, strVal: string) { - const gptConfig = GetGamePTConfig(game, playtype); +export function CheckStrSessionAlg(game: V3Game, strVal: string) { + const gameConfig = GetGameConfig(game); // @hack - if (!Object.keys(gptConfig.sessionRatingAlgs).includes(strVal as AnySessionRatingAlg)) { + if (!Object.keys(gameConfig.sessionRatingAlgs).includes(strVal as AnySessionRatingAlg)) { return null; } diff --git a/typescript/server/src/utils/types.ts b/typescript/server/src/utils/types.ts index 979114620..369998feb 100644 --- a/typescript/server/src/utils/types.ts +++ b/typescript/server/src/utils/types.ts @@ -1,24 +1,25 @@ import type { TachiBMSTable } from "#lib/game-specific/custom-bms-tables"; import type { + ChartDocument, + FolderDocument, GameGroup, + GoalDocument, + GoalSubscriptionDocument, + ImportDocument, integer, - MONGO_ChartDocument, - MONGO_FolderDocument, - MONGO_GoalDocument, - MONGO_GoalSubscriptionDocument, - MONGO_ImportDocument, - MONGO_QuestDocument, - MONGO_QuestlineDocument, - MONGO_QuestSubscriptionDocument, - MONGO_ScoreDocument, - MONGO_SessionDocument, - MONGO_SongDocument, - MONGO_TableDocument, - MONGO_TachiAPIClientDocument, - MONGO_UserDocument, - MONGO_UserGameStats, - MONGO_UserSettingsDocument, - Playtype, + LEGACY_Playtype, + QuestDocument, + QuestlineDocument, + QuestSubscriptionDocument, + ScoreDocument, + SessionDocument, + SongDocument, + TableDocument, + TachiAPIClientDocument, + UserDocument, + UserGameStats, + UserSettingsDocument, + V3Game, } from "tachi-common"; // Inject additional properties on express-session @@ -41,8 +42,8 @@ declare module "express-serve-static-core" { } export interface TachiSessionData { - user: MONGO_UserDocument; - settings: MONGO_UserSettingsDocument; + user: UserDocument; + settings: UserSettingsDocument; } export interface TachiAPIFailResponse { @@ -67,34 +68,33 @@ export type EmptyObject = Record; * Data that may be monkey-patched onto req.tachi. This holds things such as middleware results. */ export interface TachiRequestData { - uscChartDoc?: MONGO_ChartDocument<"usc:Controller" | "usc:Keyboard">; + uscChartDoc?: ChartDocument<"usc-controller" | "usc-keyboard">; - beatorajaChartDoc?: MONGO_ChartDocument< - "bms:7K" | "bms:14K" | "pms:Controller" | "pms:Keyboard" - >; + beatorajaChartDoc?: ChartDocument<"bms-7k" | "bms-14k" | "pms-controller" | "pms-keyboard">; - requestedUser?: MONGO_UserDocument; - requestedUserGameStats?: MONGO_UserGameStats; - game?: GameGroup; - playtype?: Playtype; + requestedUser?: UserDocument; + requestedUserGameStats?: UserGameStats; + gameGroup?: GameGroup; + game?: V3Game; + playtype?: LEGACY_Playtype; - chartDoc?: MONGO_ChartDocument; - songDoc?: MONGO_SongDocument; + chartDoc?: ChartDocument; + songDoc?: SongDocument; songNewID?: string; - scoreDoc?: MONGO_ScoreDocument; - sessionDoc?: MONGO_SessionDocument; - tableDoc?: MONGO_TableDocument; - folderDoc?: MONGO_FolderDocument; - goalDoc?: MONGO_GoalDocument; - questDoc?: MONGO_QuestDocument; - goalSubDoc?: MONGO_GoalSubscriptionDocument; - questSubDoc?: MONGO_QuestSubscriptionDocument; - questlineDoc?: MONGO_QuestlineDocument; - importDoc?: MONGO_ImportDocument; + scoreDoc?: ScoreDocument; + sessionDoc?: SessionDocument; + tableDoc?: TableDocument; + folderDoc?: FolderDocument; + goalDoc?: GoalDocument; + questDoc?: QuestDocument; + goalSubDoc?: GoalSubscriptionDocument; + questSubDoc?: QuestSubscriptionDocument; + questlineDoc?: QuestlineDocument; + importDoc?: ImportDocument; customBMSTable?: TachiBMSTable; - apiClientDoc: Omit; + apiClientDoc: Omit; } // This is only used on tachi-server, and isn't exposed -- so shouldn't be a part diff --git a/typescript/server/src/utils/user.oldtest.ts b/typescript/server/src/utils/user.oldtest.ts deleted file mode 100644 index 91cad2d57..000000000 --- a/typescript/server/src/utils/user.oldtest.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { UserDocument } from "tachi-common"; - -import MONGODB_KILL from "#services/mongo/db"; -import { DatabaseSchemas } from "#services/mongo/schemas"; -import { mkFakeUser } from "#test-utils/misc"; -import ResetDBState from "#test-utils/resets"; -import t from "tap"; - -import { FormatUserDoc, GetUserCaseInsensitive, GetUsersWithIDs } from "./user"; - -t.test("#GetUserCaseInsensitive", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return the user for an exact username", async (t) => { - const result = await GetUserCaseInsensitive("test_zkldi"); - - t.not(result, null, "Should not return null"); - - t.equal(result!.username, "test_zkldi", "Should return test_zkldi"); - - t.ok(DatabaseSchemas.users(result), "Should return a conforming PublicUserDocument"); - - // @ts-expect-error yeah - t.equal(result.password, undefined, "Should not return password"); - - // @ts-expect-error yeah - t.equal(result.email, undefined, "Should not return email"); - }); - - t.test("Should return the user for an incorrectly cased username", async (t) => { - const result = await GetUserCaseInsensitive("tesT_ZkLdi"); - - t.not(result, null, "Should not return null"); - - t.equal(result!.username, "test_zkldi", "Should return test_zkldi"); - - t.ok(DatabaseSchemas.users(result), "Should return a conforming PublicUserDocument"); - - // @ts-expect-error yeah - t.equal(result.password, undefined, "Should not return password"); - - // @ts-expect-error yeah - t.equal(result.email, undefined, "Should not return email"); - }); - - t.test("Should not return the user for a username that does not exist", async (t) => { - const result = await GetUserCaseInsensitive("foobar"); - - t.equal(result, null, "Should return null"); - }); - - t.end(); -}); - -t.test("#GetUsersWithIDs", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(() => MONGODB_KILL.users.insert([mkFakeUser(2), mkFakeUser(3), mkFakeUser(4)])); - - t.test("Should return users with these IDs.", async (t) => { - const res = await GetUsersWithIDs([2, 3]); - - const expected = [mkFakeUser(2), mkFakeUser(3)]; - - t.strictSame(res, expected, "Should return the user documents at these IDs."); - - t.end(); - }); - - t.test("Shouldn't reject for duplicate userIDs", async (t) => { - try { - const res = await GetUsersWithIDs([1, 2, 1]); - - t.hasStrict( - res.map((e) => e.id), - [1, 2], - "Should have the requested user IDs.", - ); - t.pass(); - } catch (e) { - const err = e as Error; - - t.fail(err.message); - } - - t.end(); - }); - - t.test("Shouldn't reject for userID length mismatch", (t) => { - t.rejects(() => GetUsersWithIDs([1, 2, 1, 5])); - - t.end(); - }); - - t.end(); -}); - -t.test("#FormatUserDoc", (t) => { - t.equal( - FormatUserDoc({ username: "zkldi", id: 123 } as UserDocument), - "zkldi (#123)", - "Should format a user document into username #id format.", - ); - - t.end(); -}); diff --git a/typescript/server/src/utils/user.test.ts b/typescript/server/src/utils/user.test.ts index b5c50a78f..57d2e348a 100644 --- a/typescript/server/src/utils/user.test.ts +++ b/typescript/server/src/utils/user.test.ts @@ -1,8 +1,17 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; +import { type UserDocument } from "tachi-common"; import { describe, expect, it } from "vitest"; -import { GetAllRankings, GetAllUserRivals, GetUsersRanking, GetUsersRankingAndOutOf } from "./user"; +import { + FormatUserDoc, + GetAllRankings, + GetAllUserRivals, + GetUserCaseInsensitive, + GetUsersRanking, + GetUsersRankingAndOutOf, + GetUsersWithIDs, +} from "./user"; // ─── helpers ───────────────────────────────────────────────────────────────── @@ -24,8 +33,7 @@ async function seedGameStats( function makeStats(userId: number, ktLampRating: number | null) { return { userID: userId, - game: "iidx" as const, - playtype: "SP" as const, + game: "iidx-sp" as const, ratings: { ktLampRating }, classes: {}, }; @@ -116,7 +124,7 @@ describe("GetUsersRankingAndOutOf", () => { // user2 has higher ktLampRating but lower BPI // Checking BPI ranking for user1: user1 BPI=80, user2 BPI=30, user1 is #1 const result = await GetUsersRankingAndOutOf( - { userID: user1.id, game: "iidx", playtype: "SP", ratings: { BPI: 80 }, classes: {} }, + { userID: user1.id, game: "iidx-sp", ratings: { BPI: 80 }, classes: {} }, "BPI" as never, ); @@ -216,8 +224,7 @@ describe("GetAllRankings", () => { const stats = { userID: user1.id, - game: "iidx" as const, - playtype: "SP" as const, + game: "iidx-sp" as const, ratings: { ktLampRating: 20, BPI: 50 }, classes: {}, }; @@ -229,3 +236,74 @@ describe("GetAllRankings", () => { expect(result.BPI).toEqual({ ranking: 1, outOf: 2 }); }); }); + +describe("GetUserCaseInsensitive", () => { + it("returns the user for an exact username", async () => { + await seedUser({ username: "case_test_user" }); + + const result = await GetUserCaseInsensitive("case_test_user"); + + expect(result).not.toBeNull(); + expect(result!.username).toBe("case_test_user"); + expect(result!).not.toHaveProperty("password"); + expect(result!).not.toHaveProperty("email"); + }); + + it("returns the user for a differently cased username", async () => { + await seedUser({ username: "case_test_user" }); + + const result = await GetUserCaseInsensitive("CaSe_TeSt_UsEr"); + + expect(result).not.toBeNull(); + expect(result!.username).toBe("case_test_user"); + }); + + it("returns null when the username does not exist", async () => { + const result = await GetUserCaseInsensitive("no_such_user_xyz"); + + expect(result).toBeNull(); + }); +}); + +describe("GetUsersWithIDs", () => { + it("returns an empty array when given no ids", async () => { + await expect(GetUsersWithIDs([])).resolves.toEqual([]); + }); + + it("returns users for the given ids", async () => { + const u2 = await seedUser({ username: "gwid_two" }); + const u3 = await seedUser({ username: "gwid_three" }); + + const res = await GetUsersWithIDs([u2.id, u3.id]); + + const byId = new Map(res.map((u) => [u.id, u])); + expect(byId.get(u2.id)?.username).toBe("gwid_two"); + expect(byId.get(u3.id)?.username).toBe("gwid_three"); + }); + + it("accepts duplicate user ids in the input", async () => { + const u1 = await seedUser({ username: "gwid_dup_a" }); + const u2 = await seedUser({ username: "gwid_dup_b" }); + + const res = await GetUsersWithIDs([u1.id, u2.id, u1.id]); + + expect(res.map((u) => u.id).sort((a, b) => a - b)).toEqual( + [u1.id, u2.id].sort((a, b) => a - b), + ); + }); + + it("throws when some user ids do not exist", async () => { + const u1 = await seedUser({ username: "gwid_missing_a" }); + const u2 = await seedUser({ username: "gwid_missing_b" }); + + await expect(GetUsersWithIDs([u1.id, u2.id, 9_999_999])).rejects.toThrow( + /given 3 userIDs, but only matched 2/u, + ); + }); +}); + +describe("FormatUserDoc", () => { + it("formats username and id for logging", () => { + expect(FormatUserDoc({ username: "zkldi", id: 123 } as UserDocument)).toBe("zkldi (#123)"); + }); +}); diff --git a/typescript/server/src/utils/user.ts b/typescript/server/src/utils/user.ts index 9fbed0a50..e193753af 100644 --- a/typescript/server/src/utils/user.ts +++ b/typescript/server/src/utils/user.ts @@ -7,19 +7,15 @@ import DB from "#services/pg/db"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Kysely, sql, type Transaction } from "kysely"; import { - type GameGroup, - GamePTToV3, - GetGamePTConfig, - type GPTString, + type APITokenDocument, + GetGameConfig, type integer, - type MONGO_APITokenDocument, - type MONGO_UserDocument, - type MONGO_UserGameStats, - type MONGO_UserSettingsDocument, - type Playtype, type ProfileRatingAlgorithms, UserAuthLevels, - V3ToGamePT, + type UserDocument, + type UserGameStats, + type UserSettingsDocument, + type V3Game, } from "tachi-common"; import { type Database } from "tachi-db"; @@ -44,7 +40,7 @@ export async function GetUsernameFromUserID(userID: integer): Promise { /** * Gets a user based on their username case-insensitively. */ -export function GetUserCaseInsensitive(username: string): Promise { +export function GetUserCaseInsensitive(username: string): Promise { return DB.selectFrom("account") .select(SELECT_USER) .where("normalized_username", "=", username.toLowerCase()) @@ -71,7 +67,7 @@ export function GetUserPrivateInfo(userID: integer) { /** * Gets a user from their userID. */ -export function GetUserWithID(userID: integer): Promise { +export function GetUserWithID(userID: integer): Promise { return DB.selectFrom("account") .select(SELECT_USER) .where("id", "=", userID) @@ -79,7 +75,7 @@ export function GetUserWithID(userID: integer): Promise (res ? ToUserDocument(res) : null)); } -export async function GetSettingsForUser(userID: integer): Promise { +export async function GetSettingsForUser(userID: integer): Promise { const following = await GetFollowingForUser(userID); return DB.selectFrom("account_settings") @@ -123,7 +119,7 @@ export async function GetUsersWithIDs(userIDs: Array) { * If the user document is not found, a severe error is logged, and this * function throws. */ -export async function GetUserWithIDGuaranteed(userID: integer): Promise { +export async function GetUserWithIDGuaranteed(userID: integer): Promise { const userDoc = await GetUserWithID(userID); if (!userDoc) { @@ -156,32 +152,30 @@ export function ResolveUser(usernameOrID: string) { /** * Returns a formatted string indicating the user. This is used for logging. */ -export function FormatUserDoc(userdoc: MONGO_UserDocument) { +export function FormatUserDoc(userdoc: UserDocument) { return `${userdoc.username} (#${userdoc.id})`; } -export async function GetUsersRanking(stats: MONGO_UserGameStats) { +export async function GetUsersRanking(stats: UserGameStats) { const { ranking } = await GetUsersRankingAndOutOf(stats); return ranking; } -export function GetUGPTPlaycount(userID: integer, game: GameGroup, playtype: Playtype) { - const v3Game = GamePTToV3(game, playtype); - +export function GetUGPTPlaycount(userID: integer, game: V3Game) { return DB.selectFrom("score") .select((eb) => eb.fn.countAll().as("playcount")) .where("user_id", "=", userID) - .where("game", "=", v3Game) + .where("game", "=", game) .executeTakeFirst() .then((res) => Number(res?.playcount ?? 0)); } -export async function GetAllRankings(stats: MONGO_UserGameStats) { - const gptConfig = GetGamePTConfig(stats.game, stats.playtype); +export async function GetAllRankings(stats: UserGameStats) { + const gameConfig = GetGameConfig(stats.game); const entries = await Promise.all( - Object.keys(gptConfig.profileRatingAlgs).map((k) => - GetUsersRankingAndOutOf(stats, k as ProfileRatingAlgorithms[GPTString]).then((r) => [ + Object.keys(gameConfig.profileRatingAlgs).map((k) => + GetUsersRankingAndOutOf(stats, k as ProfileRatingAlgorithms[V3Game]).then((r) => [ k, r, ]), @@ -189,18 +183,17 @@ export async function GetAllRankings(stats: MONGO_UserGameStats) { ); return Object.fromEntries(entries) as Record< - ProfileRatingAlgorithms[GPTString], + ProfileRatingAlgorithms[V3Game], { outOf: integer; ranking: integer } >; } export async function GetUsersRankingAndOutOf( - stats: MONGO_UserGameStats, - alg?: ProfileRatingAlgorithms[GPTString], + stats: UserGameStats, + alg?: ProfileRatingAlgorithms[V3Game], ) { - const gptConfig = GetGamePTConfig(stats.game, stats.playtype); - const ratingAlg = alg ?? gptConfig.defaultProfileRatingAlg; - const v3Game = GamePTToV3(stats.game, stats.playtype); + const gameConfig = GetGameConfig(stats.game); + const ratingAlg = alg ?? gameConfig.defaultProfileRatingAlg; const userRating = stats.ratings[ratingAlg] ?? null; const result = await DB.selectFrom("game_profile") @@ -210,7 +203,7 @@ export async function GetUsersRankingAndOutOf( "ranking_count", ), ]) - .where("game", "=", v3Game) + .where("game", "=", stats.game) .executeTakeFirstOrThrow(); return { @@ -232,7 +225,7 @@ export function GetOnlineCutoff() { /** * Returns whether a given userID is an administrator or not. */ -export async function IsRequesterAdmin(request: MONGO_APITokenDocument) { +export async function IsRequesterAdmin(request: APITokenDocument) { // API Tokens created on the behalf of an admin do NOT inherit admin permissions. if (request.token !== null) { return false; @@ -258,7 +251,7 @@ export async function IsUserAdmin(userID: integer) { return exists; } -export async function IsUserBanned(userID: integer) { +export function IsUserBanned(userID: integer) { return DB.selectFrom("account") .select("auth_level") .where("id", "=", userID) @@ -268,15 +261,15 @@ export async function IsUserBanned(userID: integer) { } /** - * Return all the GPTs this userID has played. + * Return all the games this userID has played. */ -export async function GetUserPlayedGPTs(userID: integer) { +export async function GetUserPlayedGames(userID: integer) { const rows = await DB.selectFrom("game_profile") .select("game") .where("user_id", "=", userID) .execute(); - return rows.map((r) => V3ToGamePT(r.game)); + return rows.map((r) => r.game); } export async function GetAllUserRivals(userID: integer) { @@ -327,7 +320,7 @@ export async function GetNextAvailableUsernameChange( * * In practice, that's how these things work anyway. */ -export async function GetFirstAdmin(): Promise { +export async function GetFirstAdmin(): Promise { const admin = await DB.selectFrom("account") .select(SELECT_USER) .where("auth_level", "=", "admin") diff --git a/typescript/server/tsconfig.json b/typescript/server/tsconfig.json index 6f4a16996..bb342fb56 100644 --- a/typescript/server/tsconfig.json +++ b/typescript/server/tsconfig.json @@ -9,5 +9,5 @@ } }, "include": ["src", "vitest.config.ts", "vitest.globalSetup.ts", "vitest.setup.ts"], - "exclude": ["node_modules", "src/**/*.oldtest.ts"] + "exclude": ["node_modules"] } diff --git a/typescript/server/vitest.config.ts b/typescript/server/vitest.config.ts index 0060d6b63..286c6e73f 100644 --- a/typescript/server/vitest.config.ts +++ b/typescript/server/vitest.config.ts @@ -19,6 +19,12 @@ export default defineConfig({ }, test: { + passWithNoTests: true, + + // resetDatabase() per test can exceed Vitest defaults under parallel workers. + testTimeout: 20_000, + hookTimeout: 60_000, + // `vitest bench` uses this same config: globalSetup + setupFiles + per-worker POSTGRES_URL. // Use for API / DB performance work as well as microbenches (*.bench.ts). // @@ -44,16 +50,14 @@ export default defineConfig({ pool: "forks", coverage: { + enabled: true, provider: "v8", + // Defaults plus lcov; `json` emits coverage-final.json for tachi-coverage-tools. + reporter: ["text", "html", "clover", "json", "lcov"], include: coverageSupporterActionOnly ? ["src/actions/set-user-supporter-status.ts"] : ["src/**/*.ts"], - exclude: [ - "src/**/*.test.ts", - "src/**/*.bench.ts", - "src/**/*.oldtest.ts", - "src/test-utils/**", - ], + exclude: ["src/**/*.test.ts", "src/**/*.bench.ts", "src/test-utils/**"], ...(coverageSupporterActionOnly ? { thresholds: { diff --git a/typescript/server/vitest.setup.ts b/typescript/server/vitest.setup.ts index c19b7630f..22fd3b120 100644 --- a/typescript/server/vitest.setup.ts +++ b/typescript/server/vitest.setup.ts @@ -87,6 +87,13 @@ async function resetDatabase() { END LOOP; END $$; `.execute(db); + + try { + const { clearGameStatsCacheForTests } = await import("#server/router/api/v1/games/router"); + clearGameStatsCacheForTests(); + } catch { + // ignore — router not loaded in edge test contexts + } } beforeAll(async () => { @@ -115,23 +122,20 @@ beforeEach(async (ctx) => { ClearTestingRateLimitCache(); }); -afterAll( - async () => { - try { - const { CloseServerConnection } = await import("#test-utils/mock-api"); - await CloseServerConnection(); - } catch { - // No mock HTTP server in this worker, or close failed. - } +afterAll(async () => { + try { + const { CloseServerConnection } = await import("#test-utils/mock-api"); + await CloseServerConnection(); + } catch { + // No mock HTTP server in this worker, or close failed. + } - try { - const { ClosePgConnection } = await import("#services/pg/db"); - await ClosePgConnection(); - } catch { - // Pool may not have been initialised if no test ran a query. - } + try { + const { ClosePgConnection } = await import("#services/pg/db"); + await ClosePgConnection(); + } catch { + // Pool may not have been initialised if no test ran a query. + } - await dropWorkerDatabase(); - }, - process.env.CI ? 60_000 : undefined, -); + await dropWorkerDatabase(); +}, 60_000); diff --git a/typescript/sieglinde/package.json b/typescript/sieglinde/package.json index 92c41f5a7..9f8352205 100644 --- a/typescript/sieglinde/package.json +++ b/typescript/sieglinde/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@typescript/native-preview": "catalog:", + "@vitest/coverage-v8": "catalog:", "eslint-config-tachi": "workspace:*", "vitest": "catalog:" }, diff --git a/typescript/sieglinde/src/calc/util/calc.ts b/typescript/sieglinde/src/calc/util/calc.ts index 8afd8e7ff..23f404a17 100644 --- a/typescript/sieglinde/src/calc/util/calc.ts +++ b/typescript/sieglinde/src/calc/util/calc.ts @@ -1,8 +1,7 @@ +import logger from "#logger"; import { mkdirSync, writeFileSync } from "fs"; import path from "path"; -import logger from "../../logger"; - type MD5 = string; type UserId = number; diff --git a/typescript/sieglinde/src/calc/util/lerp.ts b/typescript/sieglinde/src/calc/util/lerp.ts index 1dfd20335..ca009f18d 100644 --- a/typescript/sieglinde/src/calc/util/lerp.ts +++ b/typescript/sieglinde/src/calc/util/lerp.ts @@ -36,7 +36,7 @@ import type { BMSTablesDataset } from "#types"; -import { GetBaseline } from "../../util"; +import { GetBaseline } from "#util"; export type EcConstants = Array<{ averageSigma: number; diff --git a/typescript/sieglinde/src/fetch-tables.ts b/typescript/sieglinde/src/fetch-tables.ts index 5bfb3e931..dc3ed3832 100644 --- a/typescript/sieglinde/src/fetch-tables.ts +++ b/typescript/sieglinde/src/fetch-tables.ts @@ -1,6 +1,6 @@ import TableValueGetters from "#lookups"; import { type BMSTableEntryMD5, LoadBMSTable } from "bms-table-loader"; -import { BMS_TABLES, type Playtypes } from "tachi-common"; +import { BMS_TABLES, type BMSGames } from "tachi-common"; import type { BMSTablesDataset } from "./types"; @@ -9,13 +9,11 @@ export interface TableRes { charts: Array; } -export default async function GetTableData( - forPlaytype: Playtypes["bms"], -): Promise> { +export default async function GetTableData(forGame: BMSGames): Promise> { const out = []; for (const table of BMS_TABLES.filter( - (e) => e.name in TableValueGetters && e.playtype === forPlaytype, + (e) => e.name in TableValueGetters && e.game === forGame, )) { // eslint-disable-next-line no-await-in-loop const bmsTable = await LoadBMSTable(table.url); diff --git a/typescript/sieglinde/src/main.ts b/typescript/sieglinde/src/main.ts index 17f807161..e16d850be 100644 --- a/typescript/sieglinde/src/main.ts +++ b/typescript/sieglinde/src/main.ts @@ -25,7 +25,17 @@ void (async () => { fs.mkdirSync(`${__dirname}/cache`, { recursive: true }); - const tableInfo = await GetTableData(options.playtype as "7K" | "14K"); + const PLAYTYPE_TO_GAME: Record = { + "7K": "bms-7k", + "14K": "bms-14k", + }; + const game = PLAYTYPE_TO_GAME[options.playtype]; + + if (!game) { + throw new Error(`Unknown playtype '${options.playtype}'. Expected '7K' or '14K'.`); + } + + const tableInfo = await GetTableData(game); logger.info(`Starting...`); diff --git a/typescript/sieglinde/src/scripts/fetch-chart-data.ts b/typescript/sieglinde/src/scripts/fetch-chart-data.ts index 74b928d4d..52e6133e9 100644 --- a/typescript/sieglinde/src/scripts/fetch-chart-data.ts +++ b/typescript/sieglinde/src/scripts/fetch-chart-data.ts @@ -1,10 +1,10 @@ -import type { Playtypes } from "tachi-common"; +import type { BMSGames } from "tachi-common"; import GetTableData from "../fetch-tables"; import { ChunkifyPromiseAll, GetScoresForMD5 } from "../util"; -export default async function FetchAllTableScores(playtype: Playtypes["bms"]) { - const tableInfo = await GetTableData(playtype); +export default async function FetchAllTableScores(game: BMSGames) { + const tableInfo = await GetTableData(game); const promises = []; @@ -18,6 +18,6 @@ export default async function FetchAllTableScores(playtype: Playtypes["bms"]) { } if (require.main === module) { - void FetchAllTableScores("7K"); - void FetchAllTableScores("14K"); + void FetchAllTableScores("bms-7k"); + void FetchAllTableScores("bms-14k"); } diff --git a/typescript/sieglinde/src/types.ts b/typescript/sieglinde/src/types.ts index c4440aed2..dc1f2a6d1 100644 --- a/typescript/sieglinde/src/types.ts +++ b/typescript/sieglinde/src/types.ts @@ -1,8 +1,10 @@ +import type { BMSGames } from "tachi-common"; + export interface BMSTablesDataset { url: string; name: string; description: string; - playtype: "7K" | "14K"; + game: BMSGames; prefix: string; } diff --git a/typescript/sieglinde/vitest.config.ts b/typescript/sieglinde/vitest.config.ts index 7dd1e403e..62c7fc341 100644 --- a/typescript/sieglinde/vitest.config.ts +++ b/typescript/sieglinde/vitest.config.ts @@ -3,5 +3,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { passWithNoTests: true, + + coverage: { + enabled: true, + provider: "v8", + reporter: ["text", "html", "clover", "json", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, }, });