From acea12ec8a78eb59f99bb32cc42aba4d0ff59ef4 Mon Sep 17 00:00:00 2001 From: zk Date: Sat, 16 May 2026 19:53:23 +0000 Subject: [PATCH] dasdatmeespresso --- Justfile-db | 12 +- bun.lock | 113 ++ .../20260516120000_quest_proposals.sql | 14 + ...60516190000_quest_submitter_permission.sql | 5 + db/seeds/charts-iidx-dp.json | 748 +++++----- db/seeds/charts-iidx-sp.json | 880 ++++++----- db/seeds/goals.json | 1289 ++++++++--------- db/seeds/questlines.json | 6 +- db/seeds/quests.json | 57 +- docs/docs/api/routes/import.md | 2 +- typescript/bot/src/config.ts | 4 +- typescript/bot/vitest.setup.ts | 1 + .../dashboard/proposals/MyProposalsPage.tsx | 220 +++ .../dashboard/proposals/ProposalsPage.tsx | 153 ++ .../pages/dashboard/users/UserOrphansPage.tsx | 2 +- .../_game/_playtype/targets/UGPTGoalsPage.tsx | 14 +- .../app/pages/dashboard/utils/QuestEditor.tsx | 576 +++++++- .../app/pages/dashboard/utils/SeedsViewer.tsx | 13 - .../client/src/app/routes/DashboardRoutes.tsx | 32 + .../client/src/app/routes/UtilRoutes.tsx | 10 - .../components/game/targets/QuestsPage.tsx | 14 +- .../src/components/imports/ImportInfo.tsx | 2 +- .../components/layout/header/HeaderMenu.tsx | 27 +- .../layout/header/UtilsDropdown.tsx | 57 - .../tables/seeds/SeedsQuestTable.tsx | 20 +- .../tables/seeds/SeedsQuestlineTable.tsx | 22 +- .../src/components/targets/GoalBuilder.tsx | 20 +- .../targets/QuestProgressBar.module.scss | 69 + .../components/targets/QuestProgressBar.tsx | 33 + .../src/components/targets/Questline.tsx | 67 +- .../src/components/targets/quests/Quest.tsx | 87 +- .../targets/quests/editor/EditableQuest.tsx | 19 +- .../client/src/components/user/UserHeader.tsx | 50 +- typescript/client/src/types/tachi.ts | 8 +- typescript/common/src/types.ts | 2 + typescript/common/src/types/documents.ts | 1 + .../common/src/types/seeds-documents-zod.ts | 9 +- typescript/db/src/generated/index.ts | 1 + typescript/db/src/generated/public/Account.ts | 2 + .../db/src/generated/public/PublicSchema.ts | 3 + .../db/src/generated/public/QuestProposal.ts | 35 + typescript/github-bot/src/config.ts | 11 + typescript/github-bot/src/main.ts | 121 +- .../rerunners/import-raw-quests.ts | 4 +- .../v3/7-remap-goals-folder-and-chart-ids.ts | 19 +- typescript/seeds-webui/src/App.tsx | 2 + typescript/seeds-webui/src/pages/Diff.tsx | 9 +- .../seeds-webui/src/pages/QuestProposalPR.tsx | 501 +++++++ typescript/seeds-webui/src/styles/app.scss | 350 +++++ typescript/server/.env | 7 + typescript/server/package.json | 5 +- .../set-user-quest-submitter-status.ts | 29 + .../actions/update-goal-subscription.test.ts | 2 +- .../src/actions/update-goal-subscription.ts | 5 +- typescript/server/src/lib/actions/actions.ts | 7 + typescript/server/src/lib/db-formats/user.ts | 2 + .../server/src/lib/jobs/cron/cron-registry.ts | 2 +- .../server/src/lib/proposals/convert.ts | 203 +++ typescript/server/src/lib/proposals/github.ts | 221 +++ typescript/server/src/lib/setup/config.ts | 40 + .../server/src/scripts/load-seeds-pg.ts | 14 +- .../server/src/scripts/migrate-to-postgres.ts | 2 +- typescript/server/src/scripts/run-action.ts | 4 +- .../src/server/router/api/v1/admin/router.ts | 33 + .../_playtype/targets/goals/router.test.ts | 129 +- .../_playtype/targets/quests/router.test.ts | 52 +- .../server/router/api/v1/proposals/router.ts | 630 ++++++++ .../server/src/server/router/api/v1/router.ts | 1 + .../server/src/server/router/api/v1/spec.ts | 114 ++ .../_playtype/targets/goals/router.test.ts | 88 +- .../_game/_playtype/targets/goals/router.ts | 4 +- .../_playtype/targets/quests/router.test.ts | 4 +- typescript/server/src/services/pg/seeds.ts | 24 +- 73 files changed, 5248 insertions(+), 2090 deletions(-) create mode 100644 db/migrations/20260516120000_quest_proposals.sql create mode 100644 db/migrations/20260516190000_quest_submitter_permission.sql create mode 100644 typescript/client/src/app/pages/dashboard/proposals/MyProposalsPage.tsx create mode 100644 typescript/client/src/app/pages/dashboard/proposals/ProposalsPage.tsx delete mode 100644 typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx delete mode 100644 typescript/client/src/components/layout/header/UtilsDropdown.tsx create mode 100644 typescript/client/src/components/targets/QuestProgressBar.module.scss create mode 100644 typescript/client/src/components/targets/QuestProgressBar.tsx create mode 100644 typescript/db/src/generated/public/QuestProposal.ts create mode 100644 typescript/seeds-webui/src/pages/QuestProposalPR.tsx create mode 100644 typescript/server/src/actions/set-user-quest-submitter-status.ts create mode 100644 typescript/server/src/lib/proposals/convert.ts create mode 100644 typescript/server/src/lib/proposals/github.ts create mode 100644 typescript/server/src/server/router/api/v1/proposals/router.ts diff --git a/Justfile-db b/Justfile-db index 09c30f812..3bca98824 100644 --- a/Justfile-db +++ b/Justfile-db @@ -33,8 +33,16 @@ db-migrate-status POSTGRES_DB=DEFAULT_DB: # Load the seeds into the database. db-load-seeds POSTGRES_DB=DEFAULT_DB: - cd typescript/server && POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB}}" \ - bun run src/scripts/load-seeds-pg.ts + #!/bin/bash + set -euo pipefail + cd typescript/server + export POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB}}" + export SEEDS_DIR="../../db/seeds" + if [[ -z "${SEEDS_COMMIT_HASH:-}" ]]; then + SEEDS_COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo local)" + fi + export SEEDS_COMMIT_HASH + bun run src/scripts/load-seeds-pg.ts # Reset templatedb from migrations, then dump schema and indexes for local debugging. db-schema: (db-cheap-reset "templatedb") diff --git a/bun.lock b/bun.lock index aea291667..2172e92e8 100644 --- a/bun.lock +++ b/bun.lock @@ -347,6 +347,7 @@ "version": "3.0.0", "dependencies": { "@aws-sdk/client-s3": "catalog:", + "@octokit/app": "^16.1.2", "@types/bcryptjs": "catalog:", "@types/bunyan": "catalog:", "@types/connect-redis": "catalog:", @@ -2231,6 +2232,8 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -3383,6 +3386,8 @@ "superagent/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], + "tachi-server/@octokit/app": ["@octokit/app@16.1.2", "", { "dependencies": { "@octokit/auth-app": "^8.1.2", "@octokit/auth-unauthenticated": "^7.0.3", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -3507,6 +3512,20 @@ "serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "tachi-server/@octokit/app/@octokit/auth-app": ["@octokit/auth-app@8.2.0", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g=="], + + "tachi-server/@octokit/app/@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@7.0.3", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g=="], + + "tachi-server/@octokit/app/@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "tachi-server/@octokit/app/@octokit/oauth-app": ["@octokit/oauth-app@8.0.3", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.2", "@octokit/auth-oauth-user": "^6.0.1", "@octokit/auth-unauthenticated": "^7.0.2", "@octokit/core": "^7.0.5", "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/oauth-methods": "^6.0.1", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg=="], + + "tachi-server/@octokit/app/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "tachi-server/@octokit/app/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "tachi-server/@octokit/app/@octokit/webhooks": ["@octokit/webhooks@14.2.0", "", { "dependencies": { "@octokit/openapi-webhooks-types": "12.1.0", "@octokit/request-error": "^7.0.0", "@octokit/webhooks-methods": "^6.0.0" } }, "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw=="], + "test-exclude/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "test-exclude/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -3553,6 +3572,42 @@ "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.2", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "tachi-server/@octokit/app/@octokit/auth-unauthenticated/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "tachi-server/@octokit/app/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "tachi-server/@octokit/app/@octokit/core/@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "tachi-server/@octokit/app/@octokit/core/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="], + + "tachi-server/@octokit/app/@octokit/core/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "tachi-server/@octokit/app/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.2", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="], + + "tachi-server/@octokit/app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "tachi-server/@octokit/app/@octokit/webhooks/@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@12.1.0", "", {}, "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA=="], + + "tachi-server/@octokit/app/@octokit/webhooks/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "tachi-server/@octokit/app/@octokit/webhooks/@octokit/webhooks-methods": ["@octokit/webhooks-methods@6.0.0", "", {}, "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ=="], + "test-exclude/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "test-exclude/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -3577,6 +3632,64 @@ "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-app/@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-user/@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-user/@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "tachi-server/@octokit/app/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "tachi-server/@octokit/app/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "tachi-server/@octokit/app/@octokit/core/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-app/@octokit/auth-oauth-device/@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-user/@octokit/oauth-methods/@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-app/@octokit/auth-oauth-device/@octokit/oauth-methods/@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="], } } diff --git a/db/migrations/20260516120000_quest_proposals.sql b/db/migrations/20260516120000_quest_proposals.sql new file mode 100644 index 000000000..de0a71ac9 --- /dev/null +++ b/db/migrations/20260516120000_quest_proposals.sql @@ -0,0 +1,14 @@ +CREATE TABLE "quest_proposal" ( + row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(), + user_id BIGINT REFERENCES account(id) NOT NULL, + github_pr_number INT NOT NULL, + github_branch TEXT NOT NULL, + -- Serialised RawQuestDocument[] (the editor's inline-goal format) + raw_quests JSONB NOT NULL, + -- Serialised RawQuestlineDocument[] (may be empty array) + raw_questlines JSONB NOT NULL DEFAULT '[]', + -- 'open' | 'merged' | 'closed' + status TEXT NOT NULL DEFAULT 'open', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/db/migrations/20260516190000_quest_submitter_permission.sql b/db/migrations/20260516190000_quest_submitter_permission.sql new file mode 100644 index 000000000..b428b83d3 --- /dev/null +++ b/db/migrations/20260516190000_quest_submitter_permission.sql @@ -0,0 +1,5 @@ +-- Add a permission flag allowing users to submit quest proposals to the repo. +-- Users must apply (via Discord) and be approved by an admin before they can +-- open PRs via the quest editor. + +ALTER TABLE account ADD COLUMN can_submit_quests BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/db/seeds/charts-iidx-dp.json b/db/seeds/charts-iidx-dp.json index d31cc0efe..8d1aff91a 100644 --- a/db/seeds/charts-iidx-dp.json +++ b/db/seeds/charts-iidx-dp.json @@ -85090,11 +85090,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b1c58e59cb38", - "versions": [ - "26-omni", - "inf", - "15-cs" - ] + "versions": ["26-omni", "inf", "15-cs"] }, { "data": { @@ -85154,11 +85150,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b1c58e59cb38", - "versions": [ - "26-omni", - "inf", - "15-cs" - ] + "versions": ["26-omni", "inf", "15-cs"] }, { "data": { @@ -276398,7 +276390,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8313392e0f7", - "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni","inf"] + "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni", "inf"] }, { "data": { @@ -276417,7 +276409,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b8313392e0f7", - "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni","inf"] + "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni", "inf"] }, { "data": { @@ -276436,7 +276428,7 @@ "level": "3", "levelNum": 3, "songID": "S19d35e0b8313392e0f7", - "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni","inf"] + "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni", "inf"] }, { "data": { @@ -277206,7 +277198,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b83ffe31e5a3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277225,7 +277217,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b83ffe31e5a3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277244,7 +277236,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b83ffe31e5a3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277263,7 +277255,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b840586a99e3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277282,7 +277274,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b840586a99e3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277301,7 +277293,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b840586a99e3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277320,7 +277312,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8419eef82dc", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277339,7 +277331,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b8419eef82dc", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277358,7 +277350,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8419eef82dc", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277377,7 +277369,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b842bcc6e314", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277396,7 +277388,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b842bcc6e314", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277415,7 +277407,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b842bcc6e314", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277434,7 +277426,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b84375882440", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277453,7 +277445,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b84375882440", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277472,7 +277464,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b84375882440", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277491,7 +277483,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b844940af188", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277510,7 +277502,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b844940af188", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277529,7 +277521,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b844940af188", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277548,7 +277540,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b8454d110449", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277567,7 +277559,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8454d110449", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277586,7 +277578,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8454d110449", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277776,7 +277768,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b849169a3c33", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277795,7 +277787,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b849169a3c33", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277814,7 +277806,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b849169a3c33", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277833,7 +277825,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b84a0bc1cd28", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277852,7 +277844,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b84a0bc1cd28", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277871,7 +277863,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b84a0bc1cd28", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277890,7 +277882,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b84b6061a089", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277909,7 +277901,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b84b6061a089", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277928,7 +277920,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b84b6061a089", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277947,7 +277939,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b84c128286aa", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277966,7 +277958,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b84c128286aa", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -277985,7 +277977,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b84c128286aa", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278004,7 +277996,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b84dfe34d5f5", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278023,7 +278015,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b84dfe34d5f5", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278042,7 +278034,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b84dfe34d5f5", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278061,7 +278053,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b84e38fcb096", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278080,7 +278072,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b84e38fcb096", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278099,7 +278091,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b84e38fcb096", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278118,7 +278110,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b84fcdfdfbde", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278137,7 +278129,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b84fcdfdfbde", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278156,7 +278148,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b84fcdfdfbde", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278175,7 +278167,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85010d9835a", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278194,7 +278186,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b85010d9835a", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278213,7 +278205,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b85010d9835a", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278232,7 +278224,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b8510245bda9", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278251,7 +278243,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8510245bda9", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278270,7 +278262,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b8510245bda9", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278346,7 +278338,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b85351f9d027", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278365,7 +278357,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b85351f9d027", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278384,7 +278376,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85351f9d027", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278460,7 +278452,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b855a9fc33fe", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278479,7 +278471,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b855a9fc33fe", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278498,7 +278490,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b855a9fc33fe", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278517,7 +278509,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b856eb3e6801", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278536,7 +278528,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b856eb3e6801", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278555,7 +278547,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b856eb3e6801", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278745,7 +278737,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85a2656ad1b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278764,7 +278756,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b85a2656ad1b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278783,7 +278775,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b85a2656ad1b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278802,7 +278794,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b85b73bf8076", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278821,7 +278813,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85b73bf8076", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278840,7 +278832,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85b73bf8076", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278859,7 +278851,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b85cee950e11", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278878,7 +278870,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85cee950e11", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278897,7 +278889,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85cee950e11", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278916,7 +278908,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b85d3a72d79f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278935,7 +278927,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b85d3a72d79f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278954,7 +278946,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b85d3a72d79f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278973,7 +278965,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b85e1c70a102", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -278992,7 +278984,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85e1c70a102", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279011,7 +279003,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85e1c70a102", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279030,7 +279022,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b85f4400849d", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279049,7 +279041,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85f4400849d", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279068,7 +279060,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85f4400849d", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279144,7 +279136,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b861006121ef", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279163,7 +279155,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b861006121ef", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279182,7 +279174,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b861006121ef", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279315,7 +279307,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b86444685b92", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279334,7 +279326,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b86444685b92", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279353,7 +279345,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b86444685b92", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279372,7 +279364,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b8658b46a23f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279391,7 +279383,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8658b46a23f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279410,7 +279402,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8658b46a23f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279429,7 +279421,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b8664bd4977b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279448,7 +279440,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8664bd4977b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279467,7 +279459,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b8664bd4977b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279486,7 +279478,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b867b447ab67", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279505,7 +279497,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b867b447ab67", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -279524,7 +279516,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b867b447ab67", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281196,7 +281188,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b8859bba6fdb", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281215,7 +281207,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b8859bba6fdb", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281234,7 +281226,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8859bba6fdb", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281424,7 +281416,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b88987fbe994", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281443,7 +281435,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b88987fbe994", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281462,7 +281454,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b88987fbe994", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281538,7 +281530,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b88b459fc5ca", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281557,7 +281549,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b88b459fc5ca", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281576,7 +281568,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b88b459fc5ca", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281823,7 +281815,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b89024fd51c2", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281842,7 +281834,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b89024fd51c2", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281861,7 +281853,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b89024fd51c2", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281880,7 +281872,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8913ae2b369", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281899,7 +281891,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b8913ae2b369", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -281918,7 +281910,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b8913ae2b369", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284255,7 +284247,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8bbc7f0070c", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -284274,7 +284266,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b8bbc7f0070c", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -284293,7 +284285,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b8bbc7f0070c", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -284369,7 +284361,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b8bd72e72fdb", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -284388,7 +284380,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b8bd72e72fdb", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -284407,7 +284399,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8bd72e72fdb", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -285680,7 +285672,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8d772896514", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -285699,7 +285691,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b8d772896514", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -285718,7 +285710,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b8d772896514", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -691618,279 +691610,231 @@ "versions": ["inf"] }, { - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80106 - ], - "kaidenAverage": null, - "notecount": 1683, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C19754f0212e3e79073a", - "isPrimary": true, - "legacyChartID": "cde1e46b263a509d1daf68dd23dfd9b94fbcce11", - "level": "12", - "levelNum": 12, - "songID": "S197d195e6bc5c67322b", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80106 - ], - "kaidenAverage": null, - "notecount": 1079, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C194902dd1c12c6aa6d8", - "isPrimary": true, - "legacyChartID": "ce18e30970912cf148bb79ebbb3b3b2b18238a6a", - "level": "10", - "levelNum": 10, - "songID": "S197d195e6bc5c67322b", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80106 - ], - "kaidenAverage": null, - "notecount": 592, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C19b8f9a0a45b38a9e28", - "isPrimary": true, - "legacyChartID": "b6cca27098fbbb61c6ba406eae135e33dc75f007", - "level": "6", - "levelNum": 6, - "songID": "S197d195e6bc5c67322b", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80107 - ], - "kaidenAverage": null, - "notecount": 901, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C193b6b9eda6b24ac143", - "isPrimary": true, - "legacyChartID": "4f2b0fb6aeb2c93ff12aabaee1d6ef63447efabd", - "level": "10", - "levelNum": 10, - "songID": "S1926faf31a560a24ff2", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80107 - ], - "kaidenAverage": null, - "notecount": 584, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C199546ce5ef20d74246", - "isPrimary": true, - "legacyChartID": "049b57dd77377d35ccfa8af9b8fc1bd6d82fd841", - "level": "7", - "levelNum": 7, - "songID": "S1926faf31a560a24ff2", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80107 - ], - "kaidenAverage": null, - "notecount": 331, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C1988554ca12ea41f9f3", - "isPrimary": true, - "legacyChartID": "a00784793a3cb193f0f52787e7977a940ddbfd00", - "level": "4", - "levelNum": 4, - "songID": "S1926faf31a560a24ff2", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80103 - ], - "kaidenAverage": null, - "notecount": 1080, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C19382b3eb98648cbd98", - "isPrimary": true, - "legacyChartID": "58b46f07cbf4d1b1baab329f569a77dde0ccd086", - "level": "10", - "levelNum": 10, - "songID": "S19e0d59cbb93a5fb456", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80103 - ], - "kaidenAverage": null, - "notecount": 677, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C195f00900f43277792f", - "isPrimary": true, - "legacyChartID": "4068551420152e0c3ab9171b62ca020c38c2c6c6", - "level": "7", - "levelNum": 7, - "songID": "S19e0d59cbb93a5fb456", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80103 - ], - "kaidenAverage": null, - "notecount": 351, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C19b6fc56a0105690e7b", - "isPrimary": true, - "legacyChartID": "aa04e041a7a1d3570a3e0b5f47e6e4322996473b", - "level": "4", - "levelNum": 4, - "songID": "S19e0d59cbb93a5fb456", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80105 - ], - "kaidenAverage": null, - "notecount": 1634, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C193f85902fcc19669d7", - "isPrimary": true, - "legacyChartID": "c7a6af5305808161fdc2ef9cb2b728c66039e5f4", - "level": "12", - "levelNum": 12, - "songID": "S1911d5ffc9ecfd1aa1c", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80105 - ], - "kaidenAverage": null, - "notecount": 1050, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C19f5c935f24b3bbbf05", - "isPrimary": true, - "legacyChartID": "fdfe8bf8558871ebacd921f45cd5083fd438a00e", - "level": "10", - "levelNum": 10, - "songID": "S1911d5ffc9ecfd1aa1c", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80105 - ], - "kaidenAverage": null, - "notecount": 632, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C197545513727b0b8b4f", - "isPrimary": true, - "legacyChartID": "c298b25cae92d2062fefe980b4f966555f94d2fb", - "level": "6", - "levelNum": 6, - "songID": "S1911d5ffc9ecfd1aa1c", - "versions": [ - "inf" - ] -} + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80106], + "kaidenAverage": null, + "notecount": 1683, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C19754f0212e3e79073a", + "isPrimary": true, + "legacyChartID": "cde1e46b263a509d1daf68dd23dfd9b94fbcce11", + "level": "12", + "levelNum": 12, + "songID": "S197d195e6bc5c67322b", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80106], + "kaidenAverage": null, + "notecount": 1079, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C194902dd1c12c6aa6d8", + "isPrimary": true, + "legacyChartID": "ce18e30970912cf148bb79ebbb3b3b2b18238a6a", + "level": "10", + "levelNum": 10, + "songID": "S197d195e6bc5c67322b", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80106], + "kaidenAverage": null, + "notecount": 592, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C19b8f9a0a45b38a9e28", + "isPrimary": true, + "legacyChartID": "b6cca27098fbbb61c6ba406eae135e33dc75f007", + "level": "6", + "levelNum": 6, + "songID": "S197d195e6bc5c67322b", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80107], + "kaidenAverage": null, + "notecount": 901, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C193b6b9eda6b24ac143", + "isPrimary": true, + "legacyChartID": "4f2b0fb6aeb2c93ff12aabaee1d6ef63447efabd", + "level": "10", + "levelNum": 10, + "songID": "S1926faf31a560a24ff2", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80107], + "kaidenAverage": null, + "notecount": 584, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C199546ce5ef20d74246", + "isPrimary": true, + "legacyChartID": "049b57dd77377d35ccfa8af9b8fc1bd6d82fd841", + "level": "7", + "levelNum": 7, + "songID": "S1926faf31a560a24ff2", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80107], + "kaidenAverage": null, + "notecount": 331, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C1988554ca12ea41f9f3", + "isPrimary": true, + "legacyChartID": "a00784793a3cb193f0f52787e7977a940ddbfd00", + "level": "4", + "levelNum": 4, + "songID": "S1926faf31a560a24ff2", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80103], + "kaidenAverage": null, + "notecount": 1080, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C19382b3eb98648cbd98", + "isPrimary": true, + "legacyChartID": "58b46f07cbf4d1b1baab329f569a77dde0ccd086", + "level": "10", + "levelNum": 10, + "songID": "S19e0d59cbb93a5fb456", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80103], + "kaidenAverage": null, + "notecount": 677, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C195f00900f43277792f", + "isPrimary": true, + "legacyChartID": "4068551420152e0c3ab9171b62ca020c38c2c6c6", + "level": "7", + "levelNum": 7, + "songID": "S19e0d59cbb93a5fb456", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80103], + "kaidenAverage": null, + "notecount": 351, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C19b6fc56a0105690e7b", + "isPrimary": true, + "legacyChartID": "aa04e041a7a1d3570a3e0b5f47e6e4322996473b", + "level": "4", + "levelNum": 4, + "songID": "S19e0d59cbb93a5fb456", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80105], + "kaidenAverage": null, + "notecount": 1634, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C193f85902fcc19669d7", + "isPrimary": true, + "legacyChartID": "c7a6af5305808161fdc2ef9cb2b728c66039e5f4", + "level": "12", + "levelNum": 12, + "songID": "S1911d5ffc9ecfd1aa1c", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80105], + "kaidenAverage": null, + "notecount": 1050, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C19f5c935f24b3bbbf05", + "isPrimary": true, + "legacyChartID": "fdfe8bf8558871ebacd921f45cd5083fd438a00e", + "level": "10", + "levelNum": 10, + "songID": "S1911d5ffc9ecfd1aa1c", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80105], + "kaidenAverage": null, + "notecount": 632, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C197545513727b0b8b4f", + "isPrimary": true, + "legacyChartID": "c298b25cae92d2062fefe980b4f966555f94d2fb", + "level": "6", + "levelNum": 6, + "songID": "S1911d5ffc9ecfd1aa1c", + "versions": ["inf"] + } ] diff --git a/db/seeds/charts-iidx-sp.json b/db/seeds/charts-iidx-sp.json index bb4bbe54f..8238e0eab 100644 --- a/db/seeds/charts-iidx-sp.json +++ b/db/seeds/charts-iidx-sp.json @@ -90414,11 +90414,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b1c58e59cb38", - "versions": [ - "26-omni", - "inf", - "15-cs" - ] + "versions": ["26-omni", "inf", "15-cs"] }, { "data": { @@ -90473,11 +90469,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b1c58e59cb38", - "versions": [ - "26-omni", - "inf", - "15-cs" - ] + "versions": ["26-omni", "inf", "15-cs"] }, { "data": { @@ -90535,11 +90527,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b1c58e59cb38", - "versions": [ - "26-omni", - "inf", - "15-cs" - ] + "versions": ["26-omni", "inf", "15-cs"] }, { "data": { @@ -281649,7 +281637,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8313392e0f7", - "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni","inf"] + "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni", "inf"] }, { "data": { @@ -281668,7 +281656,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b8313392e0f7", - "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni","inf"] + "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni", "inf"] }, { "data": { @@ -281687,7 +281675,7 @@ "level": "3", "levelNum": 3, "songID": "S19d35e0b8313392e0f7", - "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni","inf"] + "versions": ["31", "31-omni", "32", "31-2dxtra", "33", "32-omni", "inf"] }, { "data": { @@ -282482,7 +282470,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b83ffe31e5a3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282501,7 +282489,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b83ffe31e5a3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282520,7 +282508,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b83ffe31e5a3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282539,7 +282527,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b840586a99e3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282558,7 +282546,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b840586a99e3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282577,7 +282565,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b840586a99e3", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282596,7 +282584,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8419eef82dc", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282615,7 +282603,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b8419eef82dc", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282634,7 +282622,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8419eef82dc", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282663,7 +282651,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b842bcc6e314", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282682,7 +282670,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b842bcc6e314", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282701,7 +282689,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b842bcc6e314", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282730,7 +282718,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b84375882440", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282749,7 +282737,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b84375882440", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282768,7 +282756,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b84375882440", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282787,7 +282775,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b844940af188", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282806,7 +282794,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b844940af188", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282825,7 +282813,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b844940af188", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282844,7 +282832,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b8454d110449", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282863,7 +282851,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8454d110449", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -282882,7 +282870,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8454d110449", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283072,7 +283060,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b849169a3c33", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283091,7 +283079,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b849169a3c33", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283110,7 +283098,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b849169a3c33", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283139,7 +283127,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b84a0bc1cd28", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283158,7 +283146,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b84a0bc1cd28", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283177,7 +283165,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b84a0bc1cd28", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283196,7 +283184,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b84b6061a089", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283215,7 +283203,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b84b6061a089", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283234,7 +283222,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b84b6061a089", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283263,7 +283251,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b84c128286aa", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283282,7 +283270,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b84c128286aa", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283301,7 +283289,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b84c128286aa", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283320,7 +283308,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b84dfe34d5f5", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283339,7 +283327,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b84dfe34d5f5", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283358,7 +283346,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b84dfe34d5f5", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283387,7 +283375,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b84e38fcb096", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283406,7 +283394,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b84e38fcb096", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283425,7 +283413,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b84e38fcb096", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283444,7 +283432,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b84fcdfdfbde", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283463,7 +283451,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b84fcdfdfbde", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283482,7 +283470,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b84fcdfdfbde", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283501,7 +283489,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85010d9835a", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283520,7 +283508,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b85010d9835a", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283539,7 +283527,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b85010d9835a", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283568,7 +283556,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b8510245bda9", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283587,7 +283575,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8510245bda9", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283606,7 +283594,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b8510245bda9", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283682,7 +283670,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b85351f9d027", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283701,7 +283689,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b85351f9d027", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283720,7 +283708,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85351f9d027", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283796,7 +283784,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b855a9fc33fe", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283815,7 +283803,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b855a9fc33fe", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283834,7 +283822,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b855a9fc33fe", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283853,7 +283841,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b856eb3e6801", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283872,7 +283860,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b856eb3e6801", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -283891,7 +283879,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b856eb3e6801", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284081,7 +284069,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85a2656ad1b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284100,7 +284088,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b85a2656ad1b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284119,7 +284107,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b85a2656ad1b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284148,7 +284136,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b85b73bf8076", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284167,7 +284155,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85b73bf8076", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284186,7 +284174,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85b73bf8076", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284215,7 +284203,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b85cee950e11", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284234,7 +284222,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85cee950e11", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284253,7 +284241,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85cee950e11", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284272,7 +284260,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b85d3a72d79f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284291,7 +284279,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b85d3a72d79f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284310,7 +284298,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b85d3a72d79f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284339,7 +284327,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b85e1c70a102", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284358,7 +284346,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85e1c70a102", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284377,7 +284365,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85e1c70a102", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284406,7 +284394,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b85f4400849d", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284425,7 +284413,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b85f4400849d", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284444,7 +284432,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b85f4400849d", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284520,7 +284508,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b861006121ef", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284539,7 +284527,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b861006121ef", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284558,7 +284546,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b861006121ef", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284701,7 +284689,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b86444685b92", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284720,7 +284708,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b86444685b92", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284739,7 +284727,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b86444685b92", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284758,7 +284746,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b8658b46a23f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284777,7 +284765,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8658b46a23f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284796,7 +284784,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8658b46a23f", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284825,7 +284813,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b8664bd4977b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284844,7 +284832,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8664bd4977b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284863,7 +284851,7 @@ "level": "6", "levelNum": 6, "songID": "S19d35e0b8664bd4977b", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284882,7 +284870,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b867b447ab67", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284901,7 +284889,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b867b447ab67", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -284920,7 +284908,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b867b447ab67", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -286637,7 +286625,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8859bba6fdb", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -286656,7 +286644,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b8859bba6fdb", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -286675,7 +286663,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8859bba6fdb", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -286885,7 +286873,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b88987fbe994", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -286904,7 +286892,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b88987fbe994", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -286923,7 +286911,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b88987fbe994", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287009,7 +286997,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b88b459fc5ca", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287028,7 +287016,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b88b459fc5ca", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287047,7 +287035,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b88b459fc5ca", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287314,7 +287302,7 @@ "level": "12", "levelNum": 12, "songID": "S19d35e0b89024fd51c2", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287333,7 +287321,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b89024fd51c2", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287352,7 +287340,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b89024fd51c2", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287371,7 +287359,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8913ae2b369", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287390,7 +287378,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b8913ae2b369", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -287409,7 +287397,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b8913ae2b369", - "versions": ["32", "33", "32-omni","inf"] + "versions": ["32", "33", "32-omni", "inf"] }, { "data": { @@ -289746,7 +289734,7 @@ "level": "9", "levelNum": 9, "songID": "S19d35e0b8bbc7f0070c", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -289765,7 +289753,7 @@ "level": "7", "levelNum": 7, "songID": "S19d35e0b8bbc7f0070c", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -289784,7 +289772,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b8bbc7f0070c", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -289860,7 +289848,7 @@ "level": "11", "levelNum": 11, "songID": "S19d35e0b8bd72e72fdb", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -289879,7 +289867,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b8bd72e72fdb", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -289898,7 +289886,7 @@ "level": "5", "levelNum": 5, "songID": "S19d35e0b8bd72e72fdb", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -291171,7 +291159,7 @@ "level": "10", "levelNum": 10, "songID": "S19d35e0b8d772896514", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -291190,7 +291178,7 @@ "level": "8", "levelNum": 8, "songID": "S19d35e0b8d772896514", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -291209,7 +291197,7 @@ "level": "4", "levelNum": 4, "songID": "S19d35e0b8d772896514", - "versions": ["33","inf"] + "versions": ["33", "inf"] }, { "data": { @@ -711777,348 +711765,288 @@ "versions": ["31-2dxtra"] }, { - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80104 - ], - "kaidenAverage": null, - "notecount": 1198, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C19c6520937d8bb464cb", - "isPrimary": true, - "legacyChartID": "b1230b01fc0e096241c18e39e2be940392d717b0", - "level": "11", - "levelNum": 11, - "songID": "S19380f58922f0023afb", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80104 - ], - "kaidenAverage": null, - "notecount": 711, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C1935c909e93c4f3f767", - "isPrimary": true, - "legacyChartID": "08e3b20497771ee6c8f72e6875cf12dc122a5067", - "level": "8", - "levelNum": 8, - "songID": "S19380f58922f0023afb", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80104 - ], - "kaidenAverage": null, - "notecount": 499, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C199c10e77dc088459d9", - "isPrimary": true, - "legacyChartID": "e927a4817906d00881f7cfee2bde35d41372fbac", - "level": "5", - "levelNum": 5, - "songID": "S19380f58922f0023afb", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80106 - ], - "kaidenAverage": null, - "notecount": 1665, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C19100ae88b68bd853c6", - "isPrimary": true, - "legacyChartID": "9515c29db10a2ec77e029a13380eae2b6f4f4b32", - "level": "12", - "levelNum": 12, - "songID": "S197d195e6bc5c67322b", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80106 - ], - "kaidenAverage": null, - "notecount": 1077, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C19c049f8976a0e97b11", - "isPrimary": true, - "legacyChartID": "271f53bc52561042f4d402f41a48198e20acfba7", - "level": "10", - "levelNum": 10, - "songID": "S197d195e6bc5c67322b", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80106 - ], - "kaidenAverage": null, - "notecount": 575, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C19fac5e50070acc48a9", - "isPrimary": true, - "legacyChartID": "a4a898b6580b707bf47b68d0db4066ea78a1b350", - "level": "6", - "levelNum": 6, - "songID": "S197d195e6bc5c67322b", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80107 - ], - "kaidenAverage": null, - "notecount": 988, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C19a71d7149d2de35eef", - "isPrimary": true, - "legacyChartID": "e743fc045d279dcd660f08e75178b32cf767be6b", - "level": "10", - "levelNum": 10, - "songID": "S1926faf31a560a24ff2", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80107 - ], - "kaidenAverage": null, - "notecount": 583, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C191ee447fcac83abf35", - "isPrimary": true, - "legacyChartID": "db86bcf6ec1a076137da99754ca42da1ccd01e3d", - "level": "7", - "levelNum": 7, - "songID": "S1926faf31a560a24ff2", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80107 - ], - "kaidenAverage": null, - "notecount": 292, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C196fff57b45d1d935b6", - "isPrimary": true, - "legacyChartID": "e4b9f5ef0355913488ceb7e53a1e803e44d97ef5", - "level": "4", - "levelNum": 4, - "songID": "S1926faf31a560a24ff2", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80103 - ], - "kaidenAverage": null, - "notecount": 1099, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C19b4d39c7081d46ee25", - "isPrimary": true, - "legacyChartID": "9690c0219c9e7e5266a6c9c2d2a28c6db2e22617", - "level": "10", - "levelNum": 10, - "songID": "S19e0d59cbb93a5fb456", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80103 - ], - "kaidenAverage": null, - "notecount": 646, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C190acd2af841d890fde", - "isPrimary": true, - "legacyChartID": "54aa0208e64d65ea4dbd94a98e83778176ea0ba2", - "level": "7", - "levelNum": 7, - "songID": "S19e0d59cbb93a5fb456", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80103 - ], - "kaidenAverage": null, - "notecount": 341, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C19e3b3c21912e4fb79a", - "isPrimary": true, - "legacyChartID": "80a41e776a65d07ce763ea9fa25baa721a9cd131", - "level": "4", - "levelNum": 4, - "songID": "S19e0d59cbb93a5fb456", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80105 - ], - "kaidenAverage": null, - "notecount": 1577, - "worldRecord": null - }, - "difficulty": "ANOTHER", - "id": "C1979c1f1005a83cc4cd", - "isPrimary": true, - "legacyChartID": "eb9674b4324ebc357d9ac46bc7dce6ed008ff9eb", - "level": "12", - "levelNum": 12, - "songID": "S1911d5ffc9ecfd1aa1c", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80105 - ], - "kaidenAverage": null, - "notecount": 1033, - "worldRecord": null - }, - "difficulty": "HYPER", - "id": "C19d3f37df750124302e", - "isPrimary": true, - "legacyChartID": "7a04b1e2d5927b783b9de08739b8d25f4259b579", - "level": "10", - "levelNum": 10, - "songID": "S1911d5ffc9ecfd1aa1c", - "versions": [ - "inf" - ] -}, -{ - "data": { - "2dxtraSet": null, - "bpiCoefficient": null, - "hashSHA256": null, - "inGameID": [ - 80105 - ], - "kaidenAverage": null, - "notecount": 621, - "worldRecord": null - }, - "difficulty": "NORMAL", - "id": "C19aa634402a90c691fe", - "isPrimary": true, - "legacyChartID": "946520cd18b7b94be05e84c1298e4c6d4f2c8d28", - "level": "6", - "levelNum": 6, - "songID": "S1911d5ffc9ecfd1aa1c", - "versions": [ - "inf" - ] -} + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80104], + "kaidenAverage": null, + "notecount": 1198, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C19c6520937d8bb464cb", + "isPrimary": true, + "legacyChartID": "b1230b01fc0e096241c18e39e2be940392d717b0", + "level": "11", + "levelNum": 11, + "songID": "S19380f58922f0023afb", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80104], + "kaidenAverage": null, + "notecount": 711, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C1935c909e93c4f3f767", + "isPrimary": true, + "legacyChartID": "08e3b20497771ee6c8f72e6875cf12dc122a5067", + "level": "8", + "levelNum": 8, + "songID": "S19380f58922f0023afb", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80104], + "kaidenAverage": null, + "notecount": 499, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C199c10e77dc088459d9", + "isPrimary": true, + "legacyChartID": "e927a4817906d00881f7cfee2bde35d41372fbac", + "level": "5", + "levelNum": 5, + "songID": "S19380f58922f0023afb", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80106], + "kaidenAverage": null, + "notecount": 1665, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C19100ae88b68bd853c6", + "isPrimary": true, + "legacyChartID": "9515c29db10a2ec77e029a13380eae2b6f4f4b32", + "level": "12", + "levelNum": 12, + "songID": "S197d195e6bc5c67322b", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80106], + "kaidenAverage": null, + "notecount": 1077, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C19c049f8976a0e97b11", + "isPrimary": true, + "legacyChartID": "271f53bc52561042f4d402f41a48198e20acfba7", + "level": "10", + "levelNum": 10, + "songID": "S197d195e6bc5c67322b", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80106], + "kaidenAverage": null, + "notecount": 575, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C19fac5e50070acc48a9", + "isPrimary": true, + "legacyChartID": "a4a898b6580b707bf47b68d0db4066ea78a1b350", + "level": "6", + "levelNum": 6, + "songID": "S197d195e6bc5c67322b", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80107], + "kaidenAverage": null, + "notecount": 988, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C19a71d7149d2de35eef", + "isPrimary": true, + "legacyChartID": "e743fc045d279dcd660f08e75178b32cf767be6b", + "level": "10", + "levelNum": 10, + "songID": "S1926faf31a560a24ff2", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80107], + "kaidenAverage": null, + "notecount": 583, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C191ee447fcac83abf35", + "isPrimary": true, + "legacyChartID": "db86bcf6ec1a076137da99754ca42da1ccd01e3d", + "level": "7", + "levelNum": 7, + "songID": "S1926faf31a560a24ff2", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80107], + "kaidenAverage": null, + "notecount": 292, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C196fff57b45d1d935b6", + "isPrimary": true, + "legacyChartID": "e4b9f5ef0355913488ceb7e53a1e803e44d97ef5", + "level": "4", + "levelNum": 4, + "songID": "S1926faf31a560a24ff2", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80103], + "kaidenAverage": null, + "notecount": 1099, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C19b4d39c7081d46ee25", + "isPrimary": true, + "legacyChartID": "9690c0219c9e7e5266a6c9c2d2a28c6db2e22617", + "level": "10", + "levelNum": 10, + "songID": "S19e0d59cbb93a5fb456", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80103], + "kaidenAverage": null, + "notecount": 646, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C190acd2af841d890fde", + "isPrimary": true, + "legacyChartID": "54aa0208e64d65ea4dbd94a98e83778176ea0ba2", + "level": "7", + "levelNum": 7, + "songID": "S19e0d59cbb93a5fb456", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80103], + "kaidenAverage": null, + "notecount": 341, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C19e3b3c21912e4fb79a", + "isPrimary": true, + "legacyChartID": "80a41e776a65d07ce763ea9fa25baa721a9cd131", + "level": "4", + "levelNum": 4, + "songID": "S19e0d59cbb93a5fb456", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80105], + "kaidenAverage": null, + "notecount": 1577, + "worldRecord": null + }, + "difficulty": "ANOTHER", + "id": "C1979c1f1005a83cc4cd", + "isPrimary": true, + "legacyChartID": "eb9674b4324ebc357d9ac46bc7dce6ed008ff9eb", + "level": "12", + "levelNum": 12, + "songID": "S1911d5ffc9ecfd1aa1c", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80105], + "kaidenAverage": null, + "notecount": 1033, + "worldRecord": null + }, + "difficulty": "HYPER", + "id": "C19d3f37df750124302e", + "isPrimary": true, + "legacyChartID": "7a04b1e2d5927b783b9de08739b8d25f4259b579", + "level": "10", + "levelNum": 10, + "songID": "S1911d5ffc9ecfd1aa1c", + "versions": ["inf"] + }, + { + "data": { + "2dxtraSet": null, + "bpiCoefficient": null, + "hashSHA256": null, + "inGameID": [80105], + "kaidenAverage": null, + "notecount": 621, + "worldRecord": null + }, + "difficulty": "NORMAL", + "id": "C19aa634402a90c691fe", + "isPrimary": true, + "legacyChartID": "946520cd18b7b94be05e84c1298e4c6d4f2c8d28", + "level": "6", + "levelNum": 6, + "songID": "S1911d5ffc9ecfd1aa1c", + "versions": ["inf"] + } ] diff --git a/db/seeds/goals.json b/db/seeds/goals.json index cef7596c4..40c747978 100644 --- a/db/seeds/goals.json +++ b/db/seeds/goals.json @@ -9,10 +9,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G1a2499cae27572b8bddd861b28aaa01dbe4c885544f15fae5ed91765a7b924a9", - "name": "HARD CLEAR Feel The Beat (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "HARD CLEAR Feel The Beat (SP LEGGENDARIA 12)" }, { "charts": { @@ -24,10 +23,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gdee336867a22a0a789ab45b863775f4351b6f70fbaa0ea1928c977179320d0ae", - "name": "EX HARD CLEAR 子供の落書き帳 (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR 子供の落書き帳 (SP ANOTHER 12)" }, { "charts": { @@ -39,14 +37,16 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G36f76a980802c1f06060e29f0d4d590fa06cb2ab2d5059b62e72489b98e0cdfe", - "name": "HARD CLEAR TITANS RETURN (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "HARD CLEAR TITANS RETURN (SP LEGGENDARIA 12)" }, { "charts": { - "data": ["C19d35e10d6cf3457ce4", "C19d35e10f64eaa8c280"], + "data": [ + "C19d35e10d6cf3457ce4", + "C19d35e10f64eaa8c280" + ], "type": "multi" }, "criteria": { @@ -54,10 +54,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G53d4c238478106b7a54b865f02d505f650c29b9bda8ff7ce229389b2bafd575a", - "name": "CLEAR either 灼熱Beach Side Bunny (SP ANOTHER 12) or 灼熱 Pt.2 Long Train Running (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR either 灼熱Beach Side Bunny (SP ANOTHER 12) or 灼熱 Pt.2 Long Train Running (SP ANOTHER 12)" }, { "charts": { @@ -69,10 +68,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G4a86b8c4a0e2c60bbf713d1be8f4ba7e4892ede6954a499beb8100f41ce78391", - "name": "HARD CLEAR Timepiece phase II (CN Ver.) (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR Timepiece phase II (CN Ver.) (SP ANOTHER 12)" }, { "charts": { @@ -84,10 +82,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G6da6680177acd81b493da1c8163174b6ac517c6463b25f0259f377bc9f2a3966", - "name": "AAA AA (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA AA (SP ANOTHER 12)" }, { "charts": { @@ -99,10 +96,9 @@ "mode": "single", "value": 98 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G749a8d3ac2bf391d65aee7491bd64bf0b20097ac4c097bc9dd0f42bccf5da047", - "name": "Get 98% on Fly Above (SP ANOTHER 8)", - "playtype": "SP" + "name": "Get 98% on Fly Above (SP ANOTHER 8)" }, { "charts": { @@ -114,10 +110,9 @@ "mode": "single", "value": 98 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G825995e16f75db2a31953a41564f3ceabdb250a3388843daafd527098989d78f", - "name": "Get 98% on the trigger of innocence (SP ANOTHER 9)", - "playtype": "SP" + "name": "Get 98% on the trigger of innocence (SP ANOTHER 9)" }, { "charts": { @@ -129,10 +124,9 @@ "mode": "single", "value": 98 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G5b99b92615fc9d1554e8bec4264885d642a5dd5ab9daba82cfc840da6261fcb0", - "name": "Get 98% on CROSSROAD ~Left Story~ (SP ANOTHER 10)", - "playtype": "SP" + "name": "Get 98% on CROSSROAD ~Left Story~ (SP ANOTHER 10)" }, { "charts": { @@ -144,10 +138,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G39cde64528a80e9a7b22f6a612d5567945c9e1e7a6e6bdb7275ff39029d98a71", - "name": "MAX- mosaic (SP ANOTHER 12)", - "playtype": "SP" + "name": "MAX- mosaic (SP ANOTHER 12)" }, { "charts": { @@ -159,10 +152,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G2634ee3209ddc4d87edb90c34471ea8603bf22885ac53daae36979fcfe656182", - "name": "AAA Happy Wedding (SP ANOTHER 8)", - "playtype": "SP" + "name": "AAA Happy Wedding (SP ANOTHER 8)" }, { "charts": { @@ -174,10 +166,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G1dafbb9a067708d0d52610a52d9e3d9efdd1ee8c40198f23a7bb219e78c5513b", - "name": "MAX- SHADE (SP ANOTHER 11)", - "playtype": "SP" + "name": "MAX- SHADE (SP ANOTHER 11)" }, { "charts": { @@ -190,10 +181,9 @@ "mode": "proportion", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gf460944fe4fa8d1cd8b1db041e9475b33e4d25f6dc49b383ea144d4e5180dd4e", - "name": "HARD CLEAR 80% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "HARD CLEAR 80% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -206,10 +196,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G1f254c17d443890622d34cee5ea0e216184c9637f76e1266eb99e0540da9fdc6", - "name": "AAA 15% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "AAA 15% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -221,10 +210,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G8a7ec915d0613ce018202caa1e16dc9dbbf5ce18cb391110bc091e9136da6e00", - "name": "CLEAR Chrono Diver -PENDULUMs- (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR Chrono Diver -PENDULUMs- (SP ANOTHER 12)" }, { "charts": { @@ -236,10 +224,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gcf8d759883e71996af9623499956d3d6ee6e591b7a58e6b8698469580ea2212d", - "name": "HARD CLEAR Verflucht (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR Verflucht (SP ANOTHER 12)" }, { "charts": { @@ -251,10 +238,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gd467e4325e60010906055a976204bffd89a5fa805fbcf898c62618a8f81a57a3", - "name": "CLEAR ECHIDNA (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR ECHIDNA (SP ANOTHER 12)" }, { "charts": { @@ -266,10 +252,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G01d884675908fc9e06a7e5b477afd10bf932a06ccbef8dfa25d5eb40ebedc703", - "name": "HARD CLEAR Ganymede (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "HARD CLEAR Ganymede (SP LEGGENDARIA 12)" }, { "charts": { @@ -281,10 +266,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G9cf0ca4046641e30a86ecfc206b095bc58c344907840e81a3f6f96ce7897dbc1", - "name": "CLEAR Time to Air (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "CLEAR Time to Air (SP LEGGENDARIA 12)" }, { "charts": { @@ -296,10 +280,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G9ec817b5b48790e7655be86536ea9f3b977d4b8f33bd2c0fef4b5675eeef5bb4", - "name": "CLEAR Lords Of The Roundtable (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR Lords Of The Roundtable (SP ANOTHER 12)" }, { "charts": { @@ -311,10 +294,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gb8464c140da50ad417aeaf8223fe260b3f88a992c6c88bb031d72cccba48a55a", - "name": "AAA snow storm (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "AAA snow storm (SP LEGGENDARIA 12)" }, { "charts": { @@ -326,10 +308,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Ge0973c181b48cbf54a3f6d14946f867a0ac238c3cd39bc572f9e64601652ace3", - "name": "AAA カゴノトリ~弐式~ (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA カゴノトリ~弐式~ (SP ANOTHER 12)" }, { "charts": { @@ -341,10 +322,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gb3bb20d0b86bf071d17a94a8a97b089486dcea29956d1e4561c17b0fe02afb36", - "name": "AAA (This Is Not) The Angels (SP ANOTHER 11)", - "playtype": "SP" + "name": "AAA (This Is Not) The Angels (SP ANOTHER 11)" }, { "charts": { @@ -356,10 +336,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G999a566fdad5912d5ba7bdd4ecad09438215ba1cca2a7085ae7e9404178d44ae", - "name": "AAA quasar (SP ANOTHER 11)", - "playtype": "SP" + "name": "AAA quasar (SP ANOTHER 11)" }, { "charts": { @@ -371,10 +350,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gf4e2efb99bcbedcec5afe6128ad9b48c2329bc604e9f3676d8b77e4bc68708f6", - "name": "MAX- Sakura Reflection (SP ANOTHER 11)", - "playtype": "SP" + "name": "MAX- Sakura Reflection (SP ANOTHER 11)" }, { "charts": { @@ -386,10 +364,9 @@ "mode": "single", "value": 96 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gd1c17cae156a8cc9993adafce8924bfee58558c51d59eec87c687c281499dc8e", - "name": "Get 96% on Wanna Party? (SP ANOTHER 8)", - "playtype": "SP" + "name": "Get 96% on Wanna Party? (SP ANOTHER 8)" }, { "charts": { @@ -402,10 +379,9 @@ "mode": "proportion", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G661132cce7d7b205f4ccc463073f7eccdd8ad0700539f5f86fff216be4a62799", - "name": "HARD CLEAR 70% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "HARD CLEAR 70% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -418,10 +394,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gd064ea10a7d7a330cde62b2047047bb7116f769b56082efb3fef7a95f9c0d125", - "name": "AAA 5% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "AAA 5% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -434,10 +409,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G34bcb7d0ec46bf486014ff7cccaf4b059572a58944d2c31ad628126ac8826358", - "name": "AAA 10% of the charts in the Level 11 (CastHour) folder", - "playtype": "SP" + "name": "AAA 10% of the charts in the Level 11 (CastHour) folder" }, { "charts": { @@ -449,10 +423,9 @@ "mode": "single", "value": 3 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Ge2ff55571ba96a533f278c0536473d0f16379e306b775cc1c63c20c5c833d201", - "name": "EASY CLEAR Mare Nectaris (SP ANOTHER 12)", - "playtype": "SP" + "name": "EASY CLEAR Mare Nectaris (SP ANOTHER 12)" }, { "charts": { @@ -464,10 +437,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G16d80e16e4657d413c93b203c6938432159f53b93dc05ead19dca8614b5f9819", - "name": "CLEAR SAMURAI-Scramble (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "CLEAR SAMURAI-Scramble (SP LEGGENDARIA 12)" }, { "charts": { @@ -479,10 +451,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G2f376d12b93d701297b28c53b6bbbce4b44a61156bc739288a2ed70fd4f6c5d1", - "name": "HARD CLEAR 疾風迅雷 (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "HARD CLEAR 疾風迅雷 (SP LEGGENDARIA 12)" }, { "charts": { @@ -494,10 +465,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G48490053cd8c865b0b7341b8728f8dcfc6b22c7a93a06cd14b328d0811e12b36", - "name": "HARD CLEAR Verflucht (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "HARD CLEAR Verflucht (SP LEGGENDARIA 12)" }, { "charts": { @@ -509,10 +479,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G53a3bad5acd46acc7ec0334bc4b4b2c317583e398e097bd219e148ccde0541f1", - "name": "EX HARD CLEAR お菓子の王国 (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR お菓子の王国 (SP ANOTHER 12)" }, { "charts": { @@ -524,10 +493,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gfbab43227c6ad53f49a2333114f97412467b657164750f0f5288a64982f42eeb", - "name": "AAA BITTER CHOCOLATE STRIKER (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA BITTER CHOCOLATE STRIKER (SP ANOTHER 12)" }, { "charts": { @@ -539,10 +507,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G19c09c3f23a7f2216d7a0fda3a525ce614f772a4fd455ccf40aa7f8f7a26b7ab", - "name": "MAX- 千年ノ理 (SP ANOTHER 12)", - "playtype": "SP" + "name": "MAX- 千年ノ理 (SP ANOTHER 12)" }, { "charts": { @@ -554,10 +521,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gd29cd861d7aaf7d27050950e69126f8f92813e96804f440458f3009a4079cbd6", - "name": "AAA Blue Rain (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "AAA Blue Rain (SP LEGGENDARIA 12)" }, { "charts": { @@ -569,10 +535,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gfb59b854a2d0f8dfc56cab487f4b43f4098cad073bfca42b99edb125d20e9c61", - "name": "AAA Rave Cannon (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA Rave Cannon (SP ANOTHER 12)" }, { "charts": { @@ -584,10 +549,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G08ed442b3f0210371e99276442281c34d4f6703bbb301a229502ef5054f1ffd9", - "name": "AAA naughty girl@Queen's Palace (SP ANOTHER 11)", - "playtype": "SP" + "name": "AAA naughty girl@Queen's Palace (SP ANOTHER 11)" }, { "charts": { @@ -599,10 +563,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G2be92dc54829e1da6016d6145b324b1a43e1ecefca262dd4bfdb68a9f35cc43f", - "name": "AAA L.F.O (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA L.F.O (SP ANOTHER 12)" }, { "charts": { @@ -614,10 +577,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G8392d2f9da662e5a7034af3e0264bb447e290d539ba4dc46ddc185f5063965d5", - "name": "MAX- quasar (SP ANOTHER 11)", - "playtype": "SP" + "name": "MAX- quasar (SP ANOTHER 11)" }, { "charts": { @@ -629,10 +591,9 @@ "mode": "single", "value": 99 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G786aa0be57281d82ebe04aefdc20f4722bb6ace8e9bcb8768db4db03b1375005", - "name": "Get 99% on Sleepless days (SP ANOTHER 9)", - "playtype": "SP" + "name": "Get 99% on Sleepless days (SP ANOTHER 9)" }, { "charts": { @@ -644,10 +605,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gf0c07a8501880c2fe56826d3c8c852563851d40b47fde07c1aeff780a83cdc2b", - "name": "AAA Ganymede (SP ANOTHER 11)", - "playtype": "SP" + "name": "AAA Ganymede (SP ANOTHER 11)" }, { "charts": { @@ -660,10 +620,9 @@ "mode": "proportion", "value": 3 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G8eb9a981f9671033f078269ac98b382f46f844649a30cea54b3be26332fdfc47", - "name": "EASY CLEAR 100% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "EASY CLEAR 100% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -676,10 +635,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G0e1c4e8dc2bd01a3437ef728654b0edb63ad1b57f869e5cee651c1fe289b2b97", - "name": "AAA 30% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "AAA 30% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -691,10 +649,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G0195d9b1603ed74eaa038f5363cdd676eeb63b30a386746cb6b349aad6f09c96", - "name": "EX HARD CLEAR VANESSA (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "EX HARD CLEAR VANESSA (SP LEGGENDARIA 12)" }, { "charts": { @@ -706,10 +663,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gbd03b00f85711c94f00fea90089076f48a9cccda65f23bd3ed3f4fb35e01ec8c", - "name": "EX HARD CLEAR 共鳴遊戯の華 (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR 共鳴遊戯の華 (SP ANOTHER 12)" }, { "charts": { @@ -721,10 +677,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G8e816f07a69b9ef1e0004ff1b0cba95fc41149a4b01c52f98f20f3f00ae07be3", - "name": "EX HARD CLEAR Sigmund (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR Sigmund (SP ANOTHER 12)" }, { "charts": { @@ -736,10 +691,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G6c8e79d4902b4cf743a8afe439b1a1e0abdb7f8f21eb5039c6a629532e60a906", - "name": "HARD CLEAR Mare Nectaris (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR Mare Nectaris (SP ANOTHER 12)" }, { "charts": { @@ -751,10 +705,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G57eb13343a058c69a5698d1ac367b92fd72ceb3da411e8dac0398a0768a5a4bb", - "name": "HARD CLEAR 冥 (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR 冥 (SP ANOTHER 12)" }, { "charts": { @@ -766,10 +719,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G7f083af0e8e9947647a900cdda9c31d034508dbaae52c0409443a3b3ad21d59f", - "name": "HARD CLEAR ICARUS (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "HARD CLEAR ICARUS (SP LEGGENDARIA 12)" }, { "charts": { @@ -781,10 +733,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G3d1c87e9352934da393602ab72e09d975ead0e6967d11f55fedb62c45d5859f5", - "name": "AAA Red. by Full Metal Jacket (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA Red. by Full Metal Jacket (SP ANOTHER 12)" }, { "charts": { @@ -796,10 +747,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G83e9153d064fa975a7c189ce7e881fa9f086350f4499aff3f3358033075b75ec", - "name": "MAX- Summerlights(IIDX Edition) (SP ANOTHER 12)", - "playtype": "SP" + "name": "MAX- Summerlights(IIDX Edition) (SP ANOTHER 12)" }, { "charts": { @@ -811,10 +761,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gd2a24516591b18582a3eff64136f2099cc177215a0e3efcedff01890733c0788", - "name": "AAA Ancient Scapes (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "AAA Ancient Scapes (SP LEGGENDARIA 12)" }, { "charts": { @@ -826,10 +775,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G3663a80447cd30ab89384962e8e6b2b8c2ccbb4c7abd1c96b149bc8181c5eba2", - "name": "AAA Elemental Creation (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA Elemental Creation (SP ANOTHER 12)" }, { "charts": { @@ -841,10 +789,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G4c7474d686b61360150065ea71908db503e9052767e39c7551d7cc4ccdbd2552", - "name": "MAX- spiral galaxy (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "MAX- spiral galaxy (SP LEGGENDARIA 12)" }, { "charts": { @@ -856,10 +803,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G324fc7b7a4f6b9c6c5600fc99efecd473ec3485a1dc8108be00b9682bf41bbf2", - "name": "AAA Dances with Snow Fairies (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA Dances with Snow Fairies (SP ANOTHER 12)" }, { "charts": { @@ -871,10 +817,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G886a5a0c91eb4a0ad9402c05b12e8f6d3fc652d3effea71d3485cf50a7dbd8c6", - "name": "MAX- AA (SP ANOTHER 12)", - "playtype": "SP" + "name": "MAX- AA (SP ANOTHER 12)" }, { "charts": { @@ -886,10 +831,9 @@ "mode": "single", "value": 99 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G3af77e883209462687918d0f436c1596d6e3fd6678841f6785e08361a75af46f", - "name": "Get 99% on CROSSROAD ~Left Story~ (SP ANOTHER 10)", - "playtype": "SP" + "name": "Get 99% on CROSSROAD ~Left Story~ (SP ANOTHER 10)" }, { "charts": { @@ -901,10 +845,9 @@ "mode": "single", "value": 97 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G78340b6bc4ce95763f797550a2941a04f1bcfcb8df5d4091d1a9ea5c24a1f7e2", - "name": "Get 97% on Wanna Party? (SP LEGGENDARIA 11)", - "playtype": "SP" + "name": "Get 97% on Wanna Party? (SP LEGGENDARIA 11)" }, { "charts": { @@ -917,10 +860,9 @@ "mode": "proportion", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Ga96793d89c4924914e2a6f306277badffacf0990af24b2b3674e96f466951615", - "name": "HARD CLEAR 100% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "HARD CLEAR 100% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -933,10 +875,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G472d131ccef571f9cb2ee06e7261214f49bbc1e36a119e3c0cc366c98f23f500", - "name": "AAA 70% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "AAA 70% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -949,10 +890,9 @@ "mode": "proportion", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G09f78b3a6b607c39b2fca68a7a4054323bb750b2fe70dc85ab13c72bf3d75860", - "name": "MAX- 15% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "MAX- 15% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -964,10 +904,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G7ac29a0cb0c338d930ffa76835b20049d1a81f81f69c794d3dcac8dfc6a0141e", - "name": "EX HARD CLEAR X-DEN (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR X-DEN (SP ANOTHER 12)" }, { "charts": { @@ -979,10 +918,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Ge7067c5fb15f67bac212093da736ffbd04aa13e2915ddbc4a08450d0d3ec4a97", - "name": "EX HARD CLEAR Mare Nectaris (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR Mare Nectaris (SP ANOTHER 12)" }, { "charts": { @@ -994,10 +932,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gacb2b831d861daf8f646169e0836465c9d1b75c879f9cae86ef70e37ac4526c2", - "name": "EX HARD CLEAR 冥 (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR 冥 (SP ANOTHER 12)" }, { "charts": { @@ -1009,10 +946,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G18170eb62353e8bb450fcc44df2cd763b64c2dd853d943e310cf372599b7055f", - "name": "EX HARD CLEAR ICARUS (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "EX HARD CLEAR ICARUS (SP LEGGENDARIA 12)" }, { "charts": { @@ -1024,10 +960,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gad8194c5f9050efcb8b12e150be83682769a61897dbd16d19851f6732951f88a", - "name": "FULL COMBO KAMAITACHI (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "FULL COMBO KAMAITACHI (SP LEGGENDARIA 12)" }, { "charts": { @@ -1039,10 +974,9 @@ "mode": "single", "value": 3700 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G5014da556b19342d379352a3befaff4d7271a65174dc91d8278c0c8295d2a429", - "name": "Get a score of 3,700 on 冥 (SP ANOTHER 12)", - "playtype": "SP" + "name": "Get a score of 3,700 on 冥 (SP ANOTHER 12)" }, { "charts": { @@ -1054,10 +988,9 @@ "mode": "single", "value": 3600 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G2ff00b659f0ddf8610d606d53b02fdc8df69ff639db5c246f6c80a525c458db7", - "name": "Get a score of 3,600 on AA (SP ANOTHER 12)", - "playtype": "SP" + "name": "Get a score of 3,600 on AA (SP ANOTHER 12)" }, { "charts": { @@ -1069,14 +1002,16 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gd0616dd15eec949ccd49d727b22a5fd33352fdab418e8d464313b357aca7fa13", - "name": "AAA 灼熱Beach Side Bunny (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA 灼熱Beach Side Bunny (SP ANOTHER 12)" }, { "charts": { - "data": ["C19d35e10e17abee5636", "C19d35e10da1d3c16cda"], + "data": [ + "C19d35e10e17abee5636", + "C19d35e10da1d3c16cda" + ], "type": "multi" }, "criteria": { @@ -1084,10 +1019,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G12f2b73cd600478a0c16c0fc6b382005b3a933865f3d11de127e499de9bcfb60", - "name": "AAA either Thor's Hammer (SP ANOTHER 12) or DIAVOLO (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA either Thor's Hammer (SP ANOTHER 12) or DIAVOLO (SP ANOTHER 12)" }, { "charts": { @@ -1100,10 +1034,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G2b883ffdbe765eaaebfac6e83bc0081b9c997ca0e3d1035d02ddff07d7fef2e1", - "name": "AAA 100% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "AAA 100% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -1116,10 +1049,9 @@ "mode": "proportion", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G61da2b79d0434ff044ceff76352d9ba9748775d1e8baf43124c8ecb6b1c8d502", - "name": "MAX- 70% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "MAX- 70% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -1131,10 +1063,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G1c9806798380fd61a84e0b9c110c728cca54f643eadf620373f84cf042d95e36", - "name": "EX HARD CLEAR 卑弥呼 (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR 卑弥呼 (SP ANOTHER 12)" }, { "charts": { @@ -1146,10 +1077,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gca3a6b342a136b9b6b7427ab653d76a516aadc1c9b0a7b079b98d5c04b984118", - "name": "EX HARD CLEAR Verflucht (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "EX HARD CLEAR Verflucht (SP LEGGENDARIA 12)" }, { "charts": { @@ -1161,10 +1091,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gf151ed2cde8b6da5011d963a3f4e593c6010fd92646a7fb1c6d6738c2920a277", - "name": "EX HARD CLEAR 灼熱Beach Side Bunny (SP ANOTHER 12)", - "playtype": "SP" + "name": "EX HARD CLEAR 灼熱Beach Side Bunny (SP ANOTHER 12)" }, { "charts": { @@ -1176,10 +1105,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G2df81066938bb2ff7d0e166f026ec253a27fa4e8adbfe55e030872213dbb0af9", - "name": "EX HARD CLEAR 疾風迅雷 (SP LEGGENDARIA 12)", - "playtype": "SP" + "name": "EX HARD CLEAR 疾風迅雷 (SP LEGGENDARIA 12)" }, { "charts": { @@ -1191,10 +1119,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G63a7f069f8942865d6ddf7b51bd8c5e1bc051671c5e3b565a8b1544d46c73b79", - "name": "AAA 冥 (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA 冥 (SP ANOTHER 12)" }, { "charts": { @@ -1206,10 +1133,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gb6469efe0ad1fe92949ed1ccfc26115a2620cdd7be6ee041e99faaf6b571b90b", - "name": "MAX- BLACK.by X-Cross Fade (SP ANOTHER 12)", - "playtype": "SP" + "name": "MAX- BLACK.by X-Cross Fade (SP ANOTHER 12)" }, { "charts": { @@ -1221,10 +1147,9 @@ "mode": "single", "value": 98 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G71cca18c17f15bbb8920c269f4c989fd064b750a8e4748b1ad767116f38a6e77", - "name": "Get 98% on LIGHTNING STRIKES (SP ANOTHER 12)", - "playtype": "SP" + "name": "Get 98% on LIGHTNING STRIKES (SP ANOTHER 12)" }, { "charts": { @@ -1236,10 +1161,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gb07e04f91ccfc1eb1502800e1f54f7197829b7d1443838f5b7f2835d5d702abb", - "name": "MAX- Initiation (SP ANOTHER 12)", - "playtype": "SP" + "name": "MAX- Initiation (SP ANOTHER 12)" }, { "charts": { @@ -1252,10 +1176,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gfa235ed890bbc75bab98cd88bc07ebd83f65ce5b14ce3249af1fdf9d816be726", - "name": "AAA 95% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "AAA 95% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -1268,10 +1191,9 @@ "mode": "proportion", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Ga9ed7c31b4262f6e5dfc58b3d00a8ccec11d0ba5e0b2e47c57b220919785b373", - "name": "MAX- 40% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "MAX- 40% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -1283,10 +1205,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Ga0695b5a44b0fc387d0fd8e7289fecb6a2254231bee6c5624695e44192f77d23", - "name": "HARD CLEAR Bad Maniacs (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR Bad Maniacs (SP ANOTHER 12)" }, { "charts": { @@ -1298,10 +1219,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gbbda7dfac9675109f47f73efe54520db3d16f51876fd8a86e29eac5ec39a534e", - "name": "HARD CLEAR MENDES (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR MENDES (SP ANOTHER 12)" }, { "charts": { @@ -1313,10 +1233,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G7265d66249d1d9bd54c1ae06b6c1936b984beef4a154ee137412bcf05d75b8e3", - "name": "HARD CLEAR quell~the seventh slave~ (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR quell~the seventh slave~ (SP ANOTHER 12)" }, { "charts": { @@ -1328,10 +1247,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G063ef72be4cc1cb237dfa267d9f400b66cf337136c952daf4334df7ae7e41bfc", - "name": "HARD CLEAR 天空の夜明け (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR 天空の夜明け (SP ANOTHER 12)" }, { "charts": { @@ -1343,10 +1261,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Ga56e3b9b26905c5a45da42bdd7080ba5341ad94d7a3a545b365fe17992cb2b65", - "name": "HARD CLEAR HAERETICUS (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR HAERETICUS (SP ANOTHER 12)" }, { "charts": { @@ -1358,10 +1275,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gb8d3310edb53dd5f6a5d08da0f6e18e75ebca930698e39abe36a52662718f1b2", - "name": "CLEAR BLACK.by X-Cross Fade (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR BLACK.by X-Cross Fade (SP ANOTHER 12)" }, { "charts": { @@ -1373,10 +1289,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gb22d2a97955f9932a348b1596b6476ed6a7bdbc1d080b59a73cfd9fba6ebb7de", - "name": "HARD CLEAR Watch Out Pt.2 (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR Watch Out Pt.2 (SP ANOTHER 12)" }, { "charts": { @@ -1388,10 +1303,9 @@ "mode": "single", "value": 6 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G8f28381abceaa26a9c3a76d3cfb7a630450da1dddb8cd82171f9a7b5173b8649", - "name": "EX HARD CLEAR era (nostalmix) (SP ANOTHER 9)", - "playtype": "SP" + "name": "EX HARD CLEAR era (nostalmix) (SP ANOTHER 9)" }, { "charts": { @@ -1403,10 +1317,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G254bc6d16c8ed54da6d7ad88903a46b8500d24049537901e070055347379986d", - "name": "AAA mosaic (SP ANOTHER 12)", - "playtype": "SP" + "name": "AAA mosaic (SP ANOTHER 12)" }, { "charts": { @@ -1418,10 +1331,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G4b25b28d4afdfa58fee55c84a2963e1886886ae515ccab6274cd66a601b55c24", - "name": "AAA Hypersonik (SP ANOTHER 10)", - "playtype": "SP" + "name": "AAA Hypersonik (SP ANOTHER 10)" }, { "charts": { @@ -1433,10 +1345,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G7c5055482429522f7482e7bdb97036d9a2c7e93f294ad4b39660d8e971884c29", - "name": "AAA PENDUAL TALISMAN (SP ANOTHER 11)", - "playtype": "SP" + "name": "AAA PENDUAL TALISMAN (SP ANOTHER 11)" }, { "charts": { @@ -1448,10 +1359,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G60b4be3a6aca95dd43fa1bf62215deabba42f7a40ab3e3eef78916705d0a38ea", - "name": "MAX- Dazzlin' Darlin (SP ANOTHER 10)", - "playtype": "SP" + "name": "MAX- Dazzlin' Darlin (SP ANOTHER 10)" }, { "charts": { @@ -1463,10 +1373,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G332b29e41e4069bf16d5b4718408c8a8fe446aff4492c26b7ab4389a7df4286d", - "name": "AAA Make Me Your Own (SP ANOTHER 10)", - "playtype": "SP" + "name": "AAA Make Me Your Own (SP ANOTHER 10)" }, { "charts": { @@ -1478,10 +1387,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G89dd2e38a992365ad2f80eb39fee7e686cb13c3a9a1343b266913fa63afa5a79", - "name": "AAA Line 4 Ruin (SP ANOTHER 11)", - "playtype": "SP" + "name": "AAA Line 4 Ruin (SP ANOTHER 11)" }, { "charts": { @@ -1494,10 +1402,9 @@ "mode": "proportion", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G86ef585d023d76a34105a87a29532201fa5cf0977e5b1bb7c4cdbbb2bc0d7d02", - "name": "CLEAR 50% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "CLEAR 50% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -1510,10 +1417,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G45fecf25975b96b93ce9c81fac3fccac618516935bf278c025e5707312de92f2", - "name": "AAA 5% of the charts in the Level 11 (CastHour) folder", - "playtype": "SP" + "name": "AAA 5% of the charts in the Level 11 (CastHour) folder" }, { "charts": { @@ -1526,10 +1432,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gb19e2023305b87213154c3a28e8c858141cf3fa113b6a15815b5d2b12009938d", - "name": "AAA 10% of the charts in the Level 10 (CastHour) folder", - "playtype": "SP" + "name": "AAA 10% of the charts in the Level 10 (CastHour) folder" }, { "charts": { @@ -1541,10 +1446,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G29a70b3f17eae4f3b8256d0d832470d278a48147e839d4238e8a082193a96502", - "name": "HARD CLEAR AA (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR AA (SP ANOTHER 12)" }, { "charts": { @@ -1556,10 +1460,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gcf40d565e86bf83a3eef2a7cb82deccf0addefa7221e7d9d4ea81d0d26da3d8d", - "name": "HARD CLEAR Scripted Connection⇒ A mix (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR Scripted Connection⇒ A mix (SP ANOTHER 12)" }, { "charts": { @@ -1571,10 +1474,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gbabb92af4e18584e9641f97cc7bd63ebadffa8ca1fe410f56bc8cee0b8f308bf", - "name": "CLEAR Dr. Chemical & Killing Machine (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR Dr. Chemical & Killing Machine (SP ANOTHER 12)" }, { "charts": { @@ -1591,10 +1493,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gcd568e1f0bc9ed6c4c1e2736f7cfda2eef649931562bfbd79b4e40c07c8ecd4b", - "name": "CLEAR any one of Red. by Full Metal Jacket (SP ANOTHER 12), Watch Out Pt.2 (SP ANOTHER 12), 199024club -Re:BounceKiller- (SP ANOTHER 12) or Snake Stick (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR any one of Red. by Full Metal Jacket (SP ANOTHER 12), Watch Out Pt.2 (SP ANOTHER 12), 199024club -Re:BounceKiller- (SP ANOTHER 12) or Snake Stick (SP ANOTHER 12)" }, { "charts": { @@ -1606,10 +1507,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gfaf0ebec02f07c36c9f09efe37acd925cefcebbf092ccc3b40a23bf3b072835f", - "name": "HARD CLEAR 雪月花 (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR 雪月花 (SP ANOTHER 12)" }, { "charts": { @@ -1621,10 +1521,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gea588214e18bc3879ee62164eb06c7356230f35be3ae75a71d83d01645532628", - "name": "AAA Sakura Reflection (SP ANOTHER 11)", - "playtype": "SP" + "name": "AAA Sakura Reflection (SP ANOTHER 11)" }, { "charts": { @@ -1636,10 +1535,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G9973d2b6a16c70d30b0d302ace1ac6d523a4d6d7d1847134dc3c67d3fde218e7", - "name": "AAA Daisuke (SP ANOTHER 10)", - "playtype": "SP" + "name": "AAA Daisuke (SP ANOTHER 10)" }, { "charts": { @@ -1651,10 +1549,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gfbbd23ea00d0c1e07e787e7f9e8a969bb1d3e50016037e7f831abcc8a76051d0", - "name": "AAA NO LIMIT -オレ達に限界は無い- (SP ANOTHER 10)", - "playtype": "SP" + "name": "AAA NO LIMIT -オレ達に限界は無い- (SP ANOTHER 10)" }, { "charts": { @@ -1666,10 +1563,9 @@ "mode": "single", "value": 8 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G7a1c16f3d0dfac597511a5ef7f102b8f05f1859aa5bb347ee980e6ffe9124153", - "name": "MAX- Sleepless days (SP ANOTHER 9)", - "playtype": "SP" + "name": "MAX- Sleepless days (SP ANOTHER 9)" }, { "charts": { @@ -1682,10 +1578,9 @@ "mode": "proportion", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gb372c466966afbf3ebf70c5d3b701dab56fd376965797a6864ed163b20d2868a", - "name": "CLEAR 30% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "CLEAR 30% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -1698,10 +1593,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gcaf664e666e9d567941bddc60734e270cc273456074b66fa81921f0718419584", - "name": "AAA 1% of the charts in the Level 11 (CastHour) folder", - "playtype": "SP" + "name": "AAA 1% of the charts in the Level 11 (CastHour) folder" }, { "charts": { @@ -1714,10 +1608,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G24421c36c33c601a8ec08fbc4de2713ff7fdbdf14e088fe0a6c4cd640e544d79", - "name": "AAA 5% of the charts in the Level 10 (CastHour) folder", - "playtype": "SP" + "name": "AAA 5% of the charts in the Level 10 (CastHour) folder" }, { "charts": { @@ -1729,10 +1622,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G2788eba138e2919d69fc1c78da2af3d03d0dd5923bc816adcd13d977b9a07b51", - "name": "CLEAR The Sampling Paradise (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR The Sampling Paradise (SP ANOTHER 12)" }, { "charts": { @@ -1744,10 +1636,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G16b48da8eaa90d33762ebdd9157f318a6796c18233c2b7342fd60342c19f5a84", - "name": "CLEAR SOLID STATE SQUAD (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR SOLID STATE SQUAD (SP ANOTHER 12)" }, { "charts": { @@ -1759,10 +1650,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G89e418fe4063bd4ec408ab6f14fc2adeb8c6b083b78c035e86baa7fb08c49be5", - "name": "CLEAR Rave Cannon (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR Rave Cannon (SP ANOTHER 12)" }, { "charts": { @@ -1774,10 +1664,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gf6b5d259f395e82c4a0253318916d1ba879518018e59db87bd074794a1b4f744", - "name": "CLEAR Highcharge Divolt (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR Highcharge Divolt (SP ANOTHER 12)" }, { "charts": { @@ -1789,10 +1678,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G7036ef4e689adc7193a8e8eb21e35fee512ee1bf77f936a902e703da6039602e", - "name": "HARD CLEAR Venom (SP ANOTHER 12)", - "playtype": "SP" + "name": "HARD CLEAR Venom (SP ANOTHER 12)" }, { "charts": { @@ -1804,10 +1692,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G167348b8b1dd68884dd7ec4c7889fff8f71ff8d96aece8aac2de550acadab729", - "name": "CLEAR Broken (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR Broken (SP ANOTHER 12)" }, { "charts": { @@ -1819,10 +1706,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G809602ca08ef16ac69cbb376195cf646e9a259905283e123c9dff879c6c13594", - "name": "AAA 5.1.1. (SP ANOTHER 10)", - "playtype": "SP" + "name": "AAA 5.1.1. (SP ANOTHER 10)" }, { "charts": { @@ -1834,10 +1720,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gf8ae63ad9fb57853c7431ea5a8ba962da3e2bd0d29df547fa9239642acd1aab8", - "name": "AAA chrono diver -fragment- (SP LEGGENDARIA 10)", - "playtype": "SP" + "name": "AAA chrono diver -fragment- (SP LEGGENDARIA 10)" }, { "charts": { @@ -1849,10 +1734,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G03e0b772b28192e1503552d94e7ca9785d79e74f6682d922b3d8e550723c095c", - "name": "AAA ¡Viva! (SP ANOTHER 10)", - "playtype": "SP" + "name": "AAA ¡Viva! (SP ANOTHER 10)" }, { "charts": { @@ -1864,10 +1748,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G712841f80690c78026f34dcf8ed885545168bcb065afa9987038c577c703fe0f", - "name": "AAA BRIDAL FESTIVAL !!! (SP ANOTHER 9)", - "playtype": "SP" + "name": "AAA BRIDAL FESTIVAL !!! (SP ANOTHER 9)" }, { "charts": { @@ -1880,10 +1763,9 @@ "mode": "proportion", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G46b5ce2a764563eb431d3270836e49fd911c64cd502495d10bf2da1bba4dc01c", - "name": "CLEAR 15% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "CLEAR 15% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -1896,10 +1778,9 @@ "mode": "proportion", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G04446d20628c449092f4e5a74c5416315fde66b6fa9e9977f6d074619451f6bd", - "name": "HARD CLEAR 5% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "HARD CLEAR 5% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -1912,10 +1793,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G911d105e08b4c74f2ddb698efbb952b51753cb7dba4e35ad512e6104fd1064b6", - "name": "AAA 1% of the charts in the Level 10 (CastHour) folder", - "playtype": "SP" + "name": "AAA 1% of the charts in the Level 10 (CastHour) folder" }, { "charts": { @@ -1927,10 +1807,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gab1c4a139570cfbee48db6c96f66cd39486d99911771340f11e05533aa4b3287", - "name": "HARD CLEAR quasar (SP ANOTHER 11)", - "playtype": "SP" + "name": "HARD CLEAR quasar (SP ANOTHER 11)" }, { "charts": { @@ -1942,10 +1821,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G3b2d3447cae04f856cb6c51a4d924aeabefc2034c1dd8a823bd134c04cc3b9db", - "name": "HARD CLEAR Digitank System (SP ANOTHER 11)", - "playtype": "SP" + "name": "HARD CLEAR Digitank System (SP ANOTHER 11)" }, { "charts": { @@ -1957,10 +1835,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G51eae5902c3a14284b515cf85001a6e94cc195f8b408865bd808074a11b0f947", - "name": "CLEAR SPECIAL SUMMER CAMPAIGN! (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR SPECIAL SUMMER CAMPAIGN! (SP ANOTHER 12)" }, { "charts": { @@ -1972,10 +1849,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gc275e614ee2303f13e217d7883a35ff4d3507a96f58db9e701023596c91d9a69", - "name": "CLEAR sakura storm (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR sakura storm (SP ANOTHER 12)" }, { "charts": { @@ -1987,14 +1863,17 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G84b57d80510556b7aa49b3c01d76d4e4c29521d70c34fa97647a37676ea9362a", - "name": "CLEAR 千年ノ理 (SP ANOTHER 12)", - "playtype": "SP" + "name": "CLEAR 千年ノ理 (SP ANOTHER 12)" }, { "charts": { - "data": ["C19d35e10e37c954e085", "C19d35e10fef1ca4f7d5", "C19d35e10dde016f1746"], + "data": [ + "C19d35e10e37c954e085", + "C19d35e10fef1ca4f7d5", + "C19d35e10dde016f1746" + ], "type": "multi" }, "criteria": { @@ -2002,10 +1881,9 @@ "mode": "single", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Gf5cc6c22777f61ca37c672458970ffae60a6ffb238343fb0b22a838bba83b9c2", - "name": "CLEAR any one of examination leave (SP ANOTHER 11), Macuilxochitl (SP ANOTHER 11) or 532nm (SP ANOTHER 11)", - "playtype": "SP" + "name": "CLEAR any one of examination leave (SP ANOTHER 11), Macuilxochitl (SP ANOTHER 11) or 532nm (SP ANOTHER 11)" }, { "charts": { @@ -2017,10 +1895,9 @@ "mode": "single", "value": 5 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G15ae6ddaa4b63de46dd2371010df7864752996f8e904906faae0feb72c511d1a", - "name": "HARD CLEAR SHADE (SP ANOTHER 11)", - "playtype": "SP" + "name": "HARD CLEAR SHADE (SP ANOTHER 11)" }, { "charts": { @@ -2032,10 +1909,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G0d44be3bae50f7554f0e696e39b726926f6bb750fd49426c848c84ed9bcbbaad", - "name": "AAA Element of SPADA (SP ANOTHER 9)", - "playtype": "SP" + "name": "AAA Element of SPADA (SP ANOTHER 9)" }, { "charts": { @@ -2047,10 +1923,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G2a931695fbcb409c4e88590d1af4887a4b41c5787c625a45b2ca7b3a672fd034", - "name": "AAA the trigger of innocence (SP ANOTHER 9)", - "playtype": "SP" + "name": "AAA the trigger of innocence (SP ANOTHER 9)" }, { "charts": { @@ -2062,10 +1937,9 @@ "mode": "single", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G00122bef622fadcfd27c3ae3472bc8f5a929a64ad5dd62f7dd7e6c67e595766f", - "name": "AAA Sweet Clapper (SP ANOTHER 10)", - "playtype": "SP" + "name": "AAA Sweet Clapper (SP ANOTHER 10)" }, { "charts": { @@ -2078,10 +1952,9 @@ "mode": "proportion", "value": 4 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "G39e38988b5fff299596322caf2d7e522d5af8c373560b3bd592fa58992a67a58", - "name": "CLEAR 10% of the charts in the Level 12 (CastHour) folder", - "playtype": "SP" + "name": "CLEAR 10% of the charts in the Level 12 (CastHour) folder" }, { "charts": { @@ -2094,10 +1967,9 @@ "mode": "proportion", "value": 7 }, - "game": "iidx", + "game": "iidx-sp", "goalID": "Ge1e31f0d0cbb42a4ade9ef4955f2e24527fdd26f43eac8d5be6f8df7b897d8f0", - "name": "AAA 1% of the charts in the Level 9 (CastHour) folder", - "playtype": "SP" + "name": "AAA 1% of the charts in the Level 9 (CastHour) folder" }, { "charts": { @@ -2114,10 +1986,9 @@ "mode": "single", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G1d5ca7dbac64333501d2addcdc5204b8f67ff97d8816b31148de032b3f88143b", - "name": "S any one of Paradission (ADV 12), Decoy (ADV 12), NEO GRAVITY (ADV 12) or Celestial stinger (ADV 12)", - "playtype": "Single" + "name": "S any one of Paradission (ADV 12), Decoy (ADV 12), NEO GRAVITY (ADV 12) or Celestial stinger (ADV 12)" }, { "charts": { @@ -2129,10 +2000,9 @@ "mode": "single", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Ga5f928c598496bb359148b3a369b2c39803a9e65829150f0719b5de89856350e", - "name": "S any chart in the Level 13 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "S any chart in the Level 13 (EXCEED GEAR) folder" }, { "charts": { @@ -2145,14 +2015,17 @@ "mode": "absolute", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gf9361811b028db747f42efac21279b2309cc60b6c4928c2995f7d5dd44d003b1", - "name": "AAA+ 3 charts in the Level 14 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 3 charts in the Level 14 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e120e2bd10f710", "C19d35e1207bcf55ca00", "C19d35e120dd312d5101"], + "data": [ + "C19d35e120e2bd10f710", + "C19d35e1207bcf55ca00", + "C19d35e120dd312d5101" + ], "type": "multi" }, "criteria": { @@ -2160,10 +2033,9 @@ "mode": "single", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G37a14b574a6fa69869b81853cd18acc23ca6cc8e5a0e66ba52bc50d2adb37bb4", - "name": "AAA any one of The First Step (EXH 14), EGOISM -Rebuild- (EXH 14) or Still Lonesome (EXH 14)", - "playtype": "Single" + "name": "AAA any one of The First Step (EXH 14), EGOISM -Rebuild- (EXH 14) or Still Lonesome (EXH 14)" }, { "charts": { @@ -2176,10 +2048,9 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G7fe33015d02f9b19626121a96480e12cbe06905f3a33252b64ec9927857050e3", - "name": "AAA 5 charts in the Level 15 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 5 charts in the Level 15 (EXCEED GEAR) folder" }, { "charts": { @@ -2192,10 +2063,9 @@ "mode": "absolute", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G26c265e7188e5a1536a818eb38fda52eaee3bfd7e71b3e1f029e0262ea6a782c", - "name": "AA+ 20 charts in the Level 15 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AA+ 20 charts in the Level 15 (EXCEED GEAR) folder" }, { "charts": { @@ -2208,10 +2078,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G94c2a3d6e936cf946d7b870caaed917eb9a00d644f68ac514387a13a1356b2c1", - "name": "EXCESSIVE CLEAR 10 charts in the Level 15 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 10 charts in the Level 15 (EXCEED GEAR) folder" }, { "charts": { @@ -2224,10 +2093,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G909ef14ed7b214a25332693fe623ae65e2e0468f91def016d7a77f8d5779af46", - "name": "CLEAR 30 charts in the Level 15 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 30 charts in the Level 15 (EXCEED GEAR) folder" }, { "charts": { @@ -2240,14 +2108,17 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G604f3063c34494062b11e181543cf881d408b67311695f750927b3f907e75cf5", - "name": "CLEAR 10 charts in the Level 16 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 10 charts in the Level 16 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11b34740b1895", "C19d35e11dd0d9664428", "C19d35e11ccba56d68b0"], + "data": [ + "C19d35e11b34740b1895", + "C19d35e11dd0d9664428", + "C19d35e11ccba56d68b0" + ], "type": "multi" }, "criteria": { @@ -2255,10 +2126,9 @@ "mode": "single", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gd95a09ddc1ce7d3d139c7c622883d58c5541127c94d3fea1c3828f912ca412f0", - "name": "CLEAR any one of 孤独の番人 (EXH 16), 爆なな☆てすとロイヤー (EXH 16) or ポメグラネイト (EXH 16)", - "playtype": "Single" + "name": "CLEAR any one of 孤独の番人 (EXH 16), 爆なな☆てすとロイヤー (EXH 16) or ポメグラネイト (EXH 16)" }, { "charts": { @@ -2271,10 +2141,9 @@ "mode": "absolute", "value": 3 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G6f51d83a87ea6468d36357d2fbebad8248a1db01503d409f7bced22a17d21199", - "name": "ULTIMATE CHAIN 3 charts in the Level 13 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "ULTIMATE CHAIN 3 charts in the Level 13 (EXCEED GEAR) folder" }, { "charts": { @@ -2287,10 +2156,9 @@ "mode": "absolute", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G16448646d8f0ee92742e50607460ed9ab36ce77078f83336592c060360326d59", - "name": "S 3 charts in the Level 14 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "S 3 charts in the Level 14 (EXCEED GEAR) folder" }, { "charts": { @@ -2303,14 +2171,17 @@ "mode": "absolute", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G39a50e57d5a90612ce18d55ad410299f854a5d88c439582fd51facdbf9d338f1", - "name": "AAA+ 4 charts in the Level 15 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 4 charts in the Level 15 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11f958b637f1e", "C19d35e11aceb41230d4", "C19d35e11eddc2799df2"], + "data": [ + "C19d35e11f958b637f1e", + "C19d35e11aceb41230d4", + "C19d35e11eddc2799df2" + ], "type": "multi" }, "criteria": { @@ -2318,10 +2189,9 @@ "mode": "single", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gcfebd2e376a3480b9e6c033f117469b765582902c36339422ad9a9898402ee1f", - "name": "AAA any one of ARROW RAIN (EXH 16), GAMBOL (dfk SLC rmx) (INF 16) or Juggler's Maddness (EXH 16)", - "playtype": "Single" + "name": "AAA any one of ARROW RAIN (EXH 16), GAMBOL (dfk SLC rmx) (INF 16) or Juggler's Maddness (EXH 16)" }, { "charts": { @@ -2334,14 +2204,16 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gcf2e16e52eeb5b2c5af03c96e37381eb1a2ca7ed1e5941fafbc297a5553381b0", - "name": "AAA 5 charts in the Level 16 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 5 charts in the Level 16 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e12116d1a9fcfc", "C19d35e11bef7119bb6c"], + "data": [ + "C19d35e12116d1a9fcfc", + "C19d35e11bef7119bb6c" + ], "type": "multi" }, "criteria": { @@ -2349,10 +2221,9 @@ "mode": "single", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G60ea64a9d5c8db898668210740f06c79924b37229dca4bda9c6908b2f958bfc6", - "name": "AA+ either VALKYRIE ASSAULT (EXH 16) or choux à la crème (EXH 16)", - "playtype": "Single" + "name": "AA+ either VALKYRIE ASSAULT (EXH 16) or choux à la crème (EXH 16)" }, { "charts": { @@ -2365,10 +2236,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gfe519c3456dff488fd95160e05e82566c09f994ef97daea3342ac39b7e5d73ff", - "name": "CLEAR 40 charts in the Level 16 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 40 charts in the Level 16 (EXCEED GEAR) folder" }, { "charts": { @@ -2381,10 +2251,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G60e9c01536ddd674ae7da090f6fed232d8de0358a0d253949d780133d12e5bf8", - "name": "EXCESSIVE CLEAR 15 charts in the Level 16 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 15 charts in the Level 16 (EXCEED GEAR) folder" }, { "charts": { @@ -2397,10 +2266,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Ga6d0023afac9a643d7bfa255e678433ff5d320d4b3de803df57e1385e97a781b", - "name": "CLEAR 5 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 5 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { @@ -2418,10 +2286,9 @@ "mode": "single", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G93ee06f15bec2deed7fb4c59221da49da778147972ebc070d3602f8beb78c13c", - "name": "CLEAR any one of D1g1t1ze b0dy (MXM 17), 逆さま♥シンデレラパレード (MXM 17), AXION (MXM 17), The Sampling Paradise (P*Light Remix) (EXH 17) or 黎明スケッチブック (MXM 17)", - "playtype": "Single" + "name": "CLEAR any one of D1g1t1ze b0dy (MXM 17), 逆さま♥シンデレラパレード (MXM 17), AXION (MXM 17), The Sampling Paradise (P*Light Remix) (EXH 17) or 黎明スケッチブック (MXM 17)" }, { "charts": { @@ -2434,10 +2301,9 @@ "mode": "absolute", "value": 3 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G2753016668b125fa4c054f8c5044e59ebee545a1cb4aa50f9a95af5e2afd600d", - "name": "ULTIMATE CHAIN 4 charts in the Level 14 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "ULTIMATE CHAIN 4 charts in the Level 14 (EXCEED GEAR) folder" }, { "charts": { @@ -2450,10 +2316,9 @@ "mode": "absolute", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Ga70c91a1d0c0240cfb3c0b3628b4b00e4250208335e09d2efd04cd137325d789", - "name": "S 4 charts in the Level 15 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "S 4 charts in the Level 15 (EXCEED GEAR) folder" }, { "charts": { @@ -2466,10 +2331,9 @@ "mode": "absolute", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G19bb3009f3b3af570179c740657bcd8ce468e0ae1a0cfddf138c31331657bdbc", - "name": "AAA+ 4 charts in the Level 16 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 4 charts in the Level 16 (EXCEED GEAR) folder" }, { "charts": { @@ -2486,10 +2350,9 @@ "mode": "single", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G9f3ef95935d9c7f41602019787938467d1fce5294f7c91850fe3d5840ee13fce", - "name": "AAA any one of Glorious Journey (EXH 16), Jack-the-Ripper◆ (EXH 16), 最終鬼畜妹フランドール・S (EXH 16) or KHAMEN BREAK (EXH 16)", - "playtype": "Single" + "name": "AAA any one of Glorious Journey (EXH 16), Jack-the-Ripper◆ (EXH 16), 最終鬼畜妹フランドール・S (EXH 16) or KHAMEN BREAK (EXH 16)" }, { "charts": { @@ -2502,14 +2365,17 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gf5463e029bcc05da520e5cd0a7964c2cbdc36618c46506212fd5488c22679391", - "name": "AAA 5 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 5 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11f23dafc55df", "C19d35e11e354caa941e", "C19d35e11e0ea3dc1118"], + "data": [ + "C19d35e11f23dafc55df", + "C19d35e11e354caa941e", + "C19d35e11e0ea3dc1118" + ], "type": "multi" }, "criteria": { @@ -2517,10 +2383,9 @@ "mode": "single", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G9c1ef5e70be5684ec6d0267e0fc0c526ed9d095338d7d9832ba4fa225daf12d1", - "name": "AA+ any one of GERBERA-For Finalists- (EXH 17), JOMANDA (EXH 17) or Destroy (GRV 17)", - "playtype": "Single" + "name": "AA+ any one of GERBERA-For Finalists- (EXH 17), JOMANDA (EXH 17) or Destroy (GRV 17)" }, { "charts": { @@ -2533,10 +2398,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G7a12b47023c873d34f89671f2dd3d967af1fbc190ca6565d3a9d4e47a764372f", - "name": "CLEAR 50 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 50 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { @@ -2549,10 +2413,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G4f2f1e9c58fe75fc1d4adb980abbd4ccef0c8e2a458eebd2dfe5c61e402a1ba4", - "name": "EXCESSIVE CLEAR 20 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 20 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { @@ -2564,10 +2427,9 @@ "mode": "single", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G7107e4419481cd400f06fe820457cfcc57868665d07f987ef19b129432a7ebf2", - "name": "CLEAR any chart in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR any chart in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -2585,10 +2447,9 @@ "mode": "single", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G6888d8c1566ce138427dfdd51ff1e7ebe5a7272b1ebf5ef88c119e0565f2d69c", - "name": "CLEAR any one of Booths of Fighters (EXH 17), Nofram (EXH 17), ULTRA B+K (MXM 17), Innocent Azure (MXM 18) or 狂水一華 (MXM 18)", - "playtype": "Single" + "name": "CLEAR any one of Booths of Fighters (EXH 17), Nofram (EXH 17), ULTRA B+K (MXM 17), Innocent Azure (MXM 18) or 狂水一華 (MXM 18)" }, { "charts": { @@ -2601,10 +2462,9 @@ "mode": "absolute", "value": 3 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G943e04101cb28a01426725ec45387a3a9de9bdbb999acaa0478f2e482c4da76b", - "name": "ULTIMATE CHAIN 5 charts in the Level 15 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "ULTIMATE CHAIN 5 charts in the Level 15 (EXCEED GEAR) folder" }, { "charts": { @@ -2617,10 +2477,9 @@ "mode": "absolute", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Ge8bef7d934fe71987f467f45f1c7d335062cc66470775c67f85fde23e8eea88a", - "name": "S 5 charts in the Level 16 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "S 5 charts in the Level 16 (EXCEED GEAR) folder" }, { "charts": { @@ -2633,14 +2492,17 @@ "mode": "absolute", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G202700527e32bf7de07b254e7b5c4a3629e8d15c428a5a1f22d2b20b21232a63", - "name": "AAA+ 5 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 5 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11de939fe3f5e", "C19d35e11da7a2b49a00", "C19d35e120185035c8e6"], + "data": [ + "C19d35e11de939fe3f5e", + "C19d35e11da7a2b49a00", + "C19d35e120185035c8e6" + ], "type": "multi" }, "criteria": { @@ -2648,10 +2510,9 @@ "mode": "single", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G247303e9e1b7a1c4863c6024ec6e9bba3e8da377cf8ea44b925b296c98594d7e", - "name": "AAA any one of CENSORED!! (EXH 17), UROBØROS (EXH 17) or Into The Madness (MXM 17)", - "playtype": "Single" + "name": "AAA any one of CENSORED!! (EXH 17), UROBØROS (EXH 17) or Into The Madness (MXM 17)" }, { "charts": { @@ -2664,14 +2525,17 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G09ee2358ba001fc10bf09472c2f4b1edcf4a512c9a77661baf3cd37464ec0584", - "name": "AAA 20 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 20 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11f8e16a9bb3a", "C19d35e1200edabf797a", "C19d35e11f6e613ef1d7"], + "data": [ + "C19d35e11f8e16a9bb3a", + "C19d35e1200edabf797a", + "C19d35e11f6e613ef1d7" + ], "type": "multi" }, "criteria": { @@ -2679,10 +2543,9 @@ "mode": "single", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G56caf3712a9a9805e1ee8eb7fe86673b64314baad10bbfc0e8c86b679051b444", - "name": "AA+ any one of She Turns Me On (GRV 18), Enchanté (MXM 18) or KIMIDORI Streak!! (MXM 18)", - "playtype": "Single" + "name": "AA+ any one of She Turns Me On (GRV 18), Enchanté (MXM 18) or KIMIDORI Streak!! (MXM 18)" }, { "charts": { @@ -2695,10 +2558,9 @@ "mode": "absolute", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G698f0bd2b5a3859478a49898f499bad6c84ec7989cec5822b5390ae33fcca96f", - "name": "AA+ 50 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AA+ 50 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { @@ -2711,10 +2573,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G65278d2891251faf37d35823634a41b75f05843239b9ec80961e1ffc99f023f2", - "name": "EXCESSIVE CLEAR 40 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 40 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { @@ -2727,10 +2588,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Ge7914262f1dabad65f3c12a74f2fe9dc72e622e35bac5a5ab7da601907687c8f", - "name": "CLEAR 80 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 80 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { @@ -2743,10 +2603,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G9096ca58f8aa719866a945404c0a03fdfb2ab9c6c0cd5628e8e82cfa22ca6162", - "name": "CLEAR 10 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 10 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -2759,10 +2618,9 @@ "mode": "absolute", "value": 3 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G780126a57153930857d9bb1e3a8aa071f60fe58bf3dbf11a49033b0f249fb483", - "name": "ULTIMATE CHAIN 5 charts in the Level 16 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "ULTIMATE CHAIN 5 charts in the Level 16 (EXCEED GEAR) folder" }, { "charts": { @@ -2775,14 +2633,17 @@ "mode": "absolute", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G2f368ae70e177729fbbe2b809198761ee4e374e8d64ecda15495df7a40e02cf2", - "name": "AAA+ 40 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 40 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11e8e7534f29c", "C19d35e11d3a7a55cb74", "C19d35e11cf85dbaf63d"], + "data": [ + "C19d35e11e8e7534f29c", + "C19d35e11d3a7a55cb74", + "C19d35e11cf85dbaf63d" + ], "type": "multi" }, "criteria": { @@ -2790,10 +2651,9 @@ "mode": "single", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G130f6090577b551ba3a65bb2da44e5807d407c41f9dac35aa4ed259e0eb21d8f", - "name": "AAA any one of HE4VEN ~天国へようこそ~ (EXH 17), TOXIC VIBRATION (EXH 17) or False Cross (EXH 17)", - "playtype": "Single" + "name": "AAA any one of HE4VEN ~天国へようこそ~ (EXH 17), TOXIC VIBRATION (EXH 17) or False Cross (EXH 17)" }, { "charts": { @@ -2806,14 +2666,17 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gc0447d29f102fc5bec6236e7cc2652568c0c32e01fcfb5f1fbc78d204b91b0d0", - "name": "AAA 15 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 15 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11dc9b7fe43ae", "C19d35e11f5114594be5", "C19d35e11ea4d05b7556"], + "data": [ + "C19d35e11dc9b7fe43ae", + "C19d35e11f5114594be5", + "C19d35e11ea4d05b7556" + ], "type": "multi" }, "criteria": { @@ -2821,14 +2684,17 @@ "mode": "single", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gf1dbcfa03d5e4496dea64d2093673b70762d6d66a3c5cc19cb329bd34d89841e", - "name": "AA+ any one of XROSS THE XOUL (GRV 18), Re:End of a Dream (MXM 18) or ΩVERSOUL (MXM 18)", - "playtype": "Single" + "name": "AA+ any one of XROSS THE XOUL (GRV 18), Re:End of a Dream (MXM 18) or ΩVERSOUL (MXM 18)" }, { "charts": { - "data": ["C19d35e11e92fa2b5166", "C19d35e11d4a7886bcae", "C19d35e11e2960cc06d3"], + "data": [ + "C19d35e11e92fa2b5166", + "C19d35e11d4a7886bcae", + "C19d35e11e2960cc06d3" + ], "type": "multi" }, "criteria": { @@ -2836,10 +2702,9 @@ "mode": "single", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G8980504c85d097d4b41a7a803ae476a050d2f82d00cb8c648feee4abc228d64c", - "name": "AA+ any one of Fin.ArcDeaR (MXM 19), Got more raves? (HVN 19) or 極圏 (HVN 19)", - "playtype": "Single" + "name": "AA+ any one of Fin.ArcDeaR (MXM 19), Got more raves? (HVN 19) or 極圏 (HVN 19)" }, { "charts": { @@ -2852,10 +2717,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G1ba1112c67f42f72661953a758f9d0fa41798e73337a2e42851bef711e489f92", - "name": "CLEAR 90 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 90 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -2868,10 +2732,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G0f51e650348ed098980ff3ef3a09392e01cc66279959e3e6dde3cc502ead527c", - "name": "EXCESSIVE CLEAR 30 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 30 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -2884,10 +2747,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G1a79132331135eca1bb77173e9ce844490b4624a7ecc5d7ffae02e40f4378404", - "name": "CLEAR 7 charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 7 charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { @@ -2904,10 +2766,9 @@ "mode": "single", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Ge06603c39c07860b82c2d49cfe4303c386e8bd88cfafaf991932963112fbdf39", - "name": "CLEAR any one of НУМЛ (MXM 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18), MeteorA (MXM 18) or SuperMiracleEnsemble (MXM 18)", - "playtype": "Single" + "name": "CLEAR any one of НУМЛ (MXM 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18), MeteorA (MXM 18) or SuperMiracleEnsemble (MXM 18)" }, { "charts": { @@ -2920,10 +2781,9 @@ "mode": "absolute", "value": 3 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G3bf0295c50cf8431f0d2c6aa13595d7dc4dcdf0738ed4288453a9047c3f1fe86", - "name": "ULTIMATE CHAIN 15 charts in the Level 16 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "ULTIMATE CHAIN 15 charts in the Level 16 (EXCEED GEAR) folder" }, { "charts": { @@ -2936,14 +2796,17 @@ "mode": "absolute", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G475ca03a09c5035de96d162dbae307e016cb16f14d7215817b5fbf613c88ae35", - "name": "S 15 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "S 15 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11eb0be169eed", "C19d35e11e1c4ce9fa55", "C19d35e120916329931b"], + "data": [ + "C19d35e11eb0be169eed", + "C19d35e11e1c4ce9fa55", + "C19d35e120916329931b" + ], "type": "multi" }, "criteria": { @@ -2951,10 +2814,9 @@ "mode": "single", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G044dccb6b25fc04451e9100bbfaa3b634dd3556c161f7b4a6c1da40d502913d6", - "name": "AAA+ any one of Poison AND÷OR Affection (MXM 17), DIABLOSIS::Nāga (EXH 17) or MilK (MXM 17)", - "playtype": "Single" + "name": "AAA+ any one of Poison AND÷OR Affection (MXM 17), DIABLOSIS::Nāga (EXH 17) or MilK (MXM 17)" }, { "charts": { @@ -2967,10 +2829,9 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G0a1437544cfd3bc60d98e11db00ec4aef55df24454e63fa986d6de3cbd9c02f7", - "name": "AAA 30 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 30 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -2983,14 +2844,17 @@ "mode": "absolute", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gfbf6843a90e7657e22782f9cee31c2e6448047a4afae1432d7dffc6b7c1f8305", - "name": "AAA+ 15 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 15 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11db98d00c586", "C19d35e11db31802a858", "C19d35e11ca41c6a9a26"], + "data": [ + "C19d35e11db98d00c586", + "C19d35e11db31802a858", + "C19d35e11ca41c6a9a26" + ], "type": "multi" }, "criteria": { @@ -2998,14 +2862,17 @@ "mode": "single", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G1bd1da343629dd0fa15bcfe24cc5cc0e67f0d67b255c9528b0428e47be9813ad", - "name": "AAA any one of REVOLVER (EXH 18), Demise Quartet (EXH 18) or Wish upon Twin Stars (GRV 18)", - "playtype": "Single" + "name": "AAA any one of REVOLVER (EXH 18), Demise Quartet (EXH 18) or Wish upon Twin Stars (GRV 18)" }, { "charts": { - "data": ["C19d35e11c4418d22289", "C19d35e11fbaf4bc2192", "C19d35e11f64f3ed2258"], + "data": [ + "C19d35e11c4418d22289", + "C19d35e11fbaf4bc2192", + "C19d35e11f64f3ed2258" + ], "type": "multi" }, "criteria": { @@ -3013,10 +2880,9 @@ "mode": "single", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G0515f7a6c498cc4ff2712a4178010910de0454c135b1fd0ea9710a96b019966a", - "name": "AA+ any one of Idola (HVN 18), Xevel (MXM 18) or Venomous Firefly (MXM 18)", - "playtype": "Single" + "name": "AA+ any one of Idola (HVN 18), Xevel (MXM 18) or Venomous Firefly (MXM 18)" }, { "charts": { @@ -3029,10 +2895,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Ge89577e221b6a55ff840aebd8f9ba403f6e219f9d034ea835e978292ec1cb479", - "name": "EXCESSIVE CLEAR 75 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 75 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -3045,10 +2910,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G8924c90d4678ac3d0495f6ad67ae4ea07c752a5a6d28b2629f26b8d98dbd9e5c", - "name": "CLEAR 35 charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 35 charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { @@ -3060,10 +2924,9 @@ "mode": "single", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G607d0a0684e41637139fef8ff230f41bb909942c9a94f57f7d0485a0628264ce", - "name": "EXCESSIVE CLEAR any chart in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR any chart in the Level 19 (EXCEED GEAR) folder" }, { "charts": { @@ -3075,10 +2938,9 @@ "mode": "single", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G72bbfc671c0f211355085393012b9835ac65d8491ff2431f878626f89df7eb07", - "name": "CLEAR any chart in the Level 20 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR any chart in the Level 20 (EXCEED GEAR) folder" }, { "charts": { @@ -3091,10 +2953,9 @@ "mode": "absolute", "value": 3 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G90bcbf690a855cc9be365f22c9fcfe47e1e73b158a2cc2ea228e14452825f354", - "name": "ULTIMATE CHAIN 10 charts in the Level 17 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "ULTIMATE CHAIN 10 charts in the Level 17 (EXCEED GEAR) folder" }, { "charts": { @@ -3107,10 +2968,9 @@ "mode": "absolute", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G6358c8004675bb53ae6bde30a8cd93735aa8fd123438cd93256d70fbd6f29cc6", - "name": "S 15 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "S 15 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -3123,10 +2983,9 @@ "mode": "absolute", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gfb2a810bd50e3d3813a6fbd939119c340bf250cfa9843b65f61bdc8301b74ef9", - "name": "AAA+ 50 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 50 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -3139,14 +2998,17 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G4a159b7973fabd82ea7e51bc96d58fc94df7fe15077769d711a155445b9eff63", - "name": "AAA 100 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 100 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e12093ed1d3609", "C19d35e11f256efc4a68", "C19d35e11fb0cc4f031b"], + "data": [ + "C19d35e12093ed1d3609", + "C19d35e11f256efc4a68", + "C19d35e11fb0cc4f031b" + ], "type": "multi" }, "criteria": { @@ -3154,10 +3016,9 @@ "mode": "single", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G259ab31e08b751f67b3fc496de2c4ae262274962ec5589e2e4a2880b70d96857", - "name": "AAA any one of ANGER of the GOD (MXM 19), A Lasting Promise (MXM 19) or Elemental Creation (MXM 19)", - "playtype": "Single" + "name": "AAA any one of ANGER of the GOD (MXM 19), A Lasting Promise (MXM 19) or Elemental Creation (MXM 19)" }, { "charts": { @@ -3174,10 +3035,9 @@ "mode": "single", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G899d13fe5225f7fca4cc5a3ad77afe02ef7f03a874d3ee2ac5a1a177b9240acf", - "name": "AA+ any one of OUTERHEΛVEN (MXM 19), For UltraPlayers (HVN 19), Bangin' Burst (GRV 19) or Dyscontrolled Galaxy (MXM 19)", - "playtype": "Single" + "name": "AA+ any one of OUTERHEΛVEN (MXM 19), For UltraPlayers (HVN 19), Bangin' Burst (GRV 19) or Dyscontrolled Galaxy (MXM 19)" }, { "charts": { @@ -3190,10 +3050,9 @@ "mode": "absolute", "value": 6 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G9540d88dcc938ebb0ef124b5f031eadc0966c826023e9719c59e349487cee00d", - "name": "AA+ 2 charts in the Level 20 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AA+ 2 charts in the Level 20 (EXCEED GEAR) folder" }, { "charts": { @@ -3212,10 +3071,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G1f9940b18b32059b73851cfd2d813ed952f6ae55a917fba2f2318091d5323df9", - "name": "CLEAR KAC 2012 ULTIMATE MEDLEY -HISTORIA SOUND VOLTEX- (GRV 18), Unleashed Redness (MXM 18), Verflucht (MXM 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18) and Joyeuse (VVD 18)", - "playtype": "Single" + "name": "CLEAR KAC 2012 ULTIMATE MEDLEY -HISTORIA SOUND VOLTEX- (GRV 18), Unleashed Redness (MXM 18), Verflucht (MXM 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18) and Joyeuse (VVD 18)" }, { "charts": { @@ -3228,10 +3086,9 @@ "mode": "proportion", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G00b9df1cc7183235c56613655d77b35df8a9716a4ff456b86629269fa579a1a4", - "name": "EXCESSIVE CLEAR 40% of the charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 40% of the charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -3244,14 +3101,16 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G465cbe195779daf0dd8449ead358f77dd3047c01538d8a8177fcca7f6c7732b9", - "name": "EXCESSIVE CLEAR 7 charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 7 charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11f82f2e7ec28", "C19d35e11f2c731fd2f4"], + "data": [ + "C19d35e11f82f2e7ec28", + "C19d35e11f2c731fd2f4" + ], "type": "multi" }, "criteria": { @@ -3259,10 +3118,9 @@ "mode": "single", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G9957c837ce04f4494df641b057cfc3161fdc8a4ec9898dc136548f46f75bc41e", - "name": "CLEAR either Xronièr (MXM 20) or I (MXM 20)", - "playtype": "Single" + "name": "CLEAR either Xronièr (MXM 20) or I (MXM 20)" }, { "charts": { @@ -3275,10 +3133,9 @@ "mode": "absolute", "value": 3 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G0f88e481ccc93429f62c7124103bb0d29c80eaf46265cb42ebf873fc1cded1ef", - "name": "ULTIMATE CHAIN 5 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "ULTIMATE CHAIN 5 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -3291,14 +3148,17 @@ "mode": "absolute", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gba598c9c26a325bb63a54c9fb6be633ef19f4c23e13f569204d3d586a4b6670e", - "name": "S 50 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "S 50 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e11edd1a77b160", "C19d35e11f95c08dff6e", "C19d35e11eff5f8f499e"], + "data": [ + "C19d35e11edd1a77b160", + "C19d35e11f95c08dff6e", + "C19d35e11eff5f8f499e" + ], "type": "multi" }, "criteria": { @@ -3306,10 +3166,9 @@ "mode": "single", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G13a74d151e58d9e4edea8c247804cacc51d435c831e355408ecfe4aef8131942", - "name": "AAA+ any one of Juggler's Maddness (MXM 18), ARROW RAIN (MXM 18) or BEAST BASS BOMB (MXM 18)", - "playtype": "Single" + "name": "AAA+ any one of Juggler's Maddness (MXM 18), ARROW RAIN (MXM 18) or BEAST BASS BOMB (MXM 18)" }, { "charts": { @@ -3327,10 +3186,9 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G6b92ca680c48f3df1ccf2772438f75abbcc1de394ec6c2ddd6e5fd7727cfef63", - "name": "AAA any 2 of V (MXM 18), Macuilxochitl (Latin Jazz Mix) (MXM 18), pique (MXM 18) or †渚の小悪魔ラヴリィ~レイディオ† (MXM 18)", - "playtype": "Single" + "name": "AAA any 2 of V (MXM 18), Macuilxochitl (Latin Jazz Mix) (MXM 18), pique (MXM 18) or †渚の小悪魔ラヴリィ~レイディオ† (MXM 18)" }, { "charts": { @@ -3343,10 +3201,9 @@ "mode": "absolute", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G011d1f17d3a353d69ad87465ea6962d8e83843c4f6cf0caf0e8a45792800ea09", - "name": "AAA+ 5 charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 5 charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { @@ -3359,10 +3216,9 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Ge11fd188ac70c54f6b37465777598f77980ab49565ab206118a5b85a73d47194", - "name": "AAA 25 charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 25 charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { @@ -3381,14 +3237,17 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G8bbc3e020dba18d3d294f2a51f722e1460b047324a80b15c44db4eb81de65729", - "name": "EXCESSIVE CLEAR any 4 of * Erm, could it be a Spatiotemporal ShockWAVE Syndrome...? (EXH 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18), croiX (INF 18), 神話に芽吹く (MXM 18) or 準備運動 (MXM 18)", - "playtype": "Single" + "name": "EXCESSIVE CLEAR any 4 of * Erm, could it be a Spatiotemporal ShockWAVE Syndrome...? (EXH 18), ムーニャポヨポヨスッポコニャーゴ (VVD 18), croiX (INF 18), 神話に芽吹く (MXM 18) or 準備運動 (MXM 18)" }, { "charts": { - "data": ["C19d35e120ba2a3794e5", "C19d35e11bf6ba32797b", "C19d35e11f2f083cda5a"], + "data": [ + "C19d35e120ba2a3794e5", + "C19d35e11bf6ba32797b", + "C19d35e11f2f083cda5a" + ], "type": "multi" }, "criteria": { @@ -3396,10 +3255,9 @@ "mode": "single", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G5882d96f0ef38eafb99037fdfc4e61a1fcc0038d5c19bef289f5ab055163f83d", - "name": "EXCESSIVE CLEAR any one of 色を喪った街 (MXM 19), Bangin' Burst (GRV 19) or Awakening (MXM 19)", - "playtype": "Single" + "name": "EXCESSIVE CLEAR any one of 色を喪った街 (MXM 19), Bangin' Burst (GRV 19) or Awakening (MXM 19)" }, { "charts": { @@ -3412,10 +3270,9 @@ "mode": "absolute", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G3a85c5204e9708532cf7f0c038bc6e65cfc929a40d090faaf361a07fb6b8bfa6", - "name": "CLEAR 100 charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 100 charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { @@ -3428,10 +3285,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G10db40bef5ef910ec3b247621889e66bc431220746a5e536c45d4f8bf0386f36", - "name": "EXCESSIVE CLEAR 35 charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 35 charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { @@ -3444,10 +3300,9 @@ "mode": "absolute", "value": 3 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G5234d7e017a9d520b2714f84bd66ff9ac28e04573e4afff04249bcef6ed7c0ed", - "name": "ULTIMATE CHAIN 25 charts in the Level 18 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "ULTIMATE CHAIN 25 charts in the Level 18 (EXCEED GEAR) folder" }, { "charts": { @@ -3460,10 +3315,9 @@ "mode": "absolute", "value": 9 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G10b47f5c3084d79bc92d45e616dcfb7dc3a7040aa3155fd49397352be6f5b6fa", - "name": "S 10 charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "S 10 charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { @@ -3476,14 +3330,17 @@ "mode": "proportion", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G582b57b4ce8cf63c44690d329d9e80f9701a841ca3f8c8dc291dbdcfb545a38d", - "name": "AAA+ 50% of the charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA+ 50% of the charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e121160373cd93", "C19d35e11edb93dff80c", "C19d35e11fdc89943ed9"], + "data": [ + "C19d35e121160373cd93", + "C19d35e11edb93dff80c", + "C19d35e11fdc89943ed9" + ], "type": "multi" }, "criteria": { @@ -3491,14 +3348,17 @@ "mode": "single", "value": 8 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G7d14143c6d71c5a9412d4ee87816eba1b926e1f9e282d482cf4c8f8c1bdaebb0", - "name": "AAA+ any one of VALKYRIE ASSAULT (MXM 19), セイレーン ~悲壮の竪琴~ (MXM 19) or TENKAICHI ULTIMATE BOSSRUSH MEDLEY (MXM 19)", - "playtype": "Single" + "name": "AAA+ any one of VALKYRIE ASSAULT (MXM 19), セイレーン ~悲壮の竪琴~ (MXM 19) or TENKAICHI ULTIMATE BOSSRUSH MEDLEY (MXM 19)" }, { "charts": { - "data": ["C19d35e11e1d061b96ca", "C19d35e11f838e8b8dc1", "C19d35e1210bfd434371"], + "data": [ + "C19d35e11e1d061b96ca", + "C19d35e11f838e8b8dc1", + "C19d35e1210bfd434371" + ], "type": "multi" }, "criteria": { @@ -3506,10 +3366,9 @@ "mode": "single", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G04fe718f0483fb71361bb3bd82d42674b25ca069823831c8907f52f7a68b88c0", - "name": "AAA any one of FLügeL《Λrp:ΣggyØ》 (GRV 19), Failnaught (MXM 19) or 飄える翼追い掛けて (MXM 19)", - "playtype": "Single" + "name": "AAA any one of FLügeL《Λrp:ΣggyØ》 (GRV 19), Failnaught (MXM 19) or 飄える翼追い掛けて (MXM 19)" }, { "charts": { @@ -3522,10 +3381,9 @@ "mode": "absolute", "value": 7 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G9c178aa1967a795a17a9eee9c84bbabd63b4cedc3223c5c6fd245839729718df", - "name": "AAA 4 charts in the Level 20 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "AAA 4 charts in the Level 20 (EXCEED GEAR) folder" }, { "charts": { @@ -3543,10 +3401,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G54b92c7e85b819464c7bb21b9c1665fc745c13f289ebe2946ea36bc6f0e074ac", - "name": "EXCESSIVE CLEAR Joyeuse (VVD 18), ツマミ戦隊 タテレンジャー (MXM 18), 超越してしまった彼女と其を生み落した理由 (HVN 18) and Re:Elemental Creation (MXM 18)", - "playtype": "Single" + "name": "EXCESSIVE CLEAR Joyeuse (VVD 18), ツマミ戦隊 タテレンジャー (MXM 18), 超越してしまった彼女と其を生み落した理由 (HVN 18) and Re:Elemental Creation (MXM 18)" }, { "charts": { @@ -3559,14 +3416,17 @@ "mode": "proportion", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G647d4f2a60cf456a392b6f062725978732d1723ef93b0cc2dc1bb75302e9479b", - "name": "EXCESSIVE CLEAR 80% of the charts in the Level 19 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 80% of the charts in the Level 19 (EXCEED GEAR) folder" }, { "charts": { - "data": ["C19d35e12099bc435ee4", "C19d35e1215439a4b677", "C19d35e12048a4a873fb"], + "data": [ + "C19d35e12099bc435ee4", + "C19d35e1215439a4b677", + "C19d35e12048a4a873fb" + ], "type": "multi" }, "criteria": { @@ -3574,10 +3434,9 @@ "mode": "single", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G1fb1cc0ee9612ead59f8792d4fe28ba527af5fecdea0b2198f9c7ac2c4d2abd4", - "name": "EXCESSIVE CLEAR any one of Xroniàl Xéro (MXM 19), 777 (MXM 19) or Ghost Family Living In Graveyard (MXM 19)", - "playtype": "Single" + "name": "EXCESSIVE CLEAR any one of Xroniàl Xéro (MXM 19), 777 (MXM 19) or Ghost Family Living In Graveyard (MXM 19)" }, { "charts": { @@ -3590,10 +3449,9 @@ "mode": "absolute", "value": 2 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "G5e9b6f5ab4339cbfcc62ab4f13f0ddb4c2576573e0f25f032602d11816acd846", - "name": "EXCESSIVE CLEAR 4 charts in the Level 20 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "EXCESSIVE CLEAR 4 charts in the Level 20 (EXCEED GEAR) folder" }, { "charts": { @@ -3606,9 +3464,8 @@ "mode": "proportion", "value": 1 }, - "game": "sdvx", + "game": "sdvx-single", "goalID": "Gf67e8bf457336de147cd7e3aa5fd27d3aaa33f057a66452c4a3eae73d73e0ef0", - "name": "CLEAR 100% of the charts in the Level 20 (EXCEED GEAR) folder", - "playtype": "Single" + "name": "CLEAR 100% of the charts in the Level 20 (EXCEED GEAR) folder" } ] diff --git a/db/seeds/questlines.json b/db/seeds/questlines.json index 98ba60ec3..28f3846a9 100644 --- a/db/seeds/questlines.json +++ b/db/seeds/questlines.json @@ -1,9 +1,8 @@ [ { "desc": "These are the official quests for IIDX SP. These aim to be generally useful, and don't focus too much on specific skills.", - "game": "iidx", + "game": "iidx-sp", "name": "Kamaitachi Official IIDX SP Questline", - "playtype": "SP", "questlineID": "kt-official-iidx-SP", "quests": [ "7f01dd4bb78537a37f0d06456c9153f960b1ab1c", @@ -20,9 +19,8 @@ }, { "desc": "These are the official quests for SDVX. These aim to be generally useful, and don't focus too much on specific skills. These were primarily created by Okami and Winter.", - "game": "sdvx", + "game": "sdvx-single", "name": "Kamaitachi Official SDVX Questline", - "playtype": "Single", "questlineID": "kt-official-sdvx", "quests": [ "559ab57794351e6eab54f2aaec0e27d52c6b3c49", diff --git a/db/seeds/quests.json b/db/seeds/quests.json index 06af4dca1..fec6f8638 100644 --- a/db/seeds/quests.json +++ b/db/seeds/quests.json @@ -1,9 +1,8 @@ [ { "desc": "These goals are around 13.1 KtLampRating and 5-10BPI.", - "game": "iidx", + "game": "iidx-sp", "name": "Post-Kaiden II", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -86,9 +85,8 @@ }, { "desc": "Good luck, it's only harder from here on. On the bright side, you don't have to play dans anymore. These goals are around 13.0 KtLampRating and 0BPI.", - "game": "iidx", + "game": "iidx-sp", "name": "Post-Kaiden I", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -171,9 +169,8 @@ }, { "desc": "These goals are around 13.2 KtLampRating and 10-20BPI.", - "game": "iidx", + "game": "iidx-sp", "name": "Post-Kaiden III", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -264,9 +261,8 @@ }, { "desc": "These goals are around 13.3 KtLampRating and 20-30BPI.", - "game": "iidx", + "game": "iidx-sp", "name": "Post-Kaiden IV", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -360,9 +356,8 @@ }, { "desc": "This is the end of the line, for now. Good luck.", - "game": "iidx", + "game": "iidx-sp", "name": "Post-Kaiden VI", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -433,9 +428,8 @@ }, { "desc": "These goals are around 13.5 KtLampRating and 30-50BPI.", - "game": "iidx", + "game": "iidx-sp", "name": "Post-Kaiden V", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -498,9 +492,8 @@ }, { "desc": "These goals are aimed at players roughly between Chuuden and Kaiden.", - "game": "iidx", + "game": "iidx-sp", "name": "Approaching Kaiden", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -595,9 +588,8 @@ }, { "desc": "These goals are aimed at players roughly between 10th Dan and Chuuden.", - "game": "iidx", + "game": "iidx-sp", "name": "Approaching Chuuden", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -672,9 +664,8 @@ }, { "desc": "These goals are aimed at players roughly between 9th and 10th Dan.", - "game": "iidx", + "game": "iidx-sp", "name": "Approaching 10th Dan", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -749,9 +740,8 @@ }, { "desc": "These goals are aimed at players roughly between 8th and 9th Dan. Simultaneously, you'll be breaking into 12s around this time. Good luck!", - "game": "iidx", + "game": "iidx-sp", "name": "Approaching 9th Dan", - "playtype": "SP", "questData": [ { "title": "Specific Clear Goals", @@ -824,9 +814,8 @@ }, { "desc": "These goals are aimed at players roughly between 13-14 VF.", - "game": "sdvx", + "game": "sdvx-single", "name": "Dandelion (15s-16s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", @@ -881,9 +870,8 @@ }, { "desc": "These goals are aimed at players roughly between 14-15 VF.", - "game": "sdvx", + "game": "sdvx-single", "name": "Cyan (16s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", @@ -938,9 +926,8 @@ }, { "desc": "These goals are aimed at players roughly between 15-16 VF.", - "game": "sdvx", + "game": "sdvx-single", "name": "Scarlet (17s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", @@ -994,9 +981,8 @@ }, { "desc": "These goals are aimed at players roughly between 16-17 VF.", - "game": "sdvx", + "game": "sdvx-single", "name": "Coral (Early 18s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", @@ -1051,9 +1037,8 @@ }, { "desc": "These goals are aimed at players roughly between 17-18 VF.", - "game": "sdvx", + "game": "sdvx-single", "name": "Argento (18s - Early 19s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", @@ -1112,9 +1097,8 @@ }, { "desc": "These goals are aimed at players roughly between 18-18.5 VF.", - "game": "sdvx", + "game": "sdvx-single", "name": "Eldora A (Mid-High 19s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", @@ -1173,9 +1157,8 @@ }, { "desc": "These goals are aimed at players roughly between 18.5-19 VF.", - "game": "sdvx", + "game": "sdvx-single", "name": "Eldora B (Early 20s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", @@ -1231,9 +1214,8 @@ }, { "desc": "These goals are aimed at players roughly between 19-19.5 VF.", - "game": "sdvx", + "game": "sdvx-single", "name": "Crimson A (20s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", @@ -1287,9 +1269,8 @@ }, { "desc": "This is the end of the road, for now.", - "game": "sdvx", + "game": "sdvx-single", "name": "Crimson B (Post-20s)", - "playtype": "Single", "questData": [ { "title": "Timing Goals", diff --git a/docs/docs/api/routes/import.md b/docs/docs/api/routes/import.md index 8ea1a048c..2080f44df 100644 --- a/docs/docs/api/routes/import.md +++ b/docs/docs/api/routes/import.md @@ -177,7 +177,7 @@ See previous example. This endpoint goes through all of the requesting user's [Orphanned Scores](../../codebase/import/orphans.md) and attempts to find them a parent song & chart. !!! note - Scores automatically attempt de-orphaning every day on Kamaitachi and Bokutachi, + Scores automatically attempt de-orphaning every day at 1 AM UTC on Kamaitachi and Bokutachi, this endpoint just allows you to force a deorphaning, should you wish to. ### Permissions diff --git a/typescript/bot/src/config.ts b/typescript/bot/src/config.ts index 502bbc744..0f99c1714 100644 --- a/typescript/bot/src/config.ts +++ b/typescript/bot/src/config.ts @@ -137,7 +137,9 @@ async function GetServerConfig() { } if (!parsed || typeof parsed !== "object" || !("success" in parsed)) { - log.error(`Server config response from ${url} was JSON but missing a success field. Can't run.`); + log.error( + `Server config response from ${url} was JSON but missing a success field. Can't run.`, + ); process.exit(1); } diff --git a/typescript/bot/vitest.setup.ts b/typescript/bot/vitest.setup.ts index 0af114447..fb4c07b0b 100644 --- a/typescript/bot/vitest.setup.ts +++ b/typescript/bot/vitest.setup.ts @@ -38,6 +38,7 @@ globalThis.fetch = (url: string | Request | URL): Promise => { NAME: "Test Tachi", TYPE: "boku", SIGNUPS_ENABLED: true, + QUEST_PROPOSALS_ENABLED: false, GAME_GROUPS: ["iidx"], IMPORT_TYPES: [...allImportTypes], }, diff --git a/typescript/client/src/app/pages/dashboard/proposals/MyProposalsPage.tsx b/typescript/client/src/app/pages/dashboard/proposals/MyProposalsPage.tsx new file mode 100644 index 000000000..609401ef2 --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/proposals/MyProposalsPage.tsx @@ -0,0 +1,220 @@ +/** + * MyProposalsPage — shows the logged-in user's own quest proposals, + * with edit and withdraw capabilities. + */ + +import type { UnsuccessfulAPIResponse } from "tachi-common"; + +import useSetSubheader from "#components/layout/header/useSetSubheader"; +import LoadingWrapper from "#components/util/LoadingWrapper"; +import { UserContext } from "#context/UserContext"; +import { TachiConfig } from "#lib/config"; +import { APIFetchV1 } from "#util/api"; +import React, { useCallback, useContext, useEffect, useState } from "react"; +import { Alert, Badge, Button, Card, Col, Modal, Row } from "react-bootstrap"; +import { Link, Redirect } from "react-router-dom"; + +type MyProposal = { + createdAt: string; + prNumber: number; + proposalID: string; + prUrl: string; + rawQuestlines: unknown; + rawQuests: unknown; + status: string; + updatedAt: string; +}; + +type MyProposalsResponse = { + proposals: Array; +}; + +function statusBadge(status: string) { + switch (status) { + case "open": + return Open; + case "merged": + return Merged; + case "closed": + return Closed; + default: + return {status}; + } +} + +export default function MyProposalsPage() { + useSetSubheader(["My Quest Proposals"]); + + const { user } = useContext(UserContext); + + if (!user) { + return ; + } + + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + const reload = useCallback(() => { + setData(null); + setError(null); + + APIFetchV1("/proposals/mine").then((res) => { + if (!res.success) { + setError(res); + return; + } + + setData(res.body); + }); + }, []); + + useEffect(() => { + reload(); + }, [reload]); + + return ( + <> +
+

My Quest Proposals

+ {TachiConfig.QUEST_PROPOSALS_ENABLED && ( + + + New Proposal + + )} +
+ + + <> + {data?.proposals.length === 0 ? ( +

+ You haven't submitted any proposals yet. + {TachiConfig.QUEST_PROPOSALS_ENABLED && ( + <> + {" "} + Create one in the Quest Editor! + + )} +

+ ) : ( + + {data?.proposals.map((proposal) => ( + + + + ))} + + )} + +
+ + ); +} + +function MyProposalCard({ + proposal, + onWithdrawn, +}: { + onWithdrawn: () => void; + proposal: MyProposal; +}) { + const [withdrawing, setWithdrawing] = useState(false); + const [withdrawErr, setWithdrawErr] = useState(null); + const [showConfirm, setShowConfirm] = useState(false); + + const questNames = Array.isArray(proposal.rawQuests) + ? (proposal.rawQuests as Array<{ name: string }>).map((q) => q.name).join(", ") + : "Quest Proposal"; + + const handleWithdraw = async () => { + setWithdrawErr(null); + setWithdrawing(true); + + try { + const res = await APIFetchV1(`/proposals/${proposal.proposalID}`, { + method: "DELETE", + }); + + if (!res.success) { + setWithdrawErr(res.description); + return; + } + + setShowConfirm(false); + onWithdrawn(); + } catch (e) { + setWithdrawErr((e as Error).message); + } finally { + setWithdrawing(false); + } + }; + + return ( + <> + + +
+ PR #{proposal.prNumber} + {statusBadge(proposal.status)} +
+ +
{questNames}
+ +

+ Submitted {new Date(proposal.createdAt).toLocaleDateString()} + {proposal.updatedAt !== proposal.createdAt && ( + <> + {" · "}Updated {new Date(proposal.updatedAt).toLocaleDateString()} + + )} +

+ +
+ + View on GitHub + + + {proposal.status === "open" && ( + + )} +
+
+
+ + setShowConfirm(false)} show={showConfirm}> + + Withdraw Proposal? + + +

+ This will close PR #{proposal.prNumber} and mark the proposal as withdrawn. + This cannot be undone. +

+ {withdrawErr && {withdrawErr}} +
+ + + + +
+ + ); +} diff --git a/typescript/client/src/app/pages/dashboard/proposals/ProposalsPage.tsx b/typescript/client/src/app/pages/dashboard/proposals/ProposalsPage.tsx new file mode 100644 index 000000000..b97bd1ada --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/proposals/ProposalsPage.tsx @@ -0,0 +1,153 @@ +/** + * ProposalsPage — public listing of all open quest proposals. + */ + +import type { UnsuccessfulAPIResponse } from "tachi-common"; + +import useSetSubheader from "#components/layout/header/useSetSubheader"; +import LoadingWrapper from "#components/util/LoadingWrapper"; +import { TachiConfig } from "#lib/config"; +import { APIFetchV1 } from "#util/api"; +import React, { useEffect, useState } from "react"; +import { Badge, Card, Col, Row } from "react-bootstrap"; +import { Link } from "react-router-dom"; + +type ProposalSummary = { + createdAt: string; + prNumber: number; + proposalID: string; + prUrl: string; + quests: Array<{ game: string; name: string }>; + status: string; + submitterUsername: string; +}; + +type ProposalsResponse = { + page: number; + proposals: Array; +}; + +function statusBadge(status: string) { + switch (status) { + case "open": + return Open; + case "merged": + return Merged; + case "closed": + return Closed; + default: + return {status}; + } +} + +export default function ProposalsPage() { + useSetSubheader(["Community", "Quest Proposals"]); + + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [page, setPage] = useState(0); + + useEffect(() => { + setData(null); + setError(null); + + APIFetchV1(`/proposals?page=${page}`).then((res) => { + if (!res.success) { + setError(res); + return; + } + + setData(res.body); + }); + }, [page]); + + return ( + <> +

Community Quest Proposals

+

+ Quest proposals submitted by the community for review. + {TachiConfig.QUEST_PROPOSALS_ENABLED ? ( + <> + {" "} + Open a PR to add your own quests via the Quest Editor. + + ) : ( + <> Open a PR on the seeds repository to add your own quests. + )} +

+ + + <> + {data?.proposals.length === 0 && page === 0 ? ( +

No open proposals yet. Be the first!

+ ) : ( + + {data?.proposals.map((p) => ( + + + + ))} + + )} + +
+ {page > 0 && ( + + )} + {data && data.proposals.length === 20 && ( + + )} +
+ +
+ + ); +} + +function ProposalCard({ proposal }: { proposal: ProposalSummary }) { + return ( + + +
+ PR #{proposal.prNumber} + {statusBadge(proposal.status)} +
+ +
{proposal.quests.map((q) => q.name).join(", ")}
+ +

+ {proposal.quests.map((q) => q.game).join(", ")} +

+ +

+ Submitted by{" "} + + {proposal.submitterUsername} + {" "} + on {new Date(proposal.createdAt).toLocaleDateString()} +

+ + + View PR on GitHub + +
+
+ ); +} diff --git a/typescript/client/src/app/pages/dashboard/users/UserOrphansPage.tsx b/typescript/client/src/app/pages/dashboard/users/UserOrphansPage.tsx index 8c3a6b341..57377e185 100644 --- a/typescript/client/src/app/pages/dashboard/users/UserOrphansPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/UserOrphansPage.tsx @@ -164,7 +164,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })

When an import cannot match a song or chart (SongOrChartNotFound), Tachi still stores that datapoint as an orphan. Orphans are retried - automatically around 00:01 UTC each day, or you can run a full + automatically around 1 AM UTC each day, or you can run a full reprocess below.

diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/UGPTGoalsPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/UGPTGoalsPage.tsx index e8ff95ce7..12decb9ca 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/UGPTGoalsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/UGPTGoalsPage.tsx @@ -1,5 +1,5 @@ -import GoalBuilder from "#components/targets/GoalBuilder"; import DeleteGoalsModal from "#components/targets/DeleteGoalsModal"; +import GoalBuilder from "#components/targets/GoalBuilder"; import GoalSubInfo from "#components/targets/GoalSubInfo"; import ApiError from "#components/util/ApiError"; import Divider from "#components/util/Divider"; @@ -14,7 +14,7 @@ import { CreateGoalSubDataset } from "#util/data"; import React, { useContext, useReducer, useState } from "react"; import { Button, Col, Modal } from "react-bootstrap"; import { Link } from "react-router-dom"; -import { type GoalDocument, FormatGame } from "tachi-common"; +import { FormatGame, type GoalDocument } from "tachi-common"; export default function UGPTGoalsPage({ reqUser, game }: UGPT) { const [showAdd, setShowAdd] = useState(false); @@ -40,7 +40,9 @@ export default function UGPTGoalsPage({ reqUser, game }: UGPT) { const userMap = new Map([[reqUser.id, reqUser]]); const dataset = CreateGoalSubDataset(data, userMap); - const handleAddGoal = async (rawGoal: { goal: Pick }) => { + const handleAddGoal = async (rawGoal: { + goal: Pick; + }) => { await APIFetchV1( `/users/${reqUser.id}/games/${game}/targets/goals/add-goal`, { @@ -120,11 +122,7 @@ export default function UGPTGoalsPage({ reqUser, game }: UGPT) { Add New Goal - + diff --git a/typescript/client/src/app/pages/dashboard/utils/QuestEditor.tsx b/typescript/client/src/app/pages/dashboard/utils/QuestEditor.tsx index a597ccdce..f82cf6b15 100644 --- a/typescript/client/src/app/pages/dashboard/utils/QuestEditor.tsx +++ b/typescript/client/src/app/pages/dashboard/utils/QuestEditor.tsx @@ -15,17 +15,19 @@ import EditableQuest from "#components/targets/quests/editor/EditableQuest"; import Divider from "#components/util/Divider"; import EditableText from "#components/util/EditableText"; import Icon from "#components/util/Icon"; -import Muted from "#components/util/Muted"; +import { UserContext } from "#context/UserContext"; import { TachiConfig } from "#lib/config"; import { type RawQuestDocument, type RawQuestlineDocument } from "#types/tachi"; +import { APIFetchV1 } from "#util/api"; import { ChangeAtPosition, DeleteInPosition } from "#util/misc"; import { p, type PrudenceSchema } from "prudence"; -import React, { useEffect, useMemo, useState } from "react"; -import { Alert, Badge, Button, Col, Form, Modal, Row } from "react-bootstrap"; +import React, { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { Alert, Badge, Button, Col, Form, Modal, Row, Spinner } from "react-bootstrap"; import { + ALL_GAMES, FormatGame, - type GameGroup, FormatPrError, + type GameGroup, GetGameGroupConfig, LEGACY_GameGroupPTToGame, type LEGACY_GPTString, @@ -43,16 +45,8 @@ const LOCAL_QUESTLINE_KEY = "LOCAL_QUESTLINES"; const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = { json: [ { - game: p.isIn(TachiConfig.GAME_GROUPS), - playtype: (self, parent) => { - const gameConfig = GetGameGroupConfig(parent.game as GameGroup); - - if (!(gameConfig.playtypes as ReadonlyArray).includes(self)) { - return `Invalid playtype '${String(self)}' for ${String(parent.game)}`; - } - - return true; - }, + /** V3 game id (e.g. `iidx-sp`), matching {@link RawQuestDocument}. */ + game: p.isIn(ALL_GAMES), name: "string", desc: "string", rawQuestData: [ @@ -140,8 +134,25 @@ function downloadJson(data: unknown, filename: string) { // ─── Main component ─────────────────────────────────────────────────────────── +// ─── Proposal types (mirrors server API response) ──────────────────────────── + +type MyProposal = { + createdAt: string; + prNumber: number; + proposalID: string; + prUrl: string; + rawQuestlines: Array; + rawQuests: Array; + status: string; + updatedAt: string; +}; + +// ─── Main component ─────────────────────────────────────────────────────────── + export default function QuestEditor() { - useSetSubheader(["Developer Utils", "Quest & Questline Editor"]); + useSetSubheader(["Quest Editor"]); + + const { user } = useContext(UserContext); const initQuests = useMemo(() => loadLocalQuests(), []); const initQuestlines = useMemo(() => loadLocalQuestlines(), []); @@ -152,6 +163,35 @@ export default function QuestEditor() { const [showImportQuests, setShowImportQuests] = useState(false); const [showImportQuestlines, setShowImportQuestlines] = useState(false); + const [showSubmitModal, setShowSubmitModal] = useState(false); + + // When non-null, we're updating an existing proposal rather than creating one + const [editingProposal, setEditingProposal] = useState(null); + + // My proposals list + const [proposals, setProposals] = useState | null>(null); + const [proposalsLoading, setProposalsLoading] = useState(false); + + // ── Fetch my proposals ───────────────────────────────────────────────────── + const fetchProposals = useCallback(() => { + if (!user) { + return; + } + + setProposalsLoading(true); + + APIFetchV1<{ proposals: Array }>("/proposals/mine").then((res) => { + setProposalsLoading(false); + + if (res.success) { + setProposals(res.body.proposals); + } + }); + }, [user]); + + useEffect(() => { + fetchProposals(); + }, [fetchProposals]); // ── Persist to localStorage on every change ──────────────────────────────── useEffect(() => { @@ -163,7 +203,7 @@ export default function QuestEditor() { }, [questlines]); // ── Derived ──────────────────────────────────────────────────────────────── - const selectedQuest = selectedQuestIdx !== null ? quests[selectedQuestIdx] ?? null : null; + const selectedQuest = selectedQuestIdx !== null ? (quests[selectedQuestIdx] ?? null) : null; const addQuest = (gptString: LEGACY_GPTString) => { const [game, playtype] = gptString.split(":") as [GameGroup, LEGACY_Playtype]; @@ -183,14 +223,81 @@ export default function QuestEditor() { }); }; + const loadProposalIntoEditor = (proposal: MyProposal) => { + if (quests.length > 0 || questlines.length > 0) { + if ( + !confirm( + "This will replace your current editor contents with the proposal's quests. Continue?", + ) + ) { + return; + } + } + + setQuests(proposal.rawQuests); + setQuestlines(proposal.rawQuestlines); + setSelectedQuestIdx(null); + setEditingProposal(proposal); + }; + + const clearEditingProposal = () => { + setEditingProposal(null); + }; + + const handleAfterSubmit = () => { + setEditingProposal(null); + fetchProposals(); + }; + return ( -

Quest & Questline Editor

-

- Build quests and questlines locally, then download the JSON files and submit - them as a PR to be included on the site. -

+
+
+

Quest & Questline Editor

+

+ Build quests locally and submit them to the community. +

+
+ {user && quests.length > 0 && ( + user.canSubmitQuests ? ( + + ) : ( + + {" "} + Want to submit quests to the community? Ask an admin to grant you + quest-submitter access. + + ) + )} +
+ + {/* Editing-proposal banner */} + {editingProposal && ( + + + {" "} + Editing{" "} + + PR #{editingProposal.prNumber} + + {" — "}make your changes, then click Update PR. + + + + )} + @@ -233,6 +340,7 @@ export default function QuestEditor() { if (confirm("Delete all quests and start over?")) { setQuests([]); setSelectedQuestIdx(null); + setEditingProposal(null); } }} size="sm" @@ -276,10 +384,10 @@ export default function QuestEditor() { setQuestlines((prev) => [...prev, ql])} + onDelete={(idx) => setQuestlines(DeleteInPosition(questlines, idx))} onUpdate={(updated, idx) => setQuestlines(ChangeAtPosition(questlines, updated, idx)) } - onDelete={(idx) => setQuestlines(DeleteInPosition(questlines, idx))} questlines={questlines} quests={quests} /> @@ -321,6 +429,20 @@ export default function QuestEditor() { + {/* ── My Proposals panel ────────────────────────────────────────── */} + {user && ( + + + + + )} + {/* Import modals */} {showImportQuests && ( setQuestlines(data as RawQuestlineDocument[])} /> )} + {showSubmitModal && ( + setShowSubmitModal(false)} + questlines={questlines} + quests={quests} + /> + )} ); } +// ─── MyProposalsPanel ───────────────────────────────────────────────────────── + +function statusBadge(status: string) { + switch (status) { + case "open": + return Open; + case "merged": + return Merged; + case "closed": + return Closed; + default: + return {status}; + } +} + +function MyProposalsPanel({ + proposals, + loading, + editingProposalID, + onLoad, + onWithdrawn, +}: { + editingProposalID: string | null; + loading: boolean; + onLoad: (p: MyProposal) => void; + onWithdrawn: () => void; + proposals: Array | null; +}) { + const [withdrawingID, setWithdrawingID] = useState(null); + + const handleWithdraw = async (proposal: MyProposal) => { + if (!confirm(`Withdraw PR #${proposal.prNumber}? This will close it on GitHub.`)) { + return; + } + + setWithdrawingID(proposal.proposalID); + + try { + const res = await APIFetchV1(`/proposals/${proposal.proposalID}`, { + method: "DELETE", + }); + + if (!res.success) { + alert(`Failed to withdraw: ${res.description}`); + } else { + onWithdrawn(); + } + } finally { + setWithdrawingID(null); + } + }; + + return ( +
+
+
My Proposals
+ {loading && } +
+ + {proposals === null && !loading && ( +

+ Could not load proposals — are proposals enabled on this instance? +

+ )} + + {proposals !== null && proposals.length === 0 && ( +

+ No proposals yet. Build some quests and click{" "} + Submit to Community! +

+ )} + + {proposals !== null && proposals.length > 0 && ( +
+ {proposals.map((proposal) => { + const questNames = proposal.rawQuests.map((q) => q.name).join(", "); + const isEditing = editingProposalID === proposal.proposalID; + + return ( +
+
+ + PR #{proposal.prNumber} + + {statusBadge(proposal.status)} +
+ +

+ {questNames} +

+ +

+ {proposal.rawQuests[0] + ? FormatGame(proposal.rawQuests[0].game as V3Game) + : ""}{" "} + · {new Date(proposal.createdAt).toLocaleDateString()} + {proposal.updatedAt !== proposal.createdAt && ( + <> + {" · "}updated{" "} + {new Date(proposal.updatedAt).toLocaleDateString()} + + )} +

+ +
+ + GitHub + + + {proposal.status === "open" && ( + <> + {isEditing ? ( + + Editing… + + ) : ( + + )} + + + + )} +
+
+ ); + })} +
+ )} +
+ ); +} + // ─── QuestList ──────────────────────────────────────────────────────────────── function QuestList({ @@ -359,16 +650,15 @@ function QuestList({ }) { const [gpt, setGpt] = useState(null); - const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = TachiConfig.GAME_GROUPS.flatMap( - (gameGroup) => { + const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = + TachiConfig.GAME_GROUPS.flatMap((gameGroup) => { const config = GetGameGroupConfig(gameGroup); return config.playtypes.map((pt) => ({ value: `${gameGroup}:${pt}` as LEGACY_GPTString, label: FormatGame(LEGACY_GameGroupPTToGame(gameGroup, pt)), })); - }, - ); + }); return (
@@ -378,18 +668,14 @@ function QuestList({ {quests.map((quest, i) => ( ))} @@ -442,18 +728,17 @@ function QuestlineComposer({ quests: Array; }) { const [newName, setNewName] = useState(""); - const [newGame, setNewGame] = useState(""); + const [newGame, setNewGame] = useState<"" | LEGACY_GPTString>(""); - const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = TachiConfig.GAME_GROUPS.flatMap( - (gameGroup) => { + const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = + TachiConfig.GAME_GROUPS.flatMap((gameGroup) => { const config = GetGameGroupConfig(gameGroup); return config.playtypes.map((pt) => ({ value: `${gameGroup}:${pt}` as LEGACY_GPTString, label: FormatGame(LEGACY_GameGroupPTToGame(gameGroup, pt)), })); - }, - ); + }); return (
@@ -466,8 +751,8 @@ function QuestlineComposer({ key={idx} onDelete={() => onDelete(idx)} onUpdate={(updated) => onUpdate(updated, idx)} - quests={quests} questline={ql} + quests={quests} /> ))} @@ -507,6 +792,8 @@ function QuestlineComposer({ LEGACY_Playtype, ]; + const v3Game = LEGACY_GameGroupPTToGame(gameGroup, playtype); + const slug = newName .trim() .toLowerCase() @@ -517,8 +804,7 @@ function QuestlineComposer({ questlineID: `${slug}-${Date.now()}`, name: newName.trim(), desc: "", - game: gameGroup, - playtype, + game: v3Game, quests: [], }); @@ -547,9 +833,7 @@ function QuestlineCard({ questline: RawQuestlineDocument; quests: Array; }) { - const availableQuests = quests.filter( - (q) => !questline.quests.includes(q.name), - ); + const availableQuests = quests.filter((q) => !questline.quests.includes(q.name)); const addQuestByName = (name: string) => { if (!questline.quests.includes(name)) { @@ -577,20 +861,22 @@ function QuestlineCard({
- onUpdate({ ...questline, name })} - placeholderText="Questline name" - /> + onUpdate({ ...questline, name })} + placeholderText="Questline name" + /> onUpdate({ ...questline, desc })} placeholderText="Description…" /> - {questline.game} / {questline.playtype} + + {questline.game} +
+ + + )} + + ); +} + // ─── ImportJsonModal ────────────────────────────────────────────────────────── function ImportJsonModal({ diff --git a/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx b/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx deleted file mode 100644 index 7c86fc736..000000000 --- a/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect } from "react"; - -const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined; - -export default function SeedsViewer() { - useEffect(() => { - if (SEEDS_URL) { - window.location.replace(SEEDS_URL); - } - }, []); - - return null; -} diff --git a/typescript/client/src/app/routes/DashboardRoutes.tsx b/typescript/client/src/app/routes/DashboardRoutes.tsx index c0cdbc1ed..da698d5e8 100644 --- a/typescript/client/src/app/routes/DashboardRoutes.tsx +++ b/typescript/client/src/app/routes/DashboardRoutes.tsx @@ -1,3 +1,5 @@ +import MyProposalsPage from "#app/pages/dashboard/proposals/MyProposalsPage"; +import ProposalsPage from "#app/pages/dashboard/proposals/ProposalsPage"; import SearchPage from "#app/pages/dashboard/search/SearchPage"; import NotificationsPage from "#app/pages/dashboard/users/NotificationsPage"; import { ErrorPage } from "#app/pages/ErrorPage"; @@ -8,11 +10,13 @@ import DashboardErrorBoundary from "#components/util/DashboardErrorBoundary"; import { BackgroundContext } from "#context/BackgroundContext"; import { BannedContext } from "#context/BannedContext"; import { UserContext } from "#context/UserContext"; +import { TachiConfig } from "#lib/config"; import { APIFetchV1, ToAPIURL } from "#util/api"; import React, { useContext, useEffect, useState } from "react"; import { Redirect, Route, Switch } from "react-router-dom"; import { DashboardPage } from "../pages/dashboard/DashboardPage"; +import QuestEditor from "../pages/dashboard/utils/QuestEditor"; import AdminRoutes from "./AdminRoutes"; import GameRoutes from "./GameRoutes"; import ImportRoutes from "./ImportRoutes"; @@ -20,6 +24,14 @@ import { RedirectLegacyUserRoutes } from "./RedirectLegacyRoutes"; import UserRoutes from "./UserRoutes"; import UtilRoutes from "./UtilRoutes"; +function QuestEditorRoute() { + if (!TachiConfig.QUEST_PROPOSALS_ENABLED) { + return ; + } + + return ; +} + export default function DashboardRoutes() { const { user } = useContext(UserContext); const { banned } = useContext(BannedContext); @@ -116,10 +128,30 @@ export default function DashboardRoutes() { + + + + + + + + + + + + + + + + + + + + diff --git a/typescript/client/src/app/routes/UtilRoutes.tsx b/typescript/client/src/app/routes/UtilRoutes.tsx index 5d2447946..9e7d5516e 100644 --- a/typescript/client/src/app/routes/UtilRoutes.tsx +++ b/typescript/client/src/app/routes/UtilRoutes.tsx @@ -1,23 +1,13 @@ import ImportAnalysers from "#app/pages/dashboard/utils/ImportAnalysers"; -import QuestEditor from "#app/pages/dashboard/utils/QuestEditor"; -import SeedsViewer from "#app/pages/dashboard/utils/SeedsViewer"; import React from "react"; import { Route, Switch } from "react-router-dom"; export default function UtilRoutes() { return ( - - - - - - - - ); } diff --git a/typescript/client/src/components/game/targets/QuestsPage.tsx b/typescript/client/src/components/game/targets/QuestsPage.tsx index 72565c717..aa17947e9 100644 --- a/typescript/client/src/components/game/targets/QuestsPage.tsx +++ b/typescript/client/src/components/game/targets/QuestsPage.tsx @@ -6,6 +6,7 @@ import ApiError from "#components/util/ApiError"; import Divider from "#components/util/Divider"; import Loading from "#components/util/Loading"; import useApiQuery from "#components/util/query/useApiQuery"; +import { TachiConfig } from "#lib/config"; import { type GamePT } from "#types/react"; import { CreateGoalMap } from "#util/data"; import React from "react"; @@ -48,9 +49,16 @@ function QuestlineSelector({ game }: GamePT) {
- Looks like this game has no quests. If you want, you could{" "} - create your own, and submit them in the - discord! + Looks like this game has no quests. + {TachiConfig.QUEST_PROPOSALS_ENABLED ? ( + <> + {" "} + If you want, you could create your own, and + submit them in the discord! + + ) : ( + <> You can suggest new quests in the discord! + )}
diff --git a/typescript/client/src/components/imports/ImportInfo.tsx b/typescript/client/src/components/imports/ImportInfo.tsx index 1942e1eeb..765d482cb 100644 --- a/typescript/client/src/components/imports/ImportInfo.tsx +++ b/typescript/client/src/components/imports/ImportInfo.tsx @@ -130,7 +130,7 @@ export default function ImportInfo({ are matched with data, all we have to display might be a hash.
SongOrChartNotFound means the score was still{" "} - saved as an orphan for nightly matching (around 00:01 + saved as an orphan for nightly matching (around 1 AM UTC) or manual reprocess - see{" "} {user ? ( Orphan scores diff --git a/typescript/client/src/components/layout/header/HeaderMenu.tsx b/typescript/client/src/components/layout/header/HeaderMenu.tsx index 095b9a4b2..ac9ddd430 100644 --- a/typescript/client/src/components/layout/header/HeaderMenu.tsx +++ b/typescript/client/src/components/layout/header/HeaderMenu.tsx @@ -2,15 +2,16 @@ import useApiQuery from "#components/util/query/useApiQuery"; import { AllLUGPTStatsContext } from "#context/AllLUGPTStatsContext"; import { UserContext } from "#context/UserContext"; import { UserSettingsContext } from "#context/UserSettingsContext"; +import { TachiConfig } from "#lib/config"; import { type SetState } from "#types/react"; import React, { useContext, useEffect } from "react"; import Nav from "react-bootstrap/Nav"; +import { NavLink } from "react-router-dom"; import { type UserGameStats } from "tachi-common"; import GlobalInfoDropdown from "./GlobalInfoDropdown"; import ImportScoresDropdown from "./ImportScoresDropdown"; import UGPTDropdown from "./UGPTDropdown"; -import UtilsDropdown from "./UtilsDropdown"; const toggleClassNames = "w-100 justify-content-between"; const menuClassNames = "shadow-none shadow-lg-lg"; @@ -71,13 +72,25 @@ export function HeaderMenu({ style={dropdownMenuStyle} /> )} + {TachiConfig.QUEST_PROPOSALS_ENABLED && ( + setState?.(false)} + to="/quests" + > + Quest Editor + + )} {settings?.preferences.developerMode && ( - + setState?.(false)} + to="/utils/imports" + > + Import Management + )} ); diff --git a/typescript/client/src/components/layout/header/UtilsDropdown.tsx b/typescript/client/src/components/layout/header/UtilsDropdown.tsx deleted file mode 100644 index b2d95feeb..000000000 --- a/typescript/client/src/components/layout/header/UtilsDropdown.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import DropdownNavLink from "#components/ui/DropdownNavLink"; -import QuickDropdown from "#components/ui/QuickDropdown"; -import { type SetState } from "#types/react"; -import React from "react"; -import DropdownItem from "react-bootstrap/DropdownItem"; - -const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined; - -function UtilsLinks({ onClick }: { onClick: React.MouseEventHandler }) { - return ( - <> - - Seeds Management - - - Import Management - - - Quest Creator - - - ); -} - -export default function UtilsDropdown({ - className, - menuClassName, - style, - setState, -}: { - className?: string; - menuClassName?: string; - setState?: SetState; - style?: React.CSSProperties; -}) { - const handleClick = () => { - setState?.(false); - }; - return ( - - - - ); -} diff --git a/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx b/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx index 8b8057f58..a0baff838 100644 --- a/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx @@ -4,19 +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 { - type GameGroup, - type GoalDocument, - LEGACY_FormatGameGroupPT, - type LEGACY_Playtypes, -} from "tachi-common"; +import { FormatGame, type GoalDocument, type V3Game } 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)], + ["Game", "Game", StrSOV((x) => x.game)], ["Goals", "Goals"], ]; @@ -24,11 +19,7 @@ export const SeedsQuestSearchFns: SearchFunctions = { name: (x) => x.name, questID: (x) => x.questID, game: (x) => x.game, - gpt: (x) => - LEGACY_FormatGameGroupPT( - x.game as GameGroup, - x.playtype as LEGACY_Playtypes[typeof x.game], - ), + gpt: (x) => FormatGame(x.game as V3Game), goals: (x) => Object.values(x.__related.goals) .map((e) => e!.name) @@ -48,10 +39,7 @@ export const SeedsQuestCells: CellsRenderFN = ({ {data.name} - {LEGACY_FormatGameGroupPT( - data.game as GameGroup, - data.playtype as LEGACY_Playtypes[typeof data.game], - )} + {FormatGame(data.game as V3Game)}
diff --git a/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx b/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx index a23de3974..5a5746bdb 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 { type GameGroup, LEGACY_FormatGameGroupPT, type LEGACY_Playtypes } from "tachi-common"; +import { FormatGame, type V3Game } 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)], + ["Game", "Game", StrSOV((x) => x.game)], ["Quests", "Quests"], ]; @@ -19,11 +19,7 @@ export const SeedsQuestlineSearchFns: SearchFunctions = { questlineID: (x) => x.questlineID, desc: (x) => x.desc, game: (x) => x.game, - gpt: (x) => - LEGACY_FormatGameGroupPT( - x.game as GameGroup, - x.playtype as LEGACY_Playtypes[typeof x.game], - ), + gpt: (x) => FormatGame(x.game as V3Game), quests: (x) => Object.values(x.__related.quests) .filter((e) => e !== undefined) @@ -42,10 +38,7 @@ export const SeedsQuestlineCells: CellsRenderFN = ({ data {data.desc} - {LEGACY_FormatGameGroupPT( - data.game as GameGroup, - data.playtype as LEGACY_Playtypes[typeof data.game], - )} + {FormatGame(data.game as V3Game)}
@@ -56,12 +49,7 @@ export const SeedsQuestlineCells: CellsRenderFN = ({ data
{quest ? ( - {quest.name} ( - {LEGACY_FormatGameGroupPT( - quest.game as GameGroup, - quest.playtype as LEGACY_Playtypes[typeof quest.game], - )} - ) + {quest.name} ({FormatGame(quest.game as V3Game)}) ) : ( UNKNOWN QUEST {e} diff --git a/typescript/client/src/components/targets/GoalBuilder.tsx b/typescript/client/src/components/targets/GoalBuilder.tsx index 05d45bf8f..1d356f365 100644 --- a/typescript/client/src/components/targets/GoalBuilder.tsx +++ b/typescript/client/src/components/targets/GoalBuilder.tsx @@ -377,7 +377,11 @@ function CriteriaPicker({ game={game} onChange={(mode, countNum) => { if (mode === "single") { - setCriteria({ mode: "single", key: criteria.key, value: criteria.value }); + setCriteria({ + mode: "single", + key: criteria.key, + value: criteria.value, + }); } else { setCriteria({ ...criteria, countNum: countNum ?? 0, mode }); } @@ -409,7 +413,11 @@ function CriteriaValuePicker({ switch (conf.type) { case "NULLABLE_GRAPH": case "GRAPH": - return Cannot set goals for graph metrics.; + return ( + + Cannot set goals for graph metrics. + + ); case "DECIMAL": case "INTEGER": @@ -457,9 +465,7 @@ function CriteriaModePicker({ criteria: GoalDocument["criteria"]; onChange: (mode: GoalDocument["criteria"]["mode"], countNum?: number) => void; } & GamePT) { - const [absCount, setAbsCount] = useState( - criteria.mode === "absolute" ? criteria.countNum : 10, - ); + const [absCount, setAbsCount] = useState(criteria.mode === "absolute" ? criteria.countNum : 10); const [perCount, setPerCount] = useState( criteria.mode === "proportion" ? criteria.countNum * 100 : 10, ); @@ -635,7 +641,9 @@ function ChartSelect({ loadOptions={loadOptions} // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange={(data: any) => - onChange(Array.isArray(data) ? data.map((e: { value: string }) => e.value) : data.value) + onChange( + Array.isArray(data) ? data.map((e: { value: string }) => e.value) : data.value, + ) } placeholder="Search for a chart..." /> diff --git a/typescript/client/src/components/targets/QuestProgressBar.module.scss b/typescript/client/src/components/targets/QuestProgressBar.module.scss new file mode 100644 index 000000000..57b653712 --- /dev/null +++ b/typescript/client/src/components/targets/QuestProgressBar.module.scss @@ -0,0 +1,69 @@ +.track { + background: color-mix(in srgb, var(--bs-body-color) 9%, var(--bs-tertiary-bg)); + border: 1px solid color-mix(in srgb, var(--bs-border-color) 70%, transparent); + border-radius: 9999px; + box-shadow: + inset 0 1px 2px rgb(0 0 0 / 0.06), + 0 1px 0 rgb(255 255 255 / 0.04); + height: 0.7rem; + overflow: hidden; + position: relative; +} + +[data-bs-theme="dark"] .track, +[data-bs-theme="oled"] .track { + box-shadow: inset 0 1px 3px rgb(0 0 0 / 0.35); +} + +.fill { + position: relative; + background: linear-gradient( + 105deg, + color-mix(in srgb, var(--bs-primary) 72%, var(--bs-body-bg)), + var(--bs-primary) 45%, + color-mix(in srgb, var(--bs-primary) 78%, white) + ); + border-radius: inherit; + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.22), + 0 0 0 1px color-mix(in srgb, var(--bs-primary) 25%, transparent); + height: 100%; + min-width: 0; + transition: width 0.55s cubic-bezier(0.33, 1, 0.68, 1); +} + +.fill::after { + background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.22), transparent); + content: ""; + height: 100%; + left: 0; + opacity: 0.85; + pointer-events: none; + position: absolute; + top: 0; + transform: translateX(-100%); + width: 40%; +} + +@media (prefers-reduced-motion: no-preference) { + .fillActive::after { + animation: questBarShimmer 2.4s ease-in-out infinite; + } +} + +@keyframes questBarShimmer { + 0%, + 100% { + transform: translateX(-100%); + } + + 50% { + transform: translateX(350%); + } +} + +@media (prefers-reduced-motion: reduce) { + .fill { + transition: none; + } +} diff --git a/typescript/client/src/components/targets/QuestProgressBar.tsx b/typescript/client/src/components/targets/QuestProgressBar.tsx new file mode 100644 index 000000000..229bf4cc8 --- /dev/null +++ b/typescript/client/src/components/targets/QuestProgressBar.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import styles from "./QuestProgressBar.module.scss"; + +/** Renders a themed track + fill for quest / questline completion (0–100%). */ +export default function QuestProgressBar({ + percent, + "aria-label": ariaLabel, + className, +}: { + "aria-label"?: string; + className?: string; + percent: number; +}) { + const clamped = Math.min(100, Math.max(0, Number.isFinite(percent) ? percent : 0)); + const active = clamped > 0 && clamped < 100; + + return ( +
+
+
+ ); +} diff --git a/typescript/client/src/components/targets/Questline.tsx b/typescript/client/src/components/targets/Questline.tsx index cb0769a15..f53cbd0f1 100644 --- a/typescript/client/src/components/targets/Questline.tsx +++ b/typescript/client/src/components/targets/Questline.tsx @@ -1,6 +1,6 @@ import QuickTooltip from "#components/layout/misc/QuickTooltip"; import Card from "#components/layout/page/Card"; -import Divider from "#components/util/Divider"; +import QuestProgressBar from "#components/targets/QuestProgressBar"; import Icon from "#components/util/Icon"; import LinkButton from "#components/util/LinkButton"; import Muted from "#components/util/Muted"; @@ -10,7 +10,7 @@ import { APIFetchV1 } from "#util/api"; import { GetGoalIDsFromQuest } from "#util/data"; import { FormatTime } from "#util/time"; import React, { useContext, useState } from "react"; -import { Badge, Button, Collapse, ProgressBar } from "react-bootstrap"; +import { Badge, Button, Collapse } from "react-bootstrap"; import { type QuestDocument, type QuestlineDocument } from "tachi-common"; export default function Questline({ @@ -35,7 +35,9 @@ export default function Questline({

{questline.name}

- {totalQuests} {totalQuests === 1 ? "Quest" : "Quests"} + + {totalQuests} {totalQuests === 1 ? "Quest" : "Quests"} +
{!quests && (
@@ -60,18 +62,21 @@ export default function Questline({ ) : ( <> -
- Questline Progress - - {achievedQuests} / {totalQuests} quests complete +
+ + Questline progress + + + + {progressPct}% + + · + {achievedQuests} / {totalQuests} quests
- 0} - style={{ height: "10px" }} - variant="primary" + )} @@ -140,7 +145,11 @@ function QuestTimelineNode({ {!isLast && (
)}
@@ -222,7 +231,13 @@ function QuestSubscribeButton({ quest }: { quest: QuestDocument }) { size="sm" variant="outline-danger" > - {loading ? "…" : <> Unsubscribe} + {loading ? ( + "…" + ) : ( + <> + Unsubscribe + + )} ); @@ -245,7 +260,13 @@ function QuestSubscribeButton({ quest }: { quest: QuestDocument }) { size="sm" variant="outline-success" > - {loading ? "…" : <> Subscribe} + {loading ? ( + "…" + ) : ( + <> + Subscribe + + )} ); } @@ -271,9 +292,19 @@ export function InnerQuestInfo({ quest }: { quest: QuestDocument }) { >
{questSub.achieved ? ( - + ) : ( - + )}
diff --git a/typescript/client/src/components/targets/quests/Quest.tsx b/typescript/client/src/components/targets/quests/Quest.tsx index c8d7d75d9..2c9162e00 100644 --- a/typescript/client/src/components/targets/quests/Quest.tsx +++ b/typescript/client/src/components/targets/quests/Quest.tsx @@ -1,5 +1,6 @@ import QuickTooltip from "#components/layout/misc/QuickTooltip"; import Card from "#components/layout/page/Card"; +import QuestProgressBar from "#components/targets/QuestProgressBar"; import Divider from "#components/util/Divider"; import GoalLink from "#components/util/GoalLink"; import Icon from "#components/util/Icon"; @@ -12,7 +13,7 @@ import { GetGoalIDsFromQuest } from "#util/data"; import { HumanisedJoinArray } from "#util/misc"; import { FormatTime } from "#util/time"; import React, { useContext, useState } from "react"; -import { Badge, Button, ProgressBar } from "react-bootstrap"; +import { Badge, Button } from "react-bootstrap"; import { FormatGame, type GoalDocument, @@ -44,8 +45,9 @@ export default function Quest({ const [show, setShow] = useState(!collapsible); - const subscribeBtn = user && ( - questSub ? ( + const subscribeBtn = + user && + (questSub ? ( ) : ( - ) - ); + )); return ( ) : ( <> -
- Progress - +
+ Progress + + + {progressPct}% + + · {achievedGoals} / {totalGoals} goals
- 0 && progressPct < 100} - style={{ height: "12px" }} - variant={progressPct === 100 ? "success" : "primary"} + )} @@ -179,9 +192,7 @@ function QuestSectionComponent({ )}
- {section.desc && ( -

{section.desc}

- )} + {section.desc &&

{section.desc}

}
{section.goals.length === 0 ? ( @@ -199,9 +210,7 @@ function QuestSectionComponent({ ); } - return ( - - ); + return ; })}
)} @@ -274,7 +283,7 @@ function GoalCard({ title="Update this goal" type="button" > - + )}
@@ -315,10 +324,7 @@ export function InnerQuestSectionGoal({ return ( <>
- + {onEdit && ( )}
@@ -353,11 +359,26 @@ export function InnerQuestSectionGoal({ >
{isAchieved ? ( - + ) : inProgress ? ( - + ) : ( - + )}
@@ -371,7 +392,7 @@ export function InnerQuestSectionGoal({ title="Update this goal" type="button" > - + )} diff --git a/typescript/client/src/components/targets/quests/editor/EditableQuest.tsx b/typescript/client/src/components/targets/quests/editor/EditableQuest.tsx index b1f19f3bd..99cfd2908 100644 --- a/typescript/client/src/components/targets/quests/editor/EditableQuest.tsx +++ b/typescript/client/src/components/targets/quests/editor/EditableQuest.tsx @@ -9,7 +9,7 @@ import { type GamePT } from "#types/react"; import { type RawQuestDocument, type RawQuestGoal, type RawQuestSection } from "#types/tachi"; import { ChangeAtPosition, CopyToClipboard, DeleteInPosition } from "#util/misc"; import React, { useState } from "react"; -import { Button, Collapse, Modal } from "react-bootstrap"; +import { Button, Modal } from "react-bootstrap"; import { FormatGame } from "tachi-common"; export default function EditableQuest({ @@ -53,7 +53,10 @@ export default function EditableQuest({ }) } onDelete={() => - onChange({ ...quest, rawQuestData: DeleteInPosition(quest.rawQuestData, i) }) + onChange({ + ...quest, + rawQuestData: DeleteInPosition(quest.rawQuestData, i), + }) } onMoveDown={ i < quest.rawQuestData.length - 1 @@ -226,7 +229,11 @@ function QuestSection({ )}
- )} - - )} + + {reqUser.canSubmitQuests ? ( + + ) : ( + + )} +
+ + )}
    diff --git a/typescript/client/src/types/tachi.ts b/typescript/client/src/types/tachi.ts index ad3fc0434..23074db42 100644 --- a/typescript/client/src/types/tachi.ts +++ b/typescript/client/src/types/tachi.ts @@ -77,12 +77,12 @@ export type RawQuestGoal = { * authoring questlines locally in the Quest Editor before uploading as seeds. */ export type RawQuestlineDocument = { + desc: string; + /** V3Game identifier, e.g. `"iidx-sp"`. */ + game: string; + name: string; /** User-chosen slug / identifier (becomes `questlineID` in seeds). */ questlineID: string; - name: string; - desc: string; - game: string; - playtype: string; /** Ordered list of `RawQuestDocument` references by their local index or slug. */ quests: Array; }; diff --git a/typescript/common/src/types.ts b/typescript/common/src/types.ts index fcf0396ff..c126eaba1 100644 --- a/typescript/common/src/types.ts +++ b/typescript/common/src/types.ts @@ -23,6 +23,8 @@ export interface TachiServerCoreConfig { NAME: string; TYPE: "boku" | "kamai" | "omni"; SIGNUPS_ENABLED: boolean; + /** True when the server is configured for community quest proposals (GitHub App). */ + QUEST_PROPOSALS_ENABLED: boolean; } export * from "./types/api"; diff --git a/typescript/common/src/types/documents.ts b/typescript/common/src/types/documents.ts index 9d8e2dd93..1d35bb267 100644 --- a/typescript/common/src/types/documents.ts +++ b/typescript/common/src/types/documents.ts @@ -318,6 +318,7 @@ export interface UserDocument { badges: Array; authLevel: UserAuthLevels; isSupporter?: boolean; + canSubmitQuests?: boolean; } export interface SpecificUserGameStats { diff --git a/typescript/common/src/types/seeds-documents-zod.ts b/typescript/common/src/types/seeds-documents-zod.ts index 78abffda3..cd4d42f50 100644 --- a/typescript/common/src/types/seeds-documents-zod.ts +++ b/typescript/common/src/types/seeds-documents-zod.ts @@ -180,9 +180,8 @@ const seedsQuestSectionSchema = z.strictObject({ export const SEEDS_QUEST_DOCUMENT_SCHEMA = z.strictObject({ desc: z.string(), - game: SEEDS_V3_GAME_GROUP_SCHEMA, + game: SEEDS_V3_GAME_SCHEMA, name: z.string(), - playtype: z.string(), questData: z.array(seedsQuestSectionSchema), questID: z.string(), }); @@ -191,9 +190,8 @@ export type SEEDS_QuestDocument = z.infer; export const SEEDS_QUESTLINE_DOCUMENT_SCHEMA = z.strictObject({ desc: z.string(), - game: SEEDS_V3_GAME_GROUP_SCHEMA, + game: SEEDS_V3_GAME_SCHEMA, name: z.string(), - playtype: z.string(), questlineID: z.string(), quests: z.array(z.string()), }); @@ -248,10 +246,9 @@ const SEEDS_GOAL_CHARTS_SCHEMA = z.discriminatedUnion("type", [ export const SEEDS_GOAL_DOCUMENT_SCHEMA = z.strictObject({ charts: SEEDS_GOAL_CHARTS_SCHEMA, criteria: SEEDS_GOAL_CRITERIA_SCHEMA, - game: SEEDS_V3_GAME_GROUP_SCHEMA, + game: SEEDS_V3_GAME_SCHEMA, goalID: z.string(), name: z.string(), - playtype: z.string(), }); export type SEEDS_GoalDocument = z.infer; diff --git a/typescript/db/src/generated/index.ts b/typescript/db/src/generated/index.ts index a373d2192..d7cd776c3 100644 --- a/typescript/db/src/generated/index.ts +++ b/typescript/db/src/generated/index.ts @@ -11,6 +11,7 @@ export { type default as InviteLockTable, type InviteLock, type NewInviteLock, t export { type orphan_chart_id, type default as OrphanChartTable, type OrphanChart, type NewOrphanChart, type OrphanChartUpdate } from './public/OrphanChart'; export { type game_rival_game, type default as GameRivalTable, type GameRival, type NewGameRival, type GameRivalUpdate } from './public/GameRival'; export { type goal_id, type default as GoalTable, type Goal, type NewGoal, type GoalUpdate } from './public/Goal'; +export { type quest_proposal_row_id, type default as QuestProposalTable, type QuestProposal, type NewQuestProposal, type QuestProposalUpdate } from './public/QuestProposal'; export { type import_quest_row_id, type default as ImportQuestTable, type ImportQuest, type NewImportQuest, type ImportQuestUpdate } from './public/ImportQuest'; export { type cron_task_execution_id, type default as CronTaskExecutionTable, type CronTaskExecution, type NewCronTaskExecution, type CronTaskExecutionUpdate } from './public/CronTaskExecution'; export { type notification_row_id, type default as NotificationTable, type Notification, type NewNotification, type NotificationUpdate } from './public/Notification'; diff --git a/typescript/db/src/generated/public/Account.ts b/typescript/db/src/generated/public/Account.ts index ffa7282a3..48debb50a 100644 --- a/typescript/db/src/generated/public/Account.ts +++ b/typescript/db/src/generated/public/Account.ts @@ -48,6 +48,8 @@ export default interface AccountTable { auth_level: ColumnType; is_supporter: ColumnType; + + can_submit_quests: ColumnType; } export type Account = Selectable; diff --git a/typescript/db/src/generated/public/PublicSchema.ts b/typescript/db/src/generated/public/PublicSchema.ts index 773d43ac9..9aa8fa958 100644 --- a/typescript/db/src/generated/public/PublicSchema.ts +++ b/typescript/db/src/generated/public/PublicSchema.ts @@ -11,6 +11,7 @@ import type { default as InviteLockTable } from './InviteLock'; import type { default as OrphanChartTable } from './OrphanChart'; import type { default as GameRivalTable } from './GameRival'; import type { default as GoalTable } from './Goal'; +import type { default as QuestProposalTable } from './QuestProposal'; import type { default as ImportQuestTable } from './ImportQuest'; import type { default as CronTaskExecutionTable } from './CronTaskExecution'; import type { default as NotificationTable } from './Notification'; @@ -89,6 +90,8 @@ export default interface PublicSchema { goal: GoalTable; + quest_proposal: QuestProposalTable; + import_quest: ImportQuestTable; cron_task_execution: CronTaskExecutionTable; diff --git a/typescript/db/src/generated/public/QuestProposal.ts b/typescript/db/src/generated/public/QuestProposal.ts new file mode 100644 index 000000000..f93f8ebae --- /dev/null +++ b/typescript/db/src/generated/public/QuestProposal.ts @@ -0,0 +1,35 @@ +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +import type { account_id } from './Account'; +import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; + +/** Identifier type for public.quest_proposal */ +export type quest_proposal_row_id = string; + +/** Represents the table public.quest_proposal */ +export default interface QuestProposalTable { + row_id: ColumnType; + + user_id: ColumnType; + + github_pr_number: ColumnType; + + github_branch: ColumnType; + + raw_quests: ColumnType; + + raw_questlines: ColumnType; + + status: ColumnType; + + created_at: ColumnType; + + updated_at: ColumnType; +} + +export type QuestProposal = Selectable; + +export type NewQuestProposal = Insertable; + +export type QuestProposalUpdate = Updateable; diff --git a/typescript/github-bot/src/config.ts b/typescript/github-bot/src/config.ts index ea019eeb2..22f50b1ce 100644 --- a/typescript/github-bot/src/config.ts +++ b/typescript/github-bot/src/config.ts @@ -34,6 +34,17 @@ function ParseEnvVars() { clientSecret: process.env.CLIENT_SECRET!, /** Origin of the static seeds web UI (no trailing slash). */ seedsWebuiOrigin: process.env.SEEDS_WEBUI_ORIGIN ?? "https://seeds.tachi.ac", + /** + * Base URL of the main Tachi server API (no trailing slash). + * When set, the bot will notify the server when a quest-proposal PR is merged. + * Example: https://kamaitachi.xyz/api/v1 + */ + tachiApiOrigin: process.env.TACHI_API_ORIGIN ?? "", + /** + * Shared secret for the /proposals/webhook/merged endpoint. + * Must match TACHI_GITHUB_WEBHOOK_SECRET in the main server's env. + */ + tachiWebhookSecret: process.env.TACHI_WEBHOOK_SECRET ?? "", }; } diff --git a/typescript/github-bot/src/main.ts b/typescript/github-bot/src/main.ts index 39d226fce..609640322 100644 --- a/typescript/github-bot/src/main.ts +++ b/typescript/github-bot/src/main.ts @@ -1,4 +1,3 @@ - import type { Repository } from "@octokit/webhooks-types"; import { App, createNodeMiddleware } from "@octokit/app"; @@ -33,10 +32,37 @@ function mkSeedDiffViewMsg(baseSha: string, headSha: string, prNumber: number) { return `\nThis pull request changes files under \`db/seeds/\`. [View the seed diff in the Seeds web UI](${origin}/diff?${params.toString()}).`; } +/** + * Create a rich comment for quest-proposal PRs, linking to the dedicated + * quest-preview page on seeds.tachi.ac in addition to the regular diff link. + */ +function mkQuestProposalMsg(baseSha: string, headSha: string, prNumber: number) { + const origin = ProcessEnv.seedsWebuiOrigin.replace(/\/$/u, ""); + + const diffParams = new URLSearchParams({ + base: baseSha, + head: headSha, + file: "quests.json", + pr: String(prNumber), + }); + + const previewUrl = `${origin}/pr/${prNumber}`; + const diffUrl = `${origin}/diff?${diffParams.toString()}`; + + return `🎯 **Quest Proposal** + +This PR was submitted via the Quest Editor. + +| | | +|---|---| +| **Preview quests** | [View on Seeds viewer](${previewUrl}) | +| **Seed diff** | [quests.json diff](${diffUrl}) | + +_A reviewer will check the content and merge when it looks good. You can update this PR from the Quest Editor at any time._`; +} + function prTouchesSeeds(files: Array<{ filename: string }>): boolean { - return files.some( - (k) => k.filename.startsWith("db/seeds/") || k.filename === "db/seeds", - ); + return files.some((k) => k.filename.startsWith("db/seeds/") || k.filename === "db/seeds"); } async function listAllPullFiles( @@ -52,13 +78,16 @@ async function listAllPullFiles( for (;;) { // GitHub returns up to 100 files per page; fetch pages sequentially. // eslint-disable-next-line no-await-in-loop -- pagination must be sequential - const { data } = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}/files", { - owner, - repo: repoName, - pull_number: pullNumber, - per_page: 100, - page, - }); + const { data } = await octokit.request( + "GET /repos/{owner}/{repo}/pulls/{pull_number}/files", + { + owner, + repo: repoName, + pull_number: pullNumber, + per_page: 100, + page, + }, + ); const batch = data; out.push(...batch); if (batch.length < 100) { @@ -82,24 +111,35 @@ app.webhooks.on( ["pull_request.opened", "pull_request.reopened", "pull_request.synchronize"], async ({ octokit, payload }) => { const repo = payload.repository as Repository; + const pr = payload.pull_request; + const isQuestProposal = pr.head.ref.startsWith("quest-proposal/"); + + // Quest-proposal PRs get a dedicated preview comment; all other PRs that + // touch db/seeds/ get the standard seed-diff link. + if (isQuestProposal) { + await sendMsg( + mkQuestProposalMsg(pr.base.sha, pr.head.sha, pr.number), + octokit, + repo, + pr.number, + ); + return; + } + try { const filesChanged = await listAllPullFiles( octokit, repo.owner.login, repo.name, - payload.pull_request.number, + pr.number, ); if (prTouchesSeeds(filesChanged)) { await sendMsg( - mkSeedDiffViewMsg( - payload.pull_request.base.sha, - payload.pull_request.head.sha, - payload.pull_request.number, - ), + mkSeedDiffViewMsg(pr.base.sha, pr.head.sha, pr.number), octokit, repo, - payload.pull_request.number, + pr.number, ); } } catch (err) { @@ -115,19 +155,52 @@ ${err} ***** -${mkSeedDiffViewMsg( - payload.pull_request.base.sha, - payload.pull_request.head.sha, - payload.pull_request.number, -)}`, +${mkSeedDiffViewMsg(pr.base.sha, pr.head.sha, pr.number)}`, octokit, repo, - payload.pull_request.number, + pr.number, ); } }, ); +app.webhooks.on(["pull_request.closed"], async ({ payload }) => { + const pr = payload.pull_request; + + // Only fire for merged PRs whose branch starts with quest-proposal/ + if (!pr.merged || !pr.head.ref.startsWith("quest-proposal/")) { + return; + } + + if (!ProcessEnv.tachiApiOrigin || !ProcessEnv.tachiWebhookSecret) { + console.warn( + `Merged quest-proposal PR #${pr.number} but TACHI_API_ORIGIN or TACHI_WEBHOOK_SECRET are not set. Skipping server notification.`, + ); + return; + } + + try { + const res = await fetch(`${ProcessEnv.tachiApiOrigin}/proposals/webhook/merged`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-tachi-webhook-secret": ProcessEnv.tachiWebhookSecret, + }, + body: JSON.stringify({ prNumber: pr.number }), + }); + + if (!res.ok) { + console.error( + `Failed to notify Tachi server of merged PR #${pr.number}: ${res.status} ${res.statusText}`, + ); + } else { + console.log(`Notified Tachi server: quest-proposal PR #${pr.number} merged.`); + } + } catch (err) { + console.error(`Error notifying Tachi server of merged PR #${pr.number}:`, err); + } +}); + app.webhooks.on(["issue_comment.created"], async ({ octokit, payload }) => { const body = payload.comment.body.trim(); diff --git a/typescript/seeds-scripts/rerunners/import-raw-quests.ts b/typescript/seeds-scripts/rerunners/import-raw-quests.ts index 8944e0def..60372a9dc 100644 --- a/typescript/seeds-scripts/rerunners/import-raw-quests.ts +++ b/typescript/seeds-scripts/rerunners/import-raw-quests.ts @@ -33,7 +33,7 @@ const newGoals: Array = []; function HydrateQuest(raw: RawQuestDocument): QuestDocument { const questData: QuestDocument["questData"] = []; - const { game, playtype } = raw; + const { game } = raw; for (const rawQuest of raw.rawQuestData) { const goals: Array<{ goalID: string; note?: string }> = []; @@ -45,7 +45,6 @@ function HydrateQuest(raw: RawQuestDocument): QuestDocument { charts: rawGoal.goal.charts, criteria: rawGoal.goal.criteria, game, - playtype, goalID, name: rawGoal.goal.name, } as GoalDocument; @@ -69,7 +68,6 @@ function HydrateQuest(raw: RawQuestDocument): QuestDocument { desc: raw.desc, name: raw.name, game, - playtype, // just 20 random bytes. can't think of much more creative at the moment. questID: CreateQuestID(), questData, diff --git a/typescript/seeds-scripts/rerunners/v3/7-remap-goals-folder-and-chart-ids.ts b/typescript/seeds-scripts/rerunners/v3/7-remap-goals-folder-and-chart-ids.ts index e8418692b..76899b571 100644 --- a/typescript/seeds-scripts/rerunners/v3/7-remap-goals-folder-and-chart-ids.ts +++ b/typescript/seeds-scripts/rerunners/v3/7-remap-goals-folder-and-chart-ids.ts @@ -190,7 +190,12 @@ function remapChartsBlock( case "folder": return { ...charts, - data: resolveFolderGoalRef(charts.data, legacyToSeedFolder, canonicalFolderIds, context), + data: resolveFolderGoalRef( + charts.data, + legacyToSeedFolder, + canonicalFolderIds, + context, + ), }; } } @@ -277,14 +282,10 @@ console.log(`goal-id-remap.json: wrote ${translateMap.size} translations`); const repoRoot = path.resolve(SEEDS_DIR, "..", ".."); const biome = path.join(repoRoot, "node_modules", ".bin", "biome"); -const biomeResult = spawnSync( - biome, - ["format", "--write", goalsPath, questsPath, remapPath], - { - cwd: repoRoot, - stdio: "inherit", - }, -); +const biomeResult = spawnSync(biome, ["format", "--write", goalsPath, questsPath, remapPath], { + cwd: repoRoot, + stdio: "inherit", +}); if (biomeResult.status !== 0) { throw new Error(`biome format exited ${biomeResult.status}`); diff --git a/typescript/seeds-webui/src/App.tsx b/typescript/seeds-webui/src/App.tsx index 4c75e6318..8447dbb90 100644 --- a/typescript/seeds-webui/src/App.tsx +++ b/typescript/seeds-webui/src/App.tsx @@ -6,6 +6,7 @@ import { Collection } from "#pages/Collection"; import { Diff } from "#pages/Diff"; import { Overview } from "#pages/Overview"; import { Query } from "#pages/Query"; +import { QuestProposalPR } from "#pages/QuestProposalPR"; import React, { useEffect, useState } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom"; @@ -63,6 +64,7 @@ export default function App() { } /> + {editRoutes.map((r) => ( ))} diff --git a/typescript/seeds-webui/src/pages/Diff.tsx b/typescript/seeds-webui/src/pages/Diff.tsx index 5cfa1713b..d158d32e2 100644 --- a/typescript/seeds-webui/src/pages/Diff.tsx +++ b/typescript/seeds-webui/src/pages/Diff.tsx @@ -355,8 +355,7 @@ function CommitHeader({ }) { const subject = commit?.message.split("\n")[0] ?? ""; const body = commit?.message.split("\n").slice(1).join("\n").trim() ?? ""; - const prNumber = - explicitPrNumber ?? (commit ? extractPrNumber(commit.message) : null); + const prNumber = explicitPrNumber ?? (commit ? extractPrNumber(commit.message) : null); const commitUrl = `${SEEDS_GITHUB_HTML_URL}/commit/${head}`; const compareUrl = `${SEEDS_GITHUB_HTML_URL}/compare/${base}...${head}`; @@ -397,7 +396,11 @@ function CommitHeader({
    base - + {base.slice(0, 7)} diff --git a/typescript/seeds-webui/src/pages/QuestProposalPR.tsx b/typescript/seeds-webui/src/pages/QuestProposalPR.tsx new file mode 100644 index 000000000..2abea4292 --- /dev/null +++ b/typescript/seeds-webui/src/pages/QuestProposalPR.tsx @@ -0,0 +1,501 @@ +/** + * Quest Proposal PR page — /pr/:prNumber + * + * Shows the quests and questlines introduced by a quest-proposal/* branch PR. + * Fetches the PR head/base SHAs from the GitHub API, then loads the relevant + * seed files from raw.githubusercontent to compute which quests are new or + * updated compared to the base branch. + */ + +import { GITHUB_PAT_KEY, SEEDS_DEFAULT_BRANCH, SEEDS_GITHUB_HTML_URL, SEEDS_REPO, SEEDS_REPO_PATH } from "#lib/config"; +import { getTransport } from "#lib/transport/transport"; +import { useMemo } from "react"; +import { useQuery } from "react-query"; +import { Link, useParams } from "react-router-dom"; + +// ─── Local seed-doc types (mirrors tachi-common seeds shape) ───────────────── + +type QuestGoalRef = { + goalID: string; + note?: string; +}; + +type QuestSection = { + goals: QuestGoalRef[]; + title?: string; +}; + +type SeedsQuestDoc = { + desc: string; + game: string; + name: string; + questData: QuestSection[]; + questID: string; +}; + +type SeedsQuestlineDoc = { + desc: string; + game: string; + name: string; + questlineID: string; + quests: Array<{ questID: string }>; +}; + +type SeedsGoalDoc = { + game: string; + goalID: string; + name: string; +}; + +// ─── GitHub helpers ─────────────────────────────────────────────────────────── + +function ghHeaders(): HeadersInit { + const pat = typeof localStorage !== "undefined" ? localStorage.getItem(GITHUB_PAT_KEY) : null; + const headers: Record = { + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + }; + if (pat) { + headers.authorization = `Bearer ${pat}`; + } + return headers; +} + +async function ghFetch(pathname: string): Promise { + const res = await fetch(`https://api.github.com${pathname}`, { headers: ghHeaders() }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`GitHub ${pathname}: HTTP ${res.status} ${text}`); + } + return (await res.json()) as T; +} + +type GhPR = { + base: { ref: string; sha: string }; + body: string | null; + head: { ref: string; sha: string }; + html_url: string; + number: number; + state: string; + title: string; + user: { avatar_url: string; login: string }; +}; + +// ─── Data hooks ────────────────────────────────────────────────────────────── + +function usePR(prNumber: number) { + return useQuery( + ["gh-pr", prNumber], + () => ghFetch(`/repos/${SEEDS_REPO}/pulls/${prNumber}`), + { retry: 1, staleTime: 120_000 }, + ); +} + +function useSeedFile(file: string, sha: string | undefined) { + return useQuery( + ["seed-file", file, sha ?? "__none__"], + async () => { + if (!sha) { + return []; + } + const t = await getTransport(); + return (await t.getCollection(file, sha)) as T[]; + }, + { enabled: Boolean(sha), staleTime: 120_000 }, + ); +} + +// ─── Diff helpers ───────────────────────────────────────────────────────────── + +function diffById( + idKey: keyof T, + base: T[], + head: T[], +): { added: T[]; changed: T[]; unchanged: T[] } { + const baseMap = new Map(base.map((x) => [x[idKey] as string, x])); + const headMap = new Map(head.map((x) => [x[idKey] as string, x])); + + const added: T[] = []; + const changed: T[] = []; + const unchanged: T[] = []; + + for (const [id, item] of headMap) { + const baseItem = baseMap.get(id); + if (!baseItem) { + added.push(item); + } else if (JSON.stringify(item) !== JSON.stringify(baseItem)) { + changed.push(item); + } else { + unchanged.push(item); + } + } + + return { added, changed, unchanged }; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatusBadge({ state }: { state: string }) { + const cls = state === "open" ? "badge-open" : state === "closed" ? "badge-closed" : "badge-merged"; + return {state}; +} + +function GoalChip({ goal }: { goal: SeedsGoalDoc }) { + return ( + + {goal.name} + + ); +} + +function QuestCard({ + badge, + goals, + quest, +}: { + badge: "new" | "updated"; + goals: Map; + quest: SeedsQuestDoc; +}) { + return ( +
    +
    + + {badge === "new" ? "New" : "Updated"} + + {quest.game} + {quest.name} +
    + {quest.desc &&

    {quest.desc}

    } +
    + {quest.questData.map((section, si) => ( +
    + {section.title && ( +

    {section.title}

    + )} +
    + {section.goals.map((ref) => { + const g = goals.get(ref.goalID); + return g ? ( + + + {ref.note && ( + — {ref.note} + )} + + ) : ( + + (unknown goal) + + ); + })} +
    +
    + ))} +
    +
    + ); +} + +function QuestlineCard({ + badge, + questline, + questsByID, +}: { + badge: "new" | "updated"; + questline: SeedsQuestlineDoc; + questsByID: Map; +}) { + return ( +
    +
    + + {badge === "new" ? "New" : "Updated"} + + {questline.game} + {questline.name} +
    + {questline.desc &&

    {questline.desc}

    } +
    + {questline.quests.map((ref, i) => { + const q = questsByID.get(ref.questID); + return ( + + {i + 1} + {q ? q.name : (unknown quest)} + + ); + })} +
    +
    + ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export function QuestProposalPR() { + const { prNumber: prNumberStr } = useParams<{ prNumber: string }>(); + const prNumber = parseInt(prNumberStr, 10); + + const pr = usePR(prNumber); + + const headSha = pr.data?.head.sha; + const baseSha = pr.data?.base.sha; + + const headQuests = useSeedFile("quests.json", headSha); + const headGoals = useSeedFile("goals.json", headSha); + const headQuestlines = useSeedFile("questlines.json", headSha); + + const baseQuests = useSeedFile("quests.json", baseSha); + const baseGoals = useSeedFile("goals.json", baseSha); + const baseQuestlines = useSeedFile("questlines.json", baseSha); + + const questDiff = useMemo( + () => + headQuests.data && baseQuests.data + ? diffById("questID", baseQuests.data, headQuests.data) + : null, + [headQuests.data, baseQuests.data], + ); + + const questlineDiff = useMemo( + () => + headQuestlines.data && baseQuestlines.data + ? diffById("questlineID", baseQuestlines.data, headQuestlines.data) + : null, + [headQuestlines.data, baseQuestlines.data], + ); + + // Build lookup maps + const headGoalsByID = useMemo( + () => new Map((headGoals.data ?? []).map((g) => [g.goalID, g])), + [headGoals.data], + ); + + // All head quests by ID (for questline resolution) + const headQuestsByID = useMemo( + () => new Map((headQuests.data ?? []).map((q) => [q.questID, q])), + [headQuests.data], + ); + + const isLoading = + pr.isLoading || + headQuests.isLoading || + headGoals.isLoading || + headQuestlines.isLoading || + baseQuests.isLoading || + baseGoals.isLoading || + baseQuestlines.isLoading; + + const error = pr.error ?? headQuests.error ?? headQuestlines.error ?? baseQuests.error; + + if (Number.isNaN(prNumber)) { + return ( +
    +

    Invalid PR

    +

    + The PR number in the URL is not valid. Go home. +

    +
    + ); + } + + if (pr.error) { + return ( +
    +

    PR #{prNumber}

    +

    + Could not load PR: {String(pr.error)}. It may not exist, or it may be a + private repo. +

    +

    + + View on GitHub + +

    +
    + ); + } + + const diffParams = pr.data + ? new URLSearchParams({ + base: pr.data.base.sha, + head: pr.data.head.sha, + file: "quests.json", + pr: String(prNumber), + }).toString() + : null; + + const isQuestProposal = pr.data?.head.ref.startsWith("quest-proposal/") ?? false; + + return ( +
    + {/* ── PR header ────────────────────────────────────────────── */} +
    +
    +

    + {pr.data ? pr.data.title : `PR #${prNumber}`} +

    + {pr.data && ( +
    + + {isQuestProposal && ( + Quest Proposal + )} + + {pr.data.user.login} + {pr.data.user.login} + + + {pr.data.head.ref} + +
    + )} +
    +
    + {pr.data && ( + + GitHub ↗ + + )} + {diffParams && ( + + Seed Diff ↗ + + )} +
    +
    + + {/* ── Loading state ─────────────────────────────────────────── */} + {isLoading && ( +

    Loading seed data from GitHub…

    + )} + + {error && !pr.error && ( +

    + Error loading seed files: {String(error)} +

    + )} + + {/* ── Quest diff ────────────────────────────────────────────── */} + {questDiff && ( +
    +

    + Quests + {questDiff.added.length > 0 && ( + + +{questDiff.added.length} new + + )} + {questDiff.changed.length > 0 && ( + + {questDiff.changed.length} updated + + )} +

    + + {questDiff.added.length === 0 && questDiff.changed.length === 0 && ( +

    No quest changes in this PR.

    + )} + +
    + {questDiff.added.map((q) => ( + + ))} + {questDiff.changed.map((q) => ( + + ))} +
    +
    + )} + + {/* ── Questline diff ────────────────────────────────────────── */} + {questlineDiff && + (questlineDiff.added.length > 0 || questlineDiff.changed.length > 0) && ( +
    +

    + Questlines + {questlineDiff.added.length > 0 && ( + + +{questlineDiff.added.length} new + + )} + {questlineDiff.changed.length > 0 && ( + + {questlineDiff.changed.length} updated + + )} +

    +
    + {questlineDiff.added.map((ql) => ( + + ))} + {questlineDiff.changed.map((ql) => ( + + ))} +
    +
    + )} + + {/* ── No quest proposal notice ──────────────────────────────── */} + {pr.data && !isQuestProposal && !isLoading && ( +
    +

    + This PR ({pr.data.head.ref}) is not a{" "} + quest-proposal/* branch. The diff above shows any + quest/questline changes it contains, but it was not submitted through + the quest editor. +

    +

    + View the full seed diff → +

    +
    + )} + + {/* ── PR body ───────────────────────────────────────────────── */} + {pr.data?.body && ( +
    + PR description +
    {pr.data.body}
    +
    + )} +
    + ); +} diff --git a/typescript/seeds-webui/src/styles/app.scss b/typescript/seeds-webui/src/styles/app.scss index 56d0469de..59b9fb6ee 100644 --- a/typescript/seeds-webui/src/styles/app.scss +++ b/typescript/seeds-webui/src/styles/app.scss @@ -1856,3 +1856,353 @@ $diff-chg-border: rgba(245, 177, 76, 0.4); .draft-diff-error { color: var(--bs-danger-text-emphasis); } + +// --------------------------------------------------------------------------- +// Quest Proposal PR page (/pr/:prNumber) +// --------------------------------------------------------------------------- + +.pr-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; + padding: 1rem 1.25rem; + background: var(--sw-panel-bg); + border: 1px solid var(--bs-border-color); + border-left: 3px solid $tachi-pink; + border-radius: var(--bs-border-radius); + + .pr-header-main { + flex: 1; + min-width: 0; + + .page-title { + margin-bottom: 0.35rem; + } + } + + .pr-header-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; + align-items: flex-start; + padding-top: 0.15rem; + } +} + +.pr-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + font-size: 0.85rem; + color: var(--bs-secondary-color); + + .pr-author { + display: flex; + align-items: center; + gap: 0.35rem; + } + + .pr-avatar { + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + flex-shrink: 0; + } + + .pr-branch code { + font-size: 0.8rem; + } +} + +.pr-status-badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + flex-shrink: 0; + + &.badge-open { + background: rgba(62, 207, 142, 0.15); + color: #3ecf8e; + border: 1px solid rgba(62, 207, 142, 0.35); + } + + &.badge-closed { + background: rgba(230, 92, 92, 0.15); + color: #e65c5c; + border: 1px solid rgba(230, 92, 92, 0.35); + } + + &.badge-merged { + background: rgba(160, 100, 230, 0.15); + color: #a064e6; + border: 1px solid rgba(160, 100, 230, 0.35); + } +} + +.quest-proposal-tag { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.55rem; + border-radius: var(--bs-border-radius-sm); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + background: var(--sw-glow); + color: var(--bs-primary); + border: 1px solid rgba($tachi-pink, 0.3); + flex-shrink: 0; +} + +.btn-ghost { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + color: var(--bs-body-color); + text-decoration: none; + font-size: 0.82rem; + background: transparent; + transition: + border-color 120ms ease-in-out, + background-color 120ms ease-in-out; + + &:hover { + border-color: rgba($tachi-pink, 0.5); + background: var(--sw-glow); + color: var(--bs-body-color); + } +} + +.section-title { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.85rem; + + .section-count { + font-size: 0.72rem; + font-weight: 600; + padding: 0.1rem 0.45rem; + border-radius: 999px; + letter-spacing: 0.03em; + + &.section-count-added { + background: rgba(62, 207, 142, 0.15); + color: #3ecf8e; + border: 1px solid rgba(62, 207, 142, 0.3); + } + + &.section-count-changed { + background: rgba(245, 177, 76, 0.12); + color: #f5b14c; + border: 1px solid rgba(245, 177, 76, 0.3); + } + } +} + +.quest-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.quest-card { + background: var(--sw-panel-bg); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + padding: 0.85rem 1rem; + + .quest-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; + flex-wrap: wrap; + } + + .quest-badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.42rem; + border-radius: 0.25rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + flex-shrink: 0; + + &.quest-badge-new { + background: rgba(62, 207, 142, 0.15); + color: #3ecf8e; + } + + &.quest-badge-updated { + background: rgba(245, 177, 76, 0.12); + color: #f5b14c; + } + } + + .quest-game { + font-family: var(--sw-mono); + font-size: 0.75rem; + padding: 0.1rem 0.45rem; + background: var(--bs-body-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius-sm); + color: var(--bs-secondary-color); + flex-shrink: 0; + } + + .quest-name { + font-size: 0.95rem; + font-weight: 600; + } + + .quest-desc { + font-size: 0.85rem; + color: var(--bs-secondary-color); + margin: 0 0 0.65rem; + line-height: 1.5; + } + + .quest-sections { + display: flex; + flex-direction: column; + gap: 0.65rem; + } + + .quest-section { + .quest-section-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--bs-secondary-color); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0 0 0.35rem; + } + } + + .quest-goals { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } + + .questline-quests { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.25rem; + } + + .questline-step { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.82rem; + + .questline-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.3rem; + height: 1.3rem; + border-radius: 50%; + background: var(--bs-body-tertiary-bg); + border: 1px solid var(--bs-border-color); + font-size: 0.68rem; + font-weight: 600; + color: var(--bs-secondary-color); + flex-shrink: 0; + } + } +} + +.goal-chip { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.55rem; + background: var(--bs-body-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius-sm); + font-size: 0.78rem; + color: var(--bs-body-color); + cursor: default; + max-width: 100%; + overflow-wrap: anywhere; + + &.goal-chip-missing { + color: var(--bs-secondary-color); + font-style: italic; + } +} + +.goal-note { + font-size: 0.78rem; + color: var(--bs-secondary-color); +} + +.pr-not-quest-proposal { + margin-top: 1rem; + padding: 0.85rem 1rem; + background: var(--bs-body-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + font-size: 0.85rem; + color: var(--bs-secondary-color); + + p { + margin: 0 0 0.35rem; + + &:last-child { + margin: 0; + } + } +} + +.pr-body-details { + margin-top: 1.25rem; + + summary { + cursor: pointer; + font-size: 0.85rem; + color: var(--bs-secondary-color); + padding: 0.35rem 0; + user-select: none; + + &:hover { + color: var(--bs-body-color); + } + } +} + +.pr-body-pre { + margin: 0.5rem 0 0; + padding: 0.75rem 1rem; + background: var(--bs-body-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + font-size: 0.82rem; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + max-height: 24rem; + overflow-y: auto; + font-family: var(--sw-mono); +} diff --git a/typescript/server/.env b/typescript/server/.env index 769fc721a..2bb87dee0 100644 --- a/typescript/server/.env +++ b/typescript/server/.env @@ -50,3 +50,10 @@ TACHI_NAME=Tachi Local Dev TACHI_TYPE=omni 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_GITHUB_APP_ID= +# TACHI_GITHUB_APP_BASE64_PRIVATE_KEY= +# TACHI_GITHUB_REPO_OWNER= +# TACHI_GITHUB_REPO_NAME= +# TACHI_GITHUB_INSTALLATION_ID= +# TACHI_GITHUB_WEBHOOK_SECRET= diff --git a/typescript/server/package.json b/typescript/server/package.json index e5c496d1f..76ea55710 100644 --- a/typescript/server/package.json +++ b/typescript/server/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "catalog:", + "@octokit/app": "^16.1.2", "@types/bcryptjs": "catalog:", "@types/bunyan": "catalog:", "@types/connect-redis": "catalog:", @@ -53,6 +54,7 @@ "bunyan": "catalog:", "commander": "catalog:", "connect-redis": "catalog:", + "cron-parser": "catalog:", "csv-parse": "catalog:", "deepmerge": "catalog:", "dotenv": "catalog:", @@ -85,8 +87,7 @@ "tachi-db-migration-engine": "workspace:*", "ts-node": "catalog:", "typescript": "catalog:", - "zod": "catalog:", - "cron-parser": "catalog:" + "zod": "catalog:" }, "nyc": { "reporter": [ diff --git a/typescript/server/src/actions/set-user-quest-submitter-status.ts b/typescript/server/src/actions/set-user-quest-submitter-status.ts new file mode 100644 index 000000000..fb396700c --- /dev/null +++ b/typescript/server/src/actions/set-user-quest-submitter-status.ts @@ -0,0 +1,29 @@ +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { IsUserAdmin } from "#utils/user"; +import { ExpectedErr } from "bliss"; + +export const ACTION_SetUserQuestSubmitterStatus = MakeAction( + "SET_USER_QUEST_SUBMITTER_STATUS", + async (taker, { userID, canSubmit }) => { + if (!(await IsUserAdmin(taker.acct.id))) { + throw new ExpectedErr(403, "You are not authorized to perform this action."); + } + + const existing = await DB.selectFrom("account") + .select("id") + .where("id", "=", userID) + .executeTakeFirst(); + + if (!existing) { + throw new ExpectedErr(404, "This user does not exist."); + } + + await DB.updateTable("account") + .set({ can_submit_quests: canSubmit }) + .where("id", "=", userID) + .execute(); + + return {}; + }, +); diff --git a/typescript/server/src/actions/update-goal-subscription.test.ts b/typescript/server/src/actions/update-goal-subscription.test.ts index 4be0f1101..66859cf48 100644 --- a/typescript/server/src/actions/update-goal-subscription.test.ts +++ b/typescript/server/src/actions/update-goal-subscription.test.ts @@ -10,7 +10,7 @@ describe("ACTION_UpdateGoalSubscription", () => { let userID: number; let username: string; let chartId: string; - let taker: { ip: string; acct: { id: number; username: string } }; + let taker: { acct: { id: number; username: string }; ip: string }; const baseInput = { game: "iidx-sp" as const, diff --git a/typescript/server/src/actions/update-goal-subscription.ts b/typescript/server/src/actions/update-goal-subscription.ts index c1ea2a9c1..861f690b0 100644 --- a/typescript/server/src/actions/update-goal-subscription.ts +++ b/typescript/server/src/actions/update-goal-subscription.ts @@ -65,7 +65,10 @@ export const ACTION_UpdateGoalSubscription = MakeAction( subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED || subResult === SubscribeFailReasons.ALREADY_ACHIEVED ) { - throw new ExpectedErr(409, "Your updated goal conflicts with an existing subscription."); + throw new ExpectedErr( + 409, + "Your updated goal conflicts with an existing subscription.", + ); } return { newGoalID: newGoal.goalID, changed: true }; diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index bc541ff2b..e5e398504 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -312,6 +312,13 @@ export const ActionSignatures = { }), output: z.object({}), }, + SET_USER_QUEST_SUBMITTER_STATUS: { + input: z.object({ + userID: z.number().int().positive(), + canSubmit: z.boolean(), + }), + output: z.object({}), + }, PATCH_UGPT_SETTINGS: { input: z.object({ userID: z.number().int(), diff --git a/typescript/server/src/lib/db-formats/user.ts b/typescript/server/src/lib/db-formats/user.ts index e2976a563..517c7d378 100644 --- a/typescript/server/src/lib/db-formats/user.ts +++ b/typescript/server/src/lib/db-formats/user.ts @@ -25,6 +25,7 @@ export const SELECT_USER = [ "account.username", "account.normalized_username", "account.is_supporter", + "account.can_submit_quests", ] as const; export function ToUserDocument( @@ -63,5 +64,6 @@ export function ToUserDocument( username: row.username, usernameLowercase: row.normalized_username, isSupporter: row.is_supporter, + canSubmitQuests: row.can_submit_quests, }; } diff --git a/typescript/server/src/lib/jobs/cron/cron-registry.ts b/typescript/server/src/lib/jobs/cron/cron-registry.ts index b2eda8e95..6112d9a7b 100644 --- a/typescript/server/src/lib/jobs/cron/cron-registry.ts +++ b/typescript/server/src/lib/jobs/cron/cron-registry.ts @@ -40,7 +40,7 @@ function buildList(): Array { }, { id: "deorphan_scores", - schedule: "1 0 * * *", + schedule: "0 1 * * *", description: "De-Orphan Scores", run: DeorphanScoresMain, }, diff --git a/typescript/server/src/lib/proposals/convert.ts b/typescript/server/src/lib/proposals/convert.ts new file mode 100644 index 000000000..603178564 --- /dev/null +++ b/typescript/server/src/lib/proposals/convert.ts @@ -0,0 +1,203 @@ +/** + * Converts the editor's "raw" quest format (inline goal definitions) into the + * canonical seeds format that lives in db/seeds/quests.json and db/seeds/goals.json. + * + * This mirrors the logic in typescript/seeds-scripts/rerunners/import-raw-quests.ts + * but runs inside the server so it can be called from the proposals API. + */ + +import { CreateGoalID } from "#lib/targets/goals"; +import fjsh from "fast-json-stable-hash"; +import { type GoalDocument, type V3Game } from "tachi-common"; + +// Mirrors client/src/types/tachi.ts — kept local to avoid a cross-package dep. +export type RawQuestGoal = { + goal: Pick; + note?: string; +}; + +export type RawQuestSection = { + desc: string; + rawGoals: Array; + title: string; +}; + +export type RawQuestDocument = { + desc: string; + game: string; + name: string; + rawQuestData: Array; +}; + +export type RawQuestlineDocument = { + desc: string; + /** V3Game identifier, e.g. "iidx-sp". */ + game: string; + name: string; + questlineID: string; + quests: Array; +}; + +// ─── Seeds-format types ──────────────────────────────────────────────────────── + +export type SeedsQuestGoalRef = { + goalID: string; + note?: string; +}; + +export type SeedsQuestSection = { + desc?: string; + goals: Array; + title: string; +}; + +/** + * Quest document shape expected by db/seeds/quests.json. + * Uses `game` as a V3Game (e.g. "iidx-sp"), matching SEEDS_QUEST_DOCUMENT_SCHEMA. + */ +export type SeedsQuestDocument = { + desc: string; + game: string; + name: string; + questData: Array; + questID: string; +}; + +/** + * Questline document shape expected by db/seeds/questlines.json. + */ +export type SeedsQuestlineDocument = { + desc: string; + game: string; + name: string; + questlineID: string; + quests: Array; +}; + +// ─── ID generation ──────────────────────────────────────────────────────────── + +/** + * Creates a deterministic questID by hashing the quest's stable identity. + * Using SHA-256 of { name, desc, game } so the same quest re-submitted by + * different authors gets the same ID, but any textual change produces a new one. + */ +export function CreateQuestID(name: string, desc: string, game: V3Game): string { + return fjsh.hash({ name, desc, game }, "sha256") as string; +} + +// ─── Hydration ──────────────────────────────────────────────────────────────── + +/** + * Converts an array of RawQuestDocuments (editor format) to the seeds format. + * Returns the fully-resolved quests and the new goals that need to be merged + * into db/seeds/goals.json. + */ +export function hydrateRawQuests(raws: Array): { + goals: Array; + quests: Array; +} { + const newGoals: Array = []; + const seenGoalIDs = new Set(); + + const quests: Array = raws.map((raw) => { + const v3Game = raw.game as V3Game; + + const questData: Array = raw.rawQuestData.map((section) => { + const goals: Array = section.rawGoals.map((rawGoal) => { + const goalID = CreateGoalID(rawGoal.goal.charts, rawGoal.goal.criteria, v3Game); + + if (!seenGoalIDs.has(goalID)) { + seenGoalIDs.add(goalID); + + newGoals.push({ + charts: rawGoal.goal.charts, + criteria: rawGoal.goal.criteria, + game: v3Game, + goalID, + name: rawGoal.goal.name, + } as GoalDocument); + } + + return { goalID, ...(rawGoal.note !== undefined ? { note: rawGoal.note } : {}) }; + }); + + return { + title: section.title, + ...(section.desc ? { desc: section.desc } : {}), + goals, + }; + }); + + return { + questID: CreateQuestID(raw.name, raw.desc, v3Game), + name: raw.name, + desc: raw.desc, + game: v3Game, + questData, + }; + }); + + return { quests, goals: newGoals }; +} + +/** + * Converts RawQuestlineDocuments to seeds format, replacing quest names with + * the questIDs that hydrateRawQuests computed. + * + * @param questNameToID Map from the quest's original `name` to its computed questID. + */ +export function hydrateRawQuestlines( + raws: Array, + questNameToID: Map, +): Array { + return raws.map((ql) => { + const questIDs = ql.quests + .map((name) => questNameToID.get(name)) + .filter((id): id is string => id !== undefined); + + return { + questlineID: ql.questlineID, + name: ql.name, + desc: ql.desc, + game: ql.game, + quests: questIDs, + }; + }); +} + +/** + * Merges new quests/goals into the existing seed arrays (deduplicating by ID). + */ +export function mergeIntoSeeds(opts: { + existingGoals: Array; + existingQuestlines: Array; + existingQuests: Array; + newGoals: Array; + newQuestlines: Array; + newQuests: Array; +}): { + goals: Array; + questlines: Array; + quests: Array; +} { + const existingQuestIDs = new Set(opts.existingQuests.map((q) => q.questID)); + const existingGoalIDs = new Set(opts.existingGoals.map((g) => g.goalID)); + const existingQuestlineIDs = new Set(opts.existingQuestlines.map((ql) => ql.questlineID)); + + const mergedQuests = [ + ...opts.existingQuests, + ...opts.newQuests.filter((q) => !existingQuestIDs.has(q.questID)), + ]; + + const mergedGoals = [ + ...opts.existingGoals, + ...opts.newGoals.filter((g) => !existingGoalIDs.has(g.goalID)), + ]; + + const mergedQuestlines = [ + ...opts.existingQuestlines, + ...opts.newQuestlines.filter((ql) => !existingQuestlineIDs.has(ql.questlineID)), + ]; + + return { quests: mergedQuests, goals: mergedGoals, questlines: mergedQuestlines }; +} diff --git a/typescript/server/src/lib/proposals/github.ts b/typescript/server/src/lib/proposals/github.ts new file mode 100644 index 000000000..dd7f5b8a5 --- /dev/null +++ b/typescript/server/src/lib/proposals/github.ts @@ -0,0 +1,221 @@ +/** + * GitHub App helpers for the quest-proposal PR workflow. + * + * All functions require GITHUB_APP_CONFIG to be present in ServerConfig. + * Callers should check `ServerConfig.GITHUB_APP_CONFIG !== undefined` before + * calling these, and return 503 to the client if the config is absent. + */ + +import { log } from "#lib/log/log"; +import { ServerConfig } from "#lib/setup/config"; +import { App } from "@octokit/app"; + +// Lazily-initialised singleton so we only create the App once per process. +let _app: App | null = null; + +function getApp(): App { + if (_app !== null) { + return _app; + } + + const cfg = ServerConfig.GITHUB_APP_CONFIG; + + if (!cfg) { + throw new Error("GITHUB_APP_CONFIG is not configured. Cannot use GitHub App."); + } + + _app = new App({ + appId: cfg.APP_ID, + privateKey: cfg.PRIVATE_KEY, + }); + + return _app; +} + +/** + * Returns an installation-scoped Octokit that can act on the configured repo. + */ +export async function getInstallationOctokit() { + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + const app = getApp(); + + return app.getInstallationOctokit(cfg.INSTALLATION_ID); +} + +type OctokitInstance = Awaited>; + +/** + * Reads a file from the default branch of the configured repo. + * Returns the decoded string content and the blob SHA (needed for updates). + */ +export async function readRepoFile( + octokit: OctokitInstance, + path: string, +): Promise<{ content: string; sha: string }> { + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + + const { data } = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { + owner: cfg.REPO_OWNER, + repo: cfg.REPO_NAME, + path, + }); + + if (Array.isArray(data) || data.type !== "file") { + throw new Error(`Expected a file at ${path} but got a directory or unexpected response.`); + } + + // GitHub returns base64-encoded content + const content = Buffer.from(data.content, "base64").toString("utf-8"); + + return { content, sha: data.sha }; +} + +/** + * Gets the SHA of the default branch's HEAD commit. + */ +async function getDefaultBranchSHA(octokit: OctokitInstance): Promise { + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + + const { data: repoData } = await octokit.request("GET /repos/{owner}/{repo}", { + owner: cfg.REPO_OWNER, + repo: cfg.REPO_NAME, + }); + + const defaultBranch = repoData.default_branch; + + const { data: refData } = await octokit.request("GET /repos/{owner}/{repo}/git/ref/{ref}", { + owner: cfg.REPO_OWNER, + repo: cfg.REPO_NAME, + ref: `heads/${defaultBranch}`, + }); + + return refData.object.sha; +} + +type FileChange = { + content: string; + path: string; +}; + +/** + * Creates or force-updates a branch, commits the given file changes, and + * either opens a new PR or updates the body/title of an existing one. + * + * Returns the PR number. + */ +export async function openOrUpdateProposalPR(opts: { + branch: string; + changes: Array; + existingPrNumber?: number; + octokit: OctokitInstance; + prBody: string; + prTitle: string; +}): Promise { + const { octokit, branch, prTitle, prBody, changes, existingPrNumber } = opts; + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + const { REPO_OWNER: owner, REPO_NAME: repo } = cfg; + + // 1. Get the base SHA from the default branch + const baseSHA = await getDefaultBranchSHA(octokit); + + // 2. Create a new git tree with the file changes + const treeItems = await Promise.all( + changes.map(async (change) => ({ + path: change.path, + mode: "100644" as const, + type: "blob" as const, + content: change.content, + })), + ); + + const { data: treeData } = await octokit.request("POST /repos/{owner}/{repo}/git/trees", { + owner, + repo, + base_tree: baseSHA, + tree: treeItems, + }); + + // 3. Create a commit on top of the base + const { data: commitData } = await octokit.request("POST /repos/{owner}/{repo}/git/commits", { + owner, + repo, + message: prTitle, + tree: treeData.sha, + parents: [baseSHA], + }); + + // 4. Create or force-update the branch ref + const refPath = `refs/heads/${branch}`; + try { + await octokit.request("POST /repos/{owner}/{repo}/git/refs", { + owner, + repo, + ref: refPath, + sha: commitData.sha, + }); + } catch { + // Branch already exists — force-update it + await octokit.request("PATCH /repos/{owner}/{repo}/git/refs/{ref}", { + owner, + repo, + ref: `heads/${branch}`, + sha: commitData.sha, + force: true, + }); + } + + // 5. Open or update the PR + if (existingPrNumber !== undefined) { + await octokit.request("PATCH /repos/{owner}/{repo}/pulls/{pull_number}", { + owner, + repo, + pull_number: existingPrNumber, + title: prTitle, + body: prBody, + }); + + log.info({ prNumber: existingPrNumber, branch }, "Updated existing quest-proposal PR."); + + return existingPrNumber; + } + + // Get default branch name for base + const { data: repoData } = await octokit.request("GET /repos/{owner}/{repo}", { + owner, + repo, + }); + + const { data: prData } = await octokit.request("POST /repos/{owner}/{repo}/pulls", { + owner, + repo, + title: prTitle, + body: prBody, + head: branch, + base: repoData.default_branch, + }); + + log.info({ prNumber: prData.number, branch }, "Opened new quest-proposal PR."); + + return prData.number; +} + +/** + * Fetches the current state of a PR from GitHub. + * Returns "open", "merged", or "closed". + */ +export async function getPRStatus(prNumber: number): Promise<"closed" | "merged" | "open"> { + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + const octokit = await getInstallationOctokit(); + + const { data } = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", { + owner: cfg.REPO_OWNER, + repo: cfg.REPO_NAME, + pull_number: prNumber, + }); + + if (data.merged_at !== null) { + return "merged"; + } + + return data.state as "closed" | "open"; +} diff --git a/typescript/server/src/lib/setup/config.ts b/typescript/server/src/lib/setup/config.ts index 49734feee..c26d5f616 100644 --- a/typescript/server/src/lib/setup/config.ts +++ b/typescript/server/src/lib/setup/config.ts @@ -89,6 +89,7 @@ const configSchema = z.object({ 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), + QUEST_PROPOSALS_ENABLED: z.boolean(), }), CDN_CONFIG: z.object({ WEB_LOCATION: z.string(), @@ -110,6 +111,17 @@ const configSchema = z.object({ }), ]) .optional(), + GITHUB_APP_CONFIG: z + .object({ + APP_ID: z.string(), + PRIVATE_KEY: z.string(), + INSTALLATION_ID: z.number().int(), + REPO_OWNER: z.string(), + REPO_NAME: z.string(), + /** Shared secret for the webhook endpoint that marks proposals as merged. */ + WEBHOOK_SECRET: z.string(), + }) + .optional(), }); export type OAuth2Info = z.infer; @@ -218,6 +230,31 @@ function s3SaveLocation( }; } +function githubAppConfig(): TachiServerConfig["GITHUB_APP_CONFIG"] { + const appId = opt("TACHI_GITHUB_APP_ID"); + if (appId === undefined) { + return undefined; + } + const b64Key = opt("TACHI_GITHUB_APP_BASE64_PRIVATE_KEY"); + const owner = opt("TACHI_GITHUB_REPO_OWNER"); + const repoName = opt("TACHI_GITHUB_REPO_NAME"); + const installationId = opt("TACHI_GITHUB_INSTALLATION_ID"); + const webhookSecret = opt("TACHI_GITHUB_WEBHOOK_SECRET"); + if (!b64Key || !owner || !repoName || !installationId || !webhookSecret) { + throw new Error( + "TACHI_GITHUB_APP_ID is set but one or more of TACHI_GITHUB_APP_BASE64_PRIVATE_KEY, TACHI_GITHUB_REPO_OWNER, TACHI_GITHUB_REPO_NAME, TACHI_GITHUB_INSTALLATION_ID, TACHI_GITHUB_WEBHOOK_SECRET are missing.", + ); + } + return { + APP_ID: appId, + PRIVATE_KEY: Buffer.from(b64Key, "base64").toString("utf-8"), + REPO_OWNER: owner, + REPO_NAME: repoName, + INSTALLATION_ID: Number.parseInt(installationId, 10), + WEBHOOK_SECRET: webhookSecret, + }; +} + function seedsConfig(): TachiServerConfig["SEEDS_CONFIG"] { const type = opt("TACHI_SEEDS_TYPE"); if (type === undefined) { @@ -385,6 +422,7 @@ const emailCfg = emailConfig(); const inviteCfg = inviteCodeConfig(); const bootstrapInvite = opt("TACHI_INVITE_ADMIN_INITIAL_INVITE_CODE")?.trim() || undefined; const seedsCfg = seedsConfig(); +const githubAppCfg = githubAppConfig(); const clientDev = clientDevServer(); const extWorkerConc = opt("TACHI_EXTERNAL_SCORE_IMPORT_WORKER_CONCURRENCY"); @@ -435,6 +473,7 @@ const configFromEnv: unknown = { GAME_GROUPS: gameGroups, IMPORT_TYPES: importTypes, SIGNUPS_ENABLED: parseBool("TACHI_SIGNUPS_ENABLED", true) ?? true, + QUEST_PROPOSALS_ENABLED: githubAppCfg !== undefined, }, CDN_CONFIG: { WEB_LOCATION: req("TACHI_CDN_WEB_LOCATION"), @@ -442,6 +481,7 @@ const configFromEnv: unknown = { SAVE_LOCATION_PRIVATE: s3SaveLocation("TACHI_CDN_SAVE_LOCATION_PRIVATE"), }, ...(seedsCfg !== undefined ? { SEEDS_CONFIG: seedsCfg } : {}), + ...(githubAppCfg !== undefined ? { GITHUB_APP_CONFIG: githubAppCfg } : {}), }; const result = configSchema.safeParse(configFromEnv); diff --git a/typescript/server/src/scripts/load-seeds-pg.ts b/typescript/server/src/scripts/load-seeds-pg.ts index bc2a3b22b..35b572bc4 100644 --- a/typescript/server/src/scripts/load-seeds-pg.ts +++ b/typescript/server/src/scripts/load-seeds-pg.ts @@ -19,9 +19,19 @@ * promotes user #1 to admin so seed import can run. */ -import { buildChartIdMap, buildGoalIdRemap, importSeeds, ImportSeedsSubsetForTests } from "../services/pg/seeds"; +import { + buildChartIdMap, + buildGoalIdRemap, + importSeeds, + ImportSeedsSubsetForTests, +} from "../services/pg/seeds"; -export { buildChartIdMap, buildGoalIdRemap, importSeeds, ImportSeedsSubsetForTests as importSeedsSubset }; +export { + buildChartIdMap, + buildGoalIdRemap, + importSeeds, + ImportSeedsSubsetForTests as importSeedsSubset, +}; // ── Standalone entrypoint ────────────────────────────────────────────────── diff --git a/typescript/server/src/scripts/migrate-to-postgres.ts b/typescript/server/src/scripts/migrate-to-postgres.ts index 6ad9fb9f5..4f9bcb6a4 100644 --- a/typescript/server/src/scripts/migrate-to-postgres.ts +++ b/typescript/server/src/scripts/migrate-to-postgres.ts @@ -1168,7 +1168,7 @@ async function main(): Promise { const uniqueMongoGoalIds = [...new Set(goalSubs.map((gs) => gs.goalID))]; const translatedIds = uniqueMongoGoalIds.map((gid) => goalIdRemap.get(gid) ?? gid); - const candidateGoalIdsForLookup = [...new Set([...uniqueMongoGoalIds, ...translatedIds])]; + const candidateGoalIdsForLookup = [...new Set([...translatedIds, ...uniqueMongoGoalIds])]; const existingGoalIds = new Set( candidateGoalIdsForLookup.length === 0 diff --git a/typescript/server/src/scripts/run-action.ts b/typescript/server/src/scripts/run-action.ts index 6d201f9c9..687afa761 100644 --- a/typescript/server/src/scripts/run-action.ts +++ b/typescript/server/src/scripts/run-action.ts @@ -80,7 +80,9 @@ async function main(): Promise { process.exit(1); } - const handler = authenticatedActionHandlers[actionName as ActionName] as AuthenticatedActionHandler; + const handler = authenticatedActionHandlers[ + actionName as ActionName + ] as AuthenticatedActionHandler; let payload: Record = {}; if (values["payload-file"]) { 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 126dd0d26..8e8e24abf 100644 --- a/typescript/server/src/server/router/api/v1/admin/router.ts +++ b/typescript/server/src/server/router/api/v1/admin/router.ts @@ -1,6 +1,7 @@ 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_SetUserQuestSubmitterStatus } from "#actions/set-user-quest-submitter-status"; import { ACTION_SetUserSupporterStatus } from "#actions/set-user-supporter-status"; import { GetActions, @@ -184,6 +185,38 @@ API_V1_ROUTER.add("POST /admin/rebuild-folder-chart-lookup", withAdmin, async ({ ); }); +API_V1_ROUTER.add("POST /admin/quest-submitter/:userID", withAdmin, async ({ params, req }) => { + const target = await ResolveUser(params.userID); + + if (!target) { + throw new ExpectedErr(404, "This user does not exist."); + } + + 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_SetUserQuestSubmitterStatus(taker, { canSubmit: true, userID: target.id }); + + return success("Done.", {}); +}); + +API_V1_ROUTER.add("DELETE /admin/quest-submitter/:userID", withAdmin, async ({ params, req }) => { + const target = await ResolveUser(params.userID); + + if (!target) { + throw new ExpectedErr(404, "This user does not exist."); + } + + 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_SetUserQuestSubmitterStatus(taker, { canSubmit: false, userID: target.id }); + + return success("Done.", {}); +}); + 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/games/_gameGroup/_playtype/targets/goals/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts index 772e6b0e5..bcb295627 100644 --- 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 @@ -64,19 +64,21 @@ describe("GET /api/v1/games/:game/targets/goals/popular", () => { const { id: uid2 } = await seedUser({ username: `pop_u2_${Date.now()}` }); for (const uid of [uid1, uid2]) { - await DB.insertInto("goal_sub").values({ - goal_id: goalId, - user_id: uid, - achieved: false, - time_achieved: null, - progress: null, - progress_human: "NO DATA", - out_of: 7, - out_of_human: "FULL COMBO", - last_interaction: null, - was_instantly_achieved: false, - was_assigned_standalone: true, - }).execute(); + await DB.insertInto("goal_sub") + .values({ + goal_id: goalId, + user_id: uid, + achieved: false, + time_achieved: null, + progress: null, + progress_human: "NO DATA", + out_of: 7, + out_of_human: "FULL COMBO", + last_interaction: null, + was_instantly_achieved: false, + was_assigned_standalone: true, + }) + .execute(); } const res = await mockApi.get("/api/v1/games/iidx-sp/targets/goals/popular"); @@ -93,12 +95,10 @@ describe("POST /api/v1/games/:game/targets/goals/format", () => { it("returns a human-readable goal name for valid input", async () => { const chartId = await seedMinimalIidxSpChart(); - const res = await mockApi - .post("/api/v1/games/iidx-sp/targets/goals/format") - .send({ - charts: { type: "single", data: chartId }, - criteria: { mode: "single", key: "lamp", value: 7 }, - }); + const res = await mockApi.post("/api/v1/games/iidx-sp/targets/goals/format").send({ + charts: { type: "single", data: chartId }, + criteria: { mode: "single", key: "lamp", value: 7 }, + }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -107,12 +107,10 @@ describe("POST /api/v1/games/:game/targets/goals/format", () => { }); it("returns 400 for an invalid chart reference", async () => { - const res = await mockApi - .post("/api/v1/games/iidx-sp/targets/goals/format") - .send({ - charts: { type: "single", data: "C_does_not_exist" }, - criteria: { mode: "single", key: "lamp", value: 7 }, - }); + const res = await mockApi.post("/api/v1/games/iidx-sp/targets/goals/format").send({ + charts: { type: "single", data: "C_does_not_exist" }, + criteria: { mode: "single", key: "lamp", value: 7 }, + }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); @@ -121,12 +119,10 @@ describe("POST /api/v1/games/:game/targets/goals/format", () => { it("returns 400 for an invalid criteria key", async () => { const chartId = await seedMinimalIidxSpChart(); - const res = await mockApi - .post("/api/v1/games/iidx-sp/targets/goals/format") - .send({ - charts: { type: "single", data: chartId }, - criteria: { mode: "single", key: "not_a_real_metric", value: 0 }, - }); + const res = await mockApi.post("/api/v1/games/iidx-sp/targets/goals/format").send({ + charts: { type: "single", data: chartId }, + criteria: { mode: "single", key: "not_a_real_metric", value: 0 }, + }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); @@ -158,7 +154,9 @@ describe("GET /api/v1/games/:game/targets/goals/:goalID", () => { it("returns 404 when the goal belongs to a different game", async () => { const chartId = await seedMinimalIidxSpChart(); - const { id: gameCheckUserId } = await seedUser({ username: `goal_game_check_${Date.now()}` }); + const { id: gameCheckUserId } = await seedUser({ + username: `goal_game_check_${Date.now()}`, + }); const goalId = await seedGoalWithSub(chartId, gameCheckUserId); const res = await mockApi.get(`/api/v1/games/sdvx/targets/goals/${goalId}`); @@ -173,41 +171,54 @@ describe("GET /api/v1/games/:game/targets/goals/:goalID", () => { const chartId = await seedMinimalIidxSpChart(); const goalId = `G_ql_shape_${suffix}`; - await DB.insertInto("goal").values({ - id: goalId, - game: "iidx-sp", - name: "Shape test goal", - charts: JSON.stringify({ type: "single", data: chartId }), - criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }), - }).execute(); + await DB.insertInto("goal") + .values({ + id: goalId, + game: "iidx-sp", + name: "Shape test goal", + charts: JSON.stringify({ type: "single", data: chartId }), + criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }), + }) + .execute(); - await DB.insertInto("quest").values({ - id: qId, - game: "iidx-sp", - name: "Shape test quest", - description: "d", - quest_data: JSON.stringify([{ title: "s", goals: [{ goalID: goalId }] }]), - }).execute(); + await DB.insertInto("quest") + .values({ + id: qId, + game: "iidx-sp", + name: "Shape test quest", + description: "d", + quest_data: JSON.stringify([{ title: "s", goals: [{ goalID: goalId }] }]), + }) + .execute(); - await DB.insertInto("questline").values({ - id: qlId, - game: "iidx-sp", - name: "Shape test questline", - description: "d", - }).execute(); + await DB.insertInto("questline") + .values({ + id: qlId, + game: "iidx-sp", + name: "Shape test questline", + description: "d", + }) + .execute(); - await DB.insertInto("questline_quest").values({ - questline_id: qlId, - quest_id: qId, - sort_order: 0, - }).execute(); + await DB.insertInto("questline_quest") + .values({ + questline_id: qlId, + quest_id: qId, + sort_order: 0, + }) + .execute(); const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/quests/${qId}`); expect(res.status).toBe(200); - const ql = (res.body.body.parentQuestlines as Array<{ questlineID: string; game: string; quests: string[] }>) - .find((x) => x.questlineID === qlId); + const ql = ( + res.body.body.parentQuestlines as Array<{ + game: string; + questlineID: string; + quests: string[]; + }> + ).find((x) => x.questlineID === qlId); expect(ql).toBeDefined(); expect(ql?.game).toBe("iidx-sp"); 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 index bad58c294..39f99cd9a 100644 --- 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 @@ -7,9 +7,7 @@ afterAll(() => CloseServerConnection()); async function seedQuest(suffix: string, goalId?: string) { const questId = `q-rt-${suffix}`; - const questData = goalId - ? [{ title: "Section", goals: [{ goalID: goalId }] }] - : []; + const questData = goalId ? [{ title: "Section", goals: [{ goalID: goalId }] }] : []; await DB.insertInto("quest") .values({ @@ -56,13 +54,15 @@ describe("GET /api/v1/games/:game/targets/quests", () => { const goalId = `G_q_search_${Date.now()}`; const suffix = `${Date.now()}-g`; - await DB.insertInto("goal").values({ - id: goalId, - game: "iidx-sp", - name: "Search goal", - charts: JSON.stringify({ type: "single", data: chartId }), - criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }), - }).execute(); + await DB.insertInto("goal") + .values({ + id: goalId, + game: "iidx-sp", + name: "Search goal", + charts: JSON.stringify({ type: "single", data: chartId }), + criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }), + }) + .execute(); const questId = await seedQuest(suffix, goalId); @@ -117,25 +117,33 @@ describe("GET /api/v1/games/:game/targets/quests/:questID", () => { const qlId = `ql-v3-${suffix}`; const questId = await seedQuest(suffix); - await DB.insertInto("questline").values({ - id: qlId, - game: "iidx-sp", - name: "V3 test questline", - description: "d", - }).execute(); + await DB.insertInto("questline") + .values({ + id: qlId, + game: "iidx-sp", + name: "V3 test questline", + description: "d", + }) + .execute(); - await DB.insertInto("questline_quest").values({ - questline_id: qlId, - quest_id: questId, - sort_order: 0, - }).execute(); + await DB.insertInto("questline_quest") + .values({ + questline_id: qlId, + quest_id: questId, + sort_order: 0, + }) + .execute(); const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/quests/${questId}`); expect(res.status).toBe(200); const ql = ( - res.body.body.parentQuestlines as Array<{ questlineID: string; game: string; quests: string[] }> + res.body.body.parentQuestlines as Array<{ + game: string; + questlineID: string; + quests: string[]; + }> ).find((x) => x.questlineID === qlId); expect(ql).toBeDefined(); diff --git a/typescript/server/src/server/router/api/v1/proposals/router.ts b/typescript/server/src/server/router/api/v1/proposals/router.ts new file mode 100644 index 000000000..7ef290d93 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/proposals/router.ts @@ -0,0 +1,630 @@ +/** + * Quest Proposal API + * + * Allows authenticated users to submit quests from the editor as GitHub PRs. + * The server converts the raw (inlined-goal) editor format to the seeds format, + * opens/updates a PR via the GitHub App, and tracks the proposal in the DB. + */ + +import type { GoalDocument } from "tachi-common"; + +import { log } from "#lib/log/log"; +import { + hydrateRawQuestlines, + hydrateRawQuests, + mergeIntoSeeds, + type RawQuestDocument, + type RawQuestlineDocument, + type SeedsQuestDocument, + type SeedsQuestlineDocument, +} from "#lib/proposals/convert"; +import { + getInstallationOctokit, + getPRStatus, + openOrUpdateProposalPR, + readRepoFile, +} from "#lib/proposals/github"; +import { success } from "#lib/router/typed-router"; +import { ServerConfig } from "#lib/setup/config"; +import { API_V1_ROUTER } from "#server/router/api/v1/router"; +import DB from "#services/pg/db"; +import { ExpectedErr } from "bliss"; + +// ─── Shared helpers ──────────────────────────────────────────────────────────── + +type OctokitLikeError = { response?: { data?: { message?: string } }; status?: number } & Error; + +/** + * Wraps a GitHub API call and converts Octokit RequestErrors into user-facing + * ExpectedErrs with the GitHub error message included. + */ +async function callGitHub(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + const e = err as OctokitLikeError; + + if (e instanceof Error && typeof e.status === "number") { + const ghMsg = e.response?.data?.message ?? e.message; + + throw new ExpectedErr( + 502, + `GitHub API error (${e.status}): ${ghMsg}. Check that the GitHub App has Contents read/write and Pull requests read/write permissions on this repository.`, + ); + } + + throw err; + } +} + +function requireGitHubConfig() { + if (!ServerConfig.GITHUB_APP_CONFIG) { + throw new ExpectedErr(503, "Quest proposals are not enabled on this instance."); + } + + return ServerConfig.GITHUB_APP_CONFIG; +} + +function requireSession(req: { session: { tachi?: { user?: { id: number; username: string } } } }) { + const user = req.session.tachi?.user; + + if (!user) { + throw new ExpectedErr(401, "You must be logged in to manage quest proposals."); + } + + return user; +} + +function buildPRBody( + submitterUsername: string, + quests: Array, + questlines: Array, +): string { + const questList = quests.map((q) => `- **${q.name}** (${q.game})`).join("\n"); + const qlList = + questlines.length > 0 + ? `\n\n### Questlines\n${questlines.map((ql) => `- **${ql.name}**`).join("\n")}` + : ""; + + return `## Quest Proposal + +Submitted by @${submitterUsername} via the Tachi Quest Editor. + +### Quests +${questList}${qlList} + +--- +_This PR was automatically generated. Please review the quest content and goals before merging._`; +} + +// ─── POST /proposals — Submit a new quest as a PR ───────────────────────────── + +/** + * Submit new quest(s) (and optional questlines) as a GitHub PR. + * + * @name POST /api/v1/proposals + */ +API_V1_ROUTER.add("POST /proposals", async ({ req, input }) => { + requireGitHubConfig(); + + const sessionUser = requireSession(req); + + // Only users who have been approved for quest submission may open PRs. + const account = await DB.selectFrom("account") + .select(["can_submit_quests"]) + .where("id", "=", sessionUser.id) + .executeTakeFirst(); + + if (!account?.can_submit_quests) { + throw new ExpectedErr( + 403, + "You do not have permission to submit quests. Apply via Discord to get access.", + ); + } + + const { + quests: rawQuests, + questlines: rawQuestlines, + prTitle, + } = input as { + prTitle?: string; + questlines?: Array; + quests: Array; + }; + + if (rawQuests.length === 0) { + throw new ExpectedErr(400, "You must provide at least one quest."); + } + + const { quests: seedQuests, goals: newGoals } = hydrateRawQuests(rawQuests); + + const questNameToID = new Map(rawQuests.map((raw, i) => [raw.name, seedQuests[i]!.questID])); + + const seedQuestlines = rawQuestlines ? hydrateRawQuestlines(rawQuestlines, questNameToID) : []; + + const octokit = await callGitHub(() => getInstallationOctokit()); + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + + // Read existing seed files from the repo + const { content: existingQuestsRaw } = await callGitHub(() => + readRepoFile(octokit, "db/seeds/quests.json"), + ); + const { content: existingGoalsRaw } = await callGitHub(() => + readRepoFile(octokit, "db/seeds/goals.json"), + ); + const { content: existingQuestlinesRaw } = await callGitHub(() => + readRepoFile(octokit, "db/seeds/questlines.json"), + ); + + const existingQuests = JSON.parse(existingQuestsRaw) as Array; + const existingGoals = JSON.parse(existingGoalsRaw) as Array; + const existingQuestlines = JSON.parse(existingQuestlinesRaw) as Array; + + const { + quests: mergedQuests, + goals: mergedGoals, + questlines: mergedQuestlines, + } = mergeIntoSeeds({ + existingQuests, + existingGoals, + existingQuestlines, + newQuests: seedQuests, + newGoals, + newQuestlines: seedQuestlines, + }); + + const branchSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + const branch = `quest-proposal/${branchSuffix}`; + + const questNames = seedQuests.map((q) => q.name).join(", "); + const title = prTitle?.trim() || `Add quest: ${questNames}`; + + const prBody = buildPRBody(sessionUser.username, seedQuests, seedQuestlines); + + const prNumber = await callGitHub(() => + openOrUpdateProposalPR({ + octokit, + branch, + prTitle: title, + prBody, + changes: [ + { path: "db/seeds/quests.json", content: JSON.stringify(mergedQuests, null, "\t") }, + { path: "db/seeds/goals.json", content: JSON.stringify(mergedGoals, null, "\t") }, + ...(mergedQuestlines !== existingQuestlines + ? [ + { + path: "db/seeds/questlines.json", + content: JSON.stringify(mergedQuestlines, null, "\t"), + }, + ] + : []), + ], + }), + ); + + const prUrl = `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${prNumber}`; + + const row = await DB.insertInto("quest_proposal") + .values({ + user_id: sessionUser.id, + github_pr_number: prNumber, + github_branch: branch, + raw_quests: JSON.stringify(rawQuests), + raw_questlines: JSON.stringify(rawQuestlines ?? []), + status: "open", + }) + .returning([ + "quest_proposal.row_id", + "quest_proposal.github_pr_number", + "quest_proposal.github_branch", + "quest_proposal.status", + "quest_proposal.created_at", + ]) + .executeTakeFirstOrThrow(); + + return success(`Opened quest proposal PR #${prNumber}.`, { + proposalID: row.row_id, + prNumber: row.github_pr_number, + prUrl, + status: row.status, + }); +}); + +// ─── GET /proposals — List all open proposals ───────────────────────────────── + +/** + * List all open quest proposals (paginated, newest first). + * + * @name GET /api/v1/proposals + */ +API_V1_ROUTER.add("GET /proposals", async ({ input }) => { + requireGitHubConfig(); + + const page = Math.max(0, Number(input.page ?? 0)); + const limit = 20; + + const rows = await DB.selectFrom("quest_proposal") + .innerJoin("account", "account.id", "quest_proposal.user_id") + .select([ + "quest_proposal.row_id", + "quest_proposal.github_pr_number", + "quest_proposal.github_branch", + "quest_proposal.status", + "quest_proposal.raw_quests", + "quest_proposal.created_at", + "account.username", + ]) + .where("quest_proposal.status", "=", "open") + .orderBy("quest_proposal.created_at", "desc") + .limit(limit) + .offset(page * limit) + .execute(); + + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + + const proposals = rows.map((row) => { + const quests = ( + typeof row.raw_quests === "string" + ? (JSON.parse(row.raw_quests) as Array) + : (row.raw_quests as unknown as Array) + ).map((q: RawQuestDocument) => ({ name: q.name, game: q.game })); + + return { + proposalID: row.row_id, + prNumber: row.github_pr_number, + prUrl: `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${row.github_pr_number}`, + status: row.status, + submitterUsername: row.username, + quests, + createdAt: row.created_at, + }; + }); + + return success(`Retrieved ${proposals.length} proposals.`, { proposals, page }); +}); + +// ─── GET /proposals/mine — Current user's proposals ─────────────────────────── + +/** + * List the calling user's quest proposals. + * + * @name GET /api/v1/proposals/mine + */ +API_V1_ROUTER.add("GET /proposals/mine", async ({ req }) => { + requireGitHubConfig(); + + const sessionUser = requireSession(req); + + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + + const rows = await DB.selectFrom("quest_proposal") + .select([ + "quest_proposal.row_id", + "quest_proposal.github_pr_number", + "quest_proposal.github_branch", + "quest_proposal.status", + "quest_proposal.raw_quests", + "quest_proposal.raw_questlines", + "quest_proposal.created_at", + "quest_proposal.updated_at", + ]) + .where("quest_proposal.user_id", "=", sessionUser.id) + .orderBy("quest_proposal.created_at", "desc") + .execute(); + + const proposals = rows.map((row) => ({ + proposalID: row.row_id, + prNumber: row.github_pr_number, + prUrl: `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${row.github_pr_number}`, + status: row.status, + rawQuests: typeof row.raw_quests === "string" ? JSON.parse(row.raw_quests) : row.raw_quests, + rawQuestlines: + typeof row.raw_questlines === "string" + ? JSON.parse(row.raw_questlines) + : row.raw_questlines, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + + return success(`Retrieved ${proposals.length} proposals.`, { proposals }); +}); + +// ─── GET /proposals/:proposalID — Single proposal + live PR status ───────────── + +/** + * Get a single quest proposal, including its live PR status fetched from GitHub. + * + * @name GET /api/v1/proposals/:proposalID + */ +API_V1_ROUTER.add("GET /proposals/:proposalID", async ({ params }) => { + requireGitHubConfig(); + + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + + const row = await DB.selectFrom("quest_proposal") + .innerJoin("account", "account.id", "quest_proposal.user_id") + .select([ + "quest_proposal.row_id", + "quest_proposal.github_pr_number", + "quest_proposal.github_branch", + "quest_proposal.status", + "quest_proposal.raw_quests", + "quest_proposal.raw_questlines", + "quest_proposal.created_at", + "quest_proposal.updated_at", + "account.username", + ]) + .where("quest_proposal.row_id", "=", params.proposalID) + .executeTakeFirst(); + + if (!row) { + throw new ExpectedErr(404, `No proposal found with ID '${params.proposalID}'.`); + } + + // Fetch live PR status from GitHub (unless already closed/merged) + let liveStatus: "closed" | "merged" | "open" = row.status as "closed" | "merged" | "open"; + + if (row.status === "open") { + try { + liveStatus = await getPRStatus(row.github_pr_number); + + // Sync the DB if the status changed + if (liveStatus !== row.status) { + await DB.updateTable("quest_proposal") + .set({ status: liveStatus, updated_at: new Date().toISOString() }) + .where("quest_proposal.row_id", "=", row.row_id) + .execute(); + } + } catch (err) { + log.warn( + { err, prNumber: row.github_pr_number }, + "Failed to fetch live PR status from GitHub.", + ); + } + } + + return success(`Retrieved proposal ${params.proposalID}.`, { + proposalID: row.row_id, + prNumber: row.github_pr_number, + prUrl: `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${row.github_pr_number}`, + status: liveStatus, + submitterUsername: row.username, + rawQuests: typeof row.raw_quests === "string" ? JSON.parse(row.raw_quests) : row.raw_quests, + rawQuestlines: + typeof row.raw_questlines === "string" + ? JSON.parse(row.raw_questlines) + : row.raw_questlines, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +}); + +// ─── PUT /proposals/:proposalID — Update quest data ──────────────────────────── + +/** + * Update the quest content of an existing proposal, pushing a new commit to + * the branch and updating the PR. + * + * @name PUT /api/v1/proposals/:proposalID + */ +API_V1_ROUTER.add("PUT /proposals/:proposalID", async ({ req, params, input }) => { + requireGitHubConfig(); + + const sessionUser = requireSession(req); + + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + + const row = await DB.selectFrom("quest_proposal") + .select([ + "quest_proposal.row_id", + "quest_proposal.github_pr_number", + "quest_proposal.github_branch", + "quest_proposal.status", + "quest_proposal.user_id", + ]) + .where("quest_proposal.row_id", "=", params.proposalID) + .executeTakeFirst(); + + if (!row) { + throw new ExpectedErr(404, `No proposal found with ID '${params.proposalID}'.`); + } + + if (row.user_id !== sessionUser.id) { + throw new ExpectedErr(403, "You can only update your own proposals."); + } + + if (row.status !== "open") { + throw new ExpectedErr(409, `Cannot update a proposal that is ${row.status}.`); + } + + const { + quests: rawQuests, + questlines: rawQuestlines, + prTitle, + } = input as { + prTitle?: string; + questlines?: Array; + quests: Array; + }; + + const { quests: seedQuests, goals: newGoals } = hydrateRawQuests(rawQuests); + const questNameToID = new Map(rawQuests.map((raw, i) => [raw.name, seedQuests[i]!.questID])); + const seedQuestlines = rawQuestlines ? hydrateRawQuestlines(rawQuestlines, questNameToID) : []; + + const octokit = await callGitHub(() => getInstallationOctokit()); + + const { content: existingQuestsRaw } = await callGitHub(() => + readRepoFile(octokit, "db/seeds/quests.json"), + ); + const { content: existingGoalsRaw } = await callGitHub(() => + readRepoFile(octokit, "db/seeds/goals.json"), + ); + const { content: existingQuestlinesRaw } = await callGitHub(() => + readRepoFile(octokit, "db/seeds/questlines.json"), + ); + + const existingQuests = JSON.parse(existingQuestsRaw) as Array; + const existingGoals = JSON.parse(existingGoalsRaw) as Array; + const existingQuestlines = JSON.parse(existingQuestlinesRaw) as Array; + + const { + quests: mergedQuests, + goals: mergedGoals, + questlines: mergedQuestlines, + } = mergeIntoSeeds({ + existingQuests, + existingGoals, + existingQuestlines, + newQuests: seedQuests, + newGoals, + newQuestlines: seedQuestlines, + }); + + const questNames = seedQuests.map((q) => q.name).join(", "); + const title = prTitle?.trim() || `Add quest: ${questNames}`; + const prBody = buildPRBody(sessionUser.username, seedQuests, seedQuestlines); + + await callGitHub(() => + openOrUpdateProposalPR({ + octokit, + branch: row.github_branch, + prTitle: title, + prBody, + changes: [ + { path: "db/seeds/quests.json", content: JSON.stringify(mergedQuests, null, "\t") }, + { path: "db/seeds/goals.json", content: JSON.stringify(mergedGoals, null, "\t") }, + ...(mergedQuestlines !== existingQuestlines + ? [ + { + path: "db/seeds/questlines.json", + content: JSON.stringify(mergedQuestlines, null, "\t"), + }, + ] + : []), + ], + existingPrNumber: row.github_pr_number, + }), + ); + + await DB.updateTable("quest_proposal") + .set({ + raw_quests: JSON.stringify(rawQuests), + raw_questlines: JSON.stringify(rawQuestlines ?? []), + updated_at: new Date().toISOString(), + }) + .where("quest_proposal.row_id", "=", row.row_id) + .execute(); + + return success(`Updated quest proposal PR #${row.github_pr_number}.`, { + proposalID: row.row_id, + prNumber: row.github_pr_number, + prUrl: `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${row.github_pr_number}`, + }); +}); + +// ─── DELETE /proposals/:proposalID — Withdraw a proposal ────────────────────── + +/** + * Close (withdraw) a quest proposal. Closes the GitHub PR and marks the row as + * closed. Only the submitter or an admin may withdraw. + * + * @name DELETE /api/v1/proposals/:proposalID + */ +API_V1_ROUTER.add("DELETE /proposals/:proposalID", async ({ req, params }) => { + requireGitHubConfig(); + + const sessionUser = requireSession(req); + const cfg = ServerConfig.GITHUB_APP_CONFIG!; + + const row = await DB.selectFrom("quest_proposal") + .innerJoin("account", "account.id", "quest_proposal.user_id") + .select([ + "quest_proposal.row_id", + "quest_proposal.github_pr_number", + "quest_proposal.github_branch", + "quest_proposal.status", + "quest_proposal.user_id", + "account.auth_level", + ]) + .where("quest_proposal.row_id", "=", params.proposalID) + .executeTakeFirst(); + + if (!row) { + throw new ExpectedErr(404, `No proposal found with ID '${params.proposalID}'.`); + } + + const isOwner = row.user_id === sessionUser.id; + const isAdmin = row.auth_level === "admin"; + + if (!isOwner && !isAdmin) { + throw new ExpectedErr(403, "You can only withdraw your own proposals."); + } + + if (row.status !== "open") { + throw new ExpectedErr(409, `Proposal is already ${row.status}.`); + } + + // Close the PR on GitHub + try { + const octokit = await getInstallationOctokit(); + + await octokit.request("PATCH /repos/{owner}/{repo}/pulls/{pull_number}", { + owner: cfg.REPO_OWNER, + repo: cfg.REPO_NAME, + pull_number: row.github_pr_number, + state: "closed", + }); + } catch (err) { + log.warn( + { err, prNumber: row.github_pr_number }, + "Failed to close GitHub PR when withdrawing proposal.", + ); + } + + await DB.updateTable("quest_proposal") + .set({ status: "closed", updated_at: new Date().toISOString() }) + .where("quest_proposal.row_id", "=", row.row_id) + .execute(); + + return success("Proposal withdrawn.", { proposalID: row.row_id }); +}); + +// ─── POST /proposals/webhook/merged — github-bot notification ───────────────── + +/** + * Called by the github-bot when a quest-proposal PR is merged. + * Validates a shared secret header and marks the matching proposal as merged. + * + * @name POST /api/v1/proposals/webhook/merged + */ +API_V1_ROUTER.add("POST /proposals/webhook/merged", async ({ req, input }) => { + const cfg = requireGitHubConfig(); + + const secret = req.headers["x-tachi-webhook-secret"]; + + if (secret !== cfg.WEBHOOK_SECRET) { + throw new ExpectedErr(401, "Invalid webhook secret."); + } + + const { prNumber } = input as { prNumber: number }; + + if (typeof prNumber !== "number") { + throw new ExpectedErr(400, "prNumber must be a number."); + } + + const result = await DB.updateTable("quest_proposal") + .set({ status: "merged", updated_at: new Date().toISOString() }) + .where("quest_proposal.github_pr_number", "=", prNumber) + .where("quest_proposal.status", "=", "open") + .returning("quest_proposal.row_id") + .executeTakeFirst(); + + if (!result) { + // Not necessarily an error — bot may call this for non-proposal PRs too + return success("No matching open proposal for this PR.", { updated: false }); + } + + log.info({ prNumber, proposalID: result.row_id }, "Quest proposal marked as merged."); + + return success("Proposal marked as merged.", { updated: true, proposalID: result.row_id }); +}); diff --git a/typescript/server/src/server/router/api/v1/router.ts b/typescript/server/src/server/router/api/v1/router.ts index 99f2446cb..46a7b27d7 100644 --- a/typescript/server/src/server/router/api/v1/router.ts +++ b/typescript/server/src/server/router/api/v1/router.ts @@ -23,6 +23,7 @@ await import("./activity/router"); await import("./config/router"); await import("./localdev/router"); await import("./seeds/router"); +await import("./proposals/router"); await import("./scores/_scoreID/router"); await import("./users/_userID/router"); diff --git a/typescript/server/src/server/router/api/v1/spec.ts b/typescript/server/src/server/router/api/v1/spec.ts index df53a8616..60492f1a4 100644 --- a/typescript/server/src/server/router/api/v1/spec.ts +++ b/typescript/server/src/server/router/api/v1/spec.ts @@ -2008,6 +2008,18 @@ export const API_V1_SPEC = { output: empty, }, + "POST /admin/quest-submitter/:userID": { + description: "Grant quest-submission permission to a user.", + input: z.object({}), + output: empty, + }, + + "DELETE /admin/quest-submitter/:userID": { + description: "Revoke quest-submission permission 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() }), @@ -2081,6 +2093,108 @@ export const API_V1_SPEC = { input: z.object({}), output: z.strictObject({ username: z.string(), password: z.string() }), }, + + // ──────────────────────────────────────────────── + // Quest Proposals + // ──────────────────────────────────────────────── + + "POST /proposals": { + description: "Submit quest(s) as a GitHub PR. Requires GITHUB_APP_CONFIG to be set.", + input: z.object({ + quests: z.array(z.record(z.string(), z.unknown())), + questlines: z.array(z.record(z.string(), z.unknown())).optional(), + prTitle: z.string().max(200).optional(), + }), + output: z.strictObject({ + proposalID: z.string(), + prNumber: z.number(), + prUrl: z.string(), + status: z.string(), + }), + }, + + "GET /proposals": { + description: "List all open quest proposals.", + input: z.object({ page: z.coerce.number().int().optional() }), + output: z.strictObject({ + proposals: z.array( + z.strictObject({ + proposalID: z.string(), + prNumber: z.number(), + prUrl: z.string(), + status: z.string(), + submitterUsername: z.string(), + quests: z.array(z.strictObject({ name: z.string(), game: z.string() })), + createdAt: z.string(), + }), + ), + page: z.number(), + }), + }, + + "GET /proposals/mine": { + description: "List the calling user's quest proposals.", + input: z.object({}), + output: z.strictObject({ + proposals: z.array( + z.strictObject({ + proposalID: z.string(), + prNumber: z.number(), + prUrl: z.string(), + status: z.string(), + rawQuests: z.unknown(), + rawQuestlines: z.unknown(), + createdAt: z.string(), + updatedAt: z.string(), + }), + ), + }), + }, + + "GET /proposals/:proposalID": { + description: "Get a single quest proposal with live GitHub PR status.", + input: z.object({}), + output: z.strictObject({ + proposalID: z.string(), + prNumber: z.number(), + prUrl: z.string(), + status: z.string(), + submitterUsername: z.string(), + rawQuests: z.unknown(), + rawQuestlines: z.unknown(), + createdAt: z.string(), + updatedAt: z.string(), + }), + }, + + "PUT /proposals/:proposalID": { + description: "Update quest content of an existing proposal (push new commit to branch).", + input: z.object({ + quests: z.array(z.record(z.string(), z.unknown())), + questlines: z.array(z.record(z.string(), z.unknown())).optional(), + prTitle: z.string().max(200).optional(), + }), + output: z.strictObject({ + proposalID: z.string(), + prNumber: z.number(), + prUrl: z.string(), + }), + }, + + "DELETE /proposals/:proposalID": { + description: "Withdraw (close) a quest proposal.", + input: z.object({}), + output: z.strictObject({ proposalID: z.string() }), + }, + + "POST /proposals/webhook/merged": { + description: "Internal webhook called by github-bot when a quest-proposal PR is merged.", + input: z.object({ prNumber: z.number() }), + output: z.strictObject({ + updated: z.boolean(), + proposalID: z.string().optional(), + }), + }, } as const satisfies AnyRouterSpec; export type APIv1Spec = typeof API_V1_SPEC; 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 index e119ae8c6..6b6bcc58f 100644 --- 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 @@ -58,27 +58,31 @@ describe("GET /api/v1/users/:userID/games/:game/targets/goals", () => { const chartId = await seedMinimalIidxSpChart(); const goalId = `G_get_${Date.now()}`; - await DB.insertInto("goal").values({ - id: goalId, - game: "iidx-sp", - name: "Listed goal", - charts: JSON.stringify({ type: "single", data: chartId }), - criteria: JSON.stringify({ mode: "single", key: "lamp", value: 7 }), - }).execute(); + await DB.insertInto("goal") + .values({ + id: goalId, + game: "iidx-sp", + name: "Listed goal", + charts: JSON.stringify({ type: "single", data: chartId }), + criteria: JSON.stringify({ mode: "single", key: "lamp", value: 7 }), + }) + .execute(); - await DB.insertInto("goal_sub").values({ - goal_id: goalId, - user_id: userId, - achieved: false, - time_achieved: null, - progress: null, - progress_human: "NO DATA", - out_of: 7, - out_of_human: "FULL COMBO", - last_interaction: null, - was_instantly_achieved: false, - was_assigned_standalone: true, - }).execute(); + await DB.insertInto("goal_sub") + .values({ + goal_id: goalId, + user_id: userId, + achieved: false, + time_achieved: null, + progress: null, + progress_human: "NO DATA", + out_of: 7, + out_of_human: "FULL COMBO", + last_interaction: null, + was_instantly_achieved: false, + was_assigned_standalone: true, + }) + .execute(); const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/goals`); @@ -187,27 +191,31 @@ describe("GET /api/v1/users/:userID/games/:game/targets/goals/:goalID", () => { const chartId = await seedMinimalIidxSpChart(); const goalId = `G_single_${Date.now()}`; - await DB.insertInto("goal").values({ - id: goalId, - game: "iidx-sp", - name: "Single test goal", - charts: JSON.stringify({ type: "single", data: chartId }), - criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }), - }).execute(); + await DB.insertInto("goal") + .values({ + id: goalId, + game: "iidx-sp", + name: "Single test goal", + charts: JSON.stringify({ type: "single", data: chartId }), + criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }), + }) + .execute(); - await DB.insertInto("goal_sub").values({ - goal_id: goalId, - user_id: userId, - achieved: false, - time_achieved: null, - progress: null, - progress_human: "NO DATA", - out_of: 4, - out_of_human: "HARD CLEAR", - last_interaction: null, - was_instantly_achieved: false, - was_assigned_standalone: true, - }).execute(); + await DB.insertInto("goal_sub") + .values({ + goal_id: goalId, + user_id: userId, + achieved: false, + time_achieved: null, + progress: null, + progress_human: "NO DATA", + out_of: 4, + out_of_human: "HARD CLEAR", + last_interaction: null, + was_instantly_achieved: false, + was_assigned_standalone: true, + }) + .execute(); const res = await mockApi.get( `/api/v1/users/${userId}/games/iidx-sp/targets/goals/${goalId}`, 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 5f0b537f3..1606f1b97 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 @@ -169,9 +169,7 @@ API_V1_ROUTER.add( const taker = { acct: { id: sessionUser.id, username: sessionUser.username }, ip: req.ip }; - const { ACTION_UpdateGoalSubscription } = await import( - "#actions/update-goal-subscription" - ); + const { ACTION_UpdateGoalSubscription } = await import("#actions/update-goal-subscription"); const { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } = await import( "#utils/db" ); 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 index 712d5ea30..4780b5ab4 100644 --- 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 @@ -229,8 +229,8 @@ describe("PUT /api/v1/users/:userID/games/:game/targets/quests/:questID", () => expect(res.status).toBe(409); // The error message must mention "quest", not "goal" (regression guard for the bug fix) - expect(res.body.description).toMatch(/quest/i); - expect(res.body.description).not.toMatch(/goal/i); + expect(res.body.description).toMatch(/quest/iu); + expect(res.body.description).not.toMatch(/goal/iu); }); }); diff --git a/typescript/server/src/services/pg/seeds.ts b/typescript/server/src/services/pg/seeds.ts index 8c810a533..c5b36b723 100644 --- a/typescript/server/src/services/pg/seeds.ts +++ b/typescript/server/src/services/pg/seeds.ts @@ -1,8 +1,6 @@ import { ALL_GAMES, type ChartDocument, - LEGACY_GameGroupPTToGame, - type LEGACY_Playtype, type SEEDS_BMSCourseDocument, type SEEDS_ChartDocument, type SEEDS_FolderDocument, @@ -38,21 +36,17 @@ import { type Insertable, type Kysely, type RawBuilder, sql } from "kysely"; import path from "path"; /** - * Maps `goals.json` / `quests.json` / `questlines.json` fields where `game` may be a - * legacy {@link GameGroup} (e.g. "iidx") to the Postgres `game` enum (e.g. "iidx-sp"). + * Maps a `game` field from seeds JSON (now always a V3Game, e.g. "iidx-sp") to + * the Postgres `game` enum. */ -function seedJsonGameToPg(game: string, playtype: string | undefined, label: string): PgGame { +function seedJsonGameToPg(game: string, label: string): PgGame { if ((ALL_GAMES as readonly string[]).includes(game)) { return game as PgGame; } - if (playtype === undefined) { - throw new Error( - `[seeds] ${label}: missing playtype for legacy game group ${JSON.stringify(game)}`, - ); - } - - return LEGACY_GameGroupPTToGame(game as GameGroup, playtype as LEGACY_Playtype) as PgGame; + throw new Error( + `[seeds] ${label}: unrecognised game ${JSON.stringify(game)}. Expected a V3Game string.`, + ); } const INSERT_CHUNK = 500; @@ -553,7 +547,7 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi const seeds = readSeedFile(seedsDir, "goals.json"); const rows: Array = seeds.map((g) => ({ id: g.goalID, - game: seedJsonGameToPg(g.game, g.playtype, `goal ${g.goalID}`), + game: seedJsonGameToPg(g.game, `goal ${g.goalID}`), name: g.name, charts: JSON.stringify(g.charts), criteria: JSON.stringify(g.criteria), @@ -579,7 +573,7 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi const seeds = readSeedFile(seedsDir, "quests.json"); const rows: Array = seeds.map((q) => ({ id: q.questID, - game: seedJsonGameToPg(q.game, q.playtype, `quest ${q.questID}`), + game: seedJsonGameToPg(q.game, `quest ${q.questID}`), name: q.name, description: q.desc, quest_data: JSON.stringify(q.questData), @@ -608,7 +602,7 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi const seeds = readSeedFile(seedsDir, "questlines.json"); const rows: Array = seeds.map((ql) => ({ id: ql.questlineID, - game: seedJsonGameToPg(ql.game, ql.playtype, `questline ${ql.questlineID}`), + game: seedJsonGameToPg(ql.game, `questline ${ql.questlineID}`), name: ql.name, description: ql.desc, }));