* checkpoint

* server compiles again

* fmt

* checkpoint

* last one out, get the lights

* euston station

* like, mostly good. I'm under the ocean right now.

* fix: some nonsense AI stuff

* fix: new eslint rules, and cleanup imports across the board

* feat: folder slug rewrite, email logic change, too

* fix: register must use txn not db

* checkpoint
This commit is contained in:
zk
2026-04-20 23:11:16 +01:00
committed by GitHub
parent edda03e916
commit 2bc4c3a628
1073 changed files with 52259 additions and 56015 deletions
+4 -13
View File
@@ -1,19 +1,10 @@
---
name: no-oldtest-edits
description: Treats `*.oldtest.ts` files as read-only legacy snapshots. Never edits them. Use when changing tests, game implementations, or when any path matches `*.oldtest.ts`.
description: Legacy. `*.oldtest.ts` snapshots were removed from the Tachi server after migration to Vitest `*.test.ts`. No action needed unless oldtest files are reintroduced.
---
# No edits to `*.oldtest.ts`
# Legacy note
## Hard rule
Server tests live in `*.test.ts` under `typescript/server/src/`. The former Tap `*.oldtest.ts` tree has been deleted.
**Do not modify** any file whose name matches `*.oldtest.ts` (for example `jubeat.oldtest.ts`). These are frozen reference tests.
## What to do instead
- Make test or implementation changes in the corresponding non-`oldtest` files (for example `*.test.ts` next to the implementation, or the main `*.ts` source).
- If behavior must be verified against old expectations, add or update tests outside `*.oldtest.ts`.
## Checklist before finishing a task
- [ ] No file edited under a `*.oldtest.ts` path.
If `*.oldtest.ts` files appear again, treat them as read-only snapshots and add or change behavior only in `*.test.ts` or source files.
+6 -1
View File
@@ -4,7 +4,12 @@
"service": "tachi-dev",
"workspaceFolder": "/tachi",
"containerEnv": {
"MINIO_ENDPOINT": "http://tachi-s3:9000"
"MINIO_ENDPOINT": "http://tachi-s3:9000",
"TACHI_EMAIL_FROM": "dev@localhost",
"TACHI_EMAIL_AUTH_POSTMARK": "false",
"TACHI_EMAIL_HOST": "tachi-mailpit",
"TACHI_EMAIL_PORT": "1025",
"TACHI_EMAIL_SECURE": "false"
},
"postAttachCommand": "./dev/bootstrap.sh",
"updateRemoteUserUID": true,
+2 -1
View File
@@ -44,5 +44,6 @@
"files.associations": {
"Justfile-*": "makefile"
},
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"remote.autoForwardPortsFallback": 0
}
+1 -1
View File
@@ -14,7 +14,7 @@ start:
server:
cd typescript/server && \
cd typescript/server && \
VERSION=$(jq -r .version package.json) \
COMMIT_HASH=$(git rev-parse --short HEAD) \
bun --watch src/main.ts
+1 -1
View File
@@ -1,4 +1,4 @@
# Misc nonsense
count-lines-of-code:
tokei -e node_modules/ -e vendor/ -e pnpm-lock.yaml
tokei -e node_modules/ -e vendor/ -e bun.lock
+1 -1
View File
@@ -68,7 +68,7 @@ test-typescript FILTER="*":
#!/bin/bash
set -euo pipefail
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
# Summarize Vitest v8 coverage across workspaces (see typescript/coverage-tools).
coverage-report *ARGS:
+1 -1
View File
@@ -58,7 +58,7 @@ That said, if you're against using vscode you can:
If those instructions don't make sense to you, I really recommend just using dev containers.
Alternatively **if you want to work outside of docker you're on your own**. Figure out the correct versions for everything (npm, pnpm, mongo, redis, ts-node...) and `dev/bootstrap.sh`.
Alternatively **if you want to work outside of docker you're on your own**. Figure out the correct versions for everything (bun, postgres, redis, ts-node...) and `dev/bootstrap.sh`.
## Repository Info
+24 -1
View File
@@ -238,6 +238,7 @@
"eslint-plugin-react": "catalog:",
"eslint-plugin-react-hooks": "catalog:",
"eslint-plugin-react-refresh": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"globals": "catalog:",
"typescript-eslint": "catalog:",
},
@@ -310,7 +311,7 @@
},
"typescript/server": {
"name": "tachi-server",
"version": "2.5.1",
"version": "3.0.0",
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
"@types/bcryptjs": "catalog:",
@@ -393,6 +394,7 @@
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint-config-tachi": "workspace:*",
"vitest": "catalog:",
},
@@ -487,6 +489,7 @@
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.14",
"eslint-plugin-unused-imports": "^4.1.4",
"express": "4.20.0",
"express-async-errors": "3.1.1",
"express-prom-bundle": "^7.0.0",
@@ -1029,6 +1032,8 @@
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@package-json/types": ["@package-json/types@0.0.12", "", {}, "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw=="],
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
@@ -1581,6 +1586,8 @@
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="],
"commist": ["commist@1.1.0", "", { "dependencies": { "leven": "^2.1.0", "minimist": "^1.1.0" } }, "sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg=="],
"component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="],
@@ -1825,6 +1832,8 @@
"eslint-config-tachi": ["eslint-config-tachi@workspace:typescript/eslint-config"],
"eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="],
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
"eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="],
@@ -1833,6 +1842,8 @@
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
"eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.2", "", { "dependencies": { "@package-json/types": "^0.0.12", "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw=="],
"eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
"eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@3.9.1", "", { "dependencies": { "@typescript-eslint/types": "^8.9.0", "@typescript-eslint/utils": "^8.9.0", "minimatch": "^9.0.5", "natural-compare-lite": "^1.4.0" }, "peerDependencies": { "astro-eslint-parser": "^1.0.2", "eslint": ">=8.0.0", "svelte": ">=3.0.0", "svelte-eslint-parser": "^0.41.1", "vue-eslint-parser": ">=9.0.0" }, "optionalPeers": ["astro-eslint-parser", "svelte", "svelte-eslint-parser", "vue-eslint-parser"] }, "sha512-9WRzf6XaAxF4Oi5t/3TqKP5zUjERhasHmLFHin2Yw6ZAp/EP/EVA2dr3BhQrrHWCm5SzTMZf0FcjDnBkO2xFkA=="],
@@ -1843,6 +1854,8 @@
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
"eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-utils": ["eslint-utils@3.0.0", "", { "dependencies": { "eslint-visitor-keys": "^2.0.0" }, "peerDependencies": { "eslint": ">=5" } }, "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA=="],
@@ -2757,6 +2770,8 @@
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
@@ -3207,6 +3222,10 @@
"eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
"eslint-plugin-import-x/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
"eslint-plugin-import-x/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"eslint-plugin-perfectionist/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
"eslint-plugin-perfectionist/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -3425,6 +3444,8 @@
"duplexify/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"eslint-plugin-import-x/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"eslint-plugin-perfectionist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"express-session/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -3487,6 +3508,8 @@
"@vitest/mocker/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.15.18", "", { "os": "linux", "cpu": "none" }, "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ=="],
"eslint-plugin-import-x/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+62 -4
View File
@@ -415,7 +415,7 @@ CREATE TABLE "bms_course_lookup" (
),
title TEXT NOT NULL UNIQUE,
set TEXT NOT NULL,
playtype TEXT NOT NULL,
game GAME NOT NULL,
value TEXT NOT NULL
);
@@ -427,7 +427,7 @@ CREATE TABLE "folder" (
title TEXT NOT NULL,
-- Used in URLs. should be short, but must be unique per game!
slug TEXT,
slug TEXT NOT NULL,
-- SQL predicate (no leading WHERE) for charts in this folder; matches seeds `where`.
-- Quoted: "where" is a reserved word in SQL.
@@ -437,7 +437,7 @@ CREATE TABLE "folder" (
version_filter TEXT[],
search_terms TEXT[] NOT NULL
);
CREATE UNIQUE INDEX folder_unique_slug_game ON "folder" (game, slug) WHERE slug IS NOT NULL;
CREATE UNIQUE INDEX folder_unique_slug_game ON "folder" (game, slug);
CREATE TABLE "table" ( -- heh
id TEXT PRIMARY KEY NOT NULL,
@@ -458,6 +458,8 @@ CREATE UNIQUE INDEX one_default_table_per_game ON "table" (game) WHERE default_v
CREATE TABLE "table_folder" (
table_id TEXT REFERENCES "table"(id) NOT NULL,
folder_id TEXT REFERENCES folder(id) NOT NULL,
-- Order within the table; matches the `folders` array index in `tables.json`.
ordering INTEGER NOT NULL,
PRIMARY KEY (table_id, folder_id)
);
@@ -522,7 +524,11 @@ CREATE TABLE "chart" (
versions TEXT[] NOT NULL,
data JSONB NOT NULL -- game specific payload
data JSONB NOT NULL, -- game specific payload
-- SHA-256 hex digest of chart fields that feed scoreDeriver/scoreCalcs. When this
-- changes, scores on the chart need re-derivation.
derivation_checksum TEXT
);
-- Denormalized chart → folders cache (rebuilt by app; see rebuildFolderChartLookup).
@@ -750,6 +756,58 @@ CREATE TABLE "score" (
committed BOOLEAN NOT NULL DEFAULT TRUE
);
-- Mark here when PBs become dirty and need to be recalculated.
CREATE TABLE pb_dirty (
user_id BIGINT NOT NULL,
chart_id TEXT NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, chart_id)
);
-- Mark here when scores become dirty and need to be recalculated.
CREATE TABLE "score_rederive" (
chart_id TEXT NOT NULL PRIMARY KEY,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Trigger function: on any score INSERT/UPDATE/DELETE, mark the (user_id, chart_id)
-- pair as needing PB recalculation.
CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO pb_dirty (user_id, chart_id)
VALUES (OLD.user_id, OLD.chart_id)
ON CONFLICT DO NOTHING;
ELSE
INSERT INTO pb_dirty (user_id, chart_id)
VALUES (NEW.user_id, NEW.chart_id)
ON CONFLICT DO NOTHING;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "score_pb_dirty"
AFTER INSERT OR UPDATE OR DELETE ON score
FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty();
-- Trigger function: on chart UPDATE, if derivation_checksum changed, enqueue the chart
-- for score re-derivation.
CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$
BEGIN
IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN
INSERT INTO score_rederive (chart_id)
VALUES (NEW.id)
ON CONFLICT DO NOTHING;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "chart_score_rederive"
AFTER UPDATE ON chart
FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive();
CREATE TABLE "pb" (
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
@@ -1,58 +0,0 @@
-- Derivation checksum on chart: a SHA-256 hex digest of the chart fields
-- that feed into scoreDeriver/scoreCalcs. When this changes, all scores
-- on the chart need re-derivation.
ALTER TABLE chart ADD COLUMN derivation_checksum TEXT;
-- Dirty queue: score mutations enqueue (user_id, chart_id) pairs that
-- need PB recalculation. Deduplication via the PK.
CREATE TABLE pb_dirty (
user_id BIGINT NOT NULL,
chart_id TEXT NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, chart_id)
);
-- Dirty queue: chart mutations (when derivation_checksum changes) enqueue
-- chart_ids whose scores need re-derivation. One row per chart.
CREATE TABLE score_rederive (
chart_id TEXT NOT NULL PRIMARY KEY,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Trigger function: on any score INSERT/UPDATE/DELETE, mark the
-- (user_id, chart_id) pair as needing PB recalculation.
CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO pb_dirty (user_id, chart_id)
VALUES (OLD.user_id, OLD.chart_id)
ON CONFLICT DO NOTHING;
ELSE
INSERT INTO pb_dirty (user_id, chart_id)
VALUES (NEW.user_id, NEW.chart_id)
ON CONFLICT DO NOTHING;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER score_pb_dirty
AFTER INSERT OR UPDATE OR DELETE ON score
FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty();
-- Trigger function: on chart UPDATE, if derivation_checksum changed,
-- enqueue the chart for score re-derivation.
CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$
BEGIN
IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN
INSERT INTO score_rederive (chart_id)
VALUES (NEW.id)
ON CONFLICT DO NOTHING;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER chart_score_rederive
AFTER UPDATE ON chart
FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive();
File diff suppressed because it is too large Load Diff
+9748 -5838
View File
File diff suppressed because it is too large Load Diff
+3899 -3911
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -3,7 +3,6 @@
defnew g=git
defnew j=just
defnew c=cargo
defnew p=pnpm
defnew gp=git push
defnew gsw=git switch
-5
View File
@@ -35,11 +35,6 @@ function cmd
end
function fish_greeting
# check $PATH is correct
if not contains ~/.local/pnpm $PATH
fish_add_path ~/.local/pnpm
end
if test (random 0 100) -lt 10; and test -d /host-pc/dl # entropy
perl /tachi/dev/eye.pl
end
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,7 +1,6 @@
#!/bin/fish
# add a local bin for ts-node
fish_add_path ~/.local/pnpm
fish_add_path /tachi/node_modules/.bin
source ./dev/fish-plugins/fisher/functions/fisher.fish
+10
View File
@@ -64,12 +64,22 @@ services:
- tachi-minio:/data
entrypoint: /usr/bin/minio server /data --console-address=':9001'
# Local SMTP capture + web UI (http://localhost:8025). Server uses tachi-mailpit:1025 via env.
tachi-mailpit:
container_name: tachi-mailpit
image: axllent/mailpit:v1.29.7
restart: unless-stopped
ports:
- "8025:8025"
- "1025:1025"
tachi-dev:
user: "1000:1000"
tty: true
container_name: tachi-dev
depends_on:
- tachi-s3
- tachi-mailpit
build:
dockerfile: Dockerfile.dev
ports:
+11 -11
View File
@@ -8,7 +8,7 @@ For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endp
## Retrieve this game's recently achieved targets
`GET /api/v1/games/:game/:playtype/targets/recently-achieved`
`GET /api/v1/games/:gameGroup/:playtype/targets/recently-achieved`
!!! info
This endpoint returns the 100 most recently achieved goal subscriptions, and 50 most recently achieved quest subscriptions.
@@ -69,7 +69,7 @@ GET /api/v1/games/iidx/SP/targets/recently-achieved
## Retrieve this game's recently interacted-with targets
`GET /api/v1/games/:game/:playtype/targets/recently-raised`
`GET /api/v1/games/:gameGroup/:playtype/targets/recently-raised`
!!! info
This endpoint returns the 100 most recently interacted-with goal subscriptions, and 50 most recently interacted-with quest subscriptions.
@@ -135,7 +135,7 @@ GET /api/v1/games/iidx/SP/targets/recently-raised
## Get the most popular goals for this GPT.
`GET /api/v1/games/:game/:playtype/targets/goals/popular`
`GET /api/v1/games/:gameGroup/:playtype/targets/goals/popular`
### Parameters
@@ -152,7 +152,7 @@ N/A
#### Request
```
GET /api/v1/games/:game/:playtype/targets/goals/popular
GET /api/v1/games/:gameGroup/:playtype/targets/goals/popular
```
#### Response
@@ -174,7 +174,7 @@ GET /api/v1/games/:game/:playtype/targets/goals/popular
## Retrieve information about a specific goal and its subscribers.
`GET /api/v1/games/:game/:playtype/targets/goals/:goalID`
`GET /api/v1/games/:gameGroup/:playtype/targets/goals/:goalID`
### Parameters
@@ -193,7 +193,7 @@ None.
## Evaluate a goal upon a user.
`GET /api/v1/games/:game/:playtype/targets/goals/:goalID/evaluate-for`
`GET /api/v1/games/:gameGroup/:playtype/targets/goals/:goalID/evaluate-for`
!!! note
This endpoint is notably in a bit of a strange position. It can't go under UGPT because
@@ -259,7 +259,7 @@ GET /api/v1/games/iidx/SP/targets/goals/some_goal_id/evaluate-for?userID=zkldi
## Search quests for this GPT.
`GET /api/v1/games/:game/:playtype/targets/quests`
`GET /api/v1/games/:gameGroup/:playtype/targets/quests`
!!! note
You might notice that there's no equivalent endpoint for goals.
@@ -284,7 +284,7 @@ You might notice that there's no equivalent endpoint for goals.
## Retrieve information about a specific quest, and who is subscribed to it.
`GET /api/v1/games/:game/:playtype/targets/quests/:questID`
`GET /api/v1/games/:gameGroup/:playtype/targets/quests/:questID`
### Parameters
@@ -304,7 +304,7 @@ N/A
## Evaluate a quest for a user, even if they aren't subscribed to it.
`GET /api/v1/games/:game/:playtype/targets/quests/:questID/evaluate-for`
`GET /api/v1/games/:gameGroup/:playtype/targets/quests/:questID/evaluate-for`
### Parameters
@@ -337,7 +337,7 @@ N/A
## Search Questlines
`GET /api/v1/games/:game/:playtype/targets/questlines`
`GET /api/v1/games/:gameGroup/:playtype/targets/questlines`
### Parameters
@@ -355,7 +355,7 @@ N/A
## Retrieve a questline with a specific ID.
`GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID`
`GET /api/v1/games/:gameGroup/:playtype/targets/questlines/:questlineID`
### Parameters
+18 -18
View File
@@ -8,7 +8,7 @@ programmatically, you should see [Game Endpoints](./games.md).
## Retrieve Game:Playtype Configuration.
`GET /api/v1/games/:game/:playtype`
`GET /api/v1/games/:gameGroup/:playtype`
### Parameters
@@ -54,7 +54,7 @@ GET /api/v1/games/iidx/SP
## Retrieve the player leaderboard.
`GET /api/v1/games/:game/:playtype/leaderboard`
`GET /api/v1/games/:gameGroup/:playtype/leaderboard`
### Parameters
@@ -104,7 +104,7 @@ GET /api/v1/games/iidx/SP/leaderboard
## Retrieve a song and its charts.
`GET /api/v1/games/:game/:playtype/songs/:songID`
`GET /api/v1/games/:gameGroup/:playtype/songs/:songID`
### Parameters
@@ -154,7 +154,7 @@ GET /api/v1/games/iidx/SP/songs/1
## Get popular charts for this game + playtype.
`GET /api/v1/games/:game/:playtype/charts`
`GET /api/v1/games/:gameGroup/:playtype/charts`
### Parameters
@@ -227,7 +227,7 @@ GET /api/v1/games/iidx/SP/charts?search=AA
## Retrieve a chart at a specific ID.
`GET /api/v1/games/:game/:playtype/charts/:chartID`
`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID`
### Parameters
@@ -270,7 +270,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart_id
## Retrieve playcount for this chart.
`GET /api/v1/games/:game/:playtype/charts/:chartID/playcount`
`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/playcount`
### Parameters
@@ -290,7 +290,7 @@ Self-explanatory.
## Retrieve leaderboards for this chart.
`GET /api/v1/games/:game/:playtype/charts/:chartID/pbs`
`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs`
### Parameters
@@ -342,7 +342,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart/pbs
## Search for a user's PB on this chart.
`GET /api/v1/games/:game/:playtype/charts/:chartID/pbs/search`
`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs/search`
### Parameters
@@ -352,7 +352,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart/pbs
### Response
Same as `/api/v1/games/:game/:playtype/charts/:chartID/pbs`.
Same as `/api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs`.
### Example
@@ -362,7 +362,7 @@ See Above.
## Search a GPT's folders.
`GET /api/v1/games/:game/:playtype/folders`
`GET /api/v1/games/:gameGroup/:playtype/folders`
### Parameters
@@ -399,7 +399,7 @@ GET /api/v1/games/iidx/SP/folders?search=12
## Retrieve information on a specific folderID
`GET /api/v1/games/:game/:playtype/folders/:folderID`
`GET /api/v1/games/:gameGroup/:playtype/folders/:folderID`
### Parameters
@@ -449,7 +449,7 @@ GET /api/v1/games/iidx/SP/folders/some_folder_id
## Return all the tables for this game
`GET /api/v1/games/:game/:playtype/tables`
`GET /api/v1/games/:gameGroup/:playtype/tables`
!!! note
Unlike the folders endpoint, this one doesn't have a search parameter. This is because we expect
@@ -496,7 +496,7 @@ GET /api/v1/games/bms/7K/tables
## Retrieve folder documents for a specific table.
`GET /api/v1/games/:game/:playtype/tables/:tableID`
`GET /api/v1/games/:gameGroup/:playtype/tables/:tableID`
### Parameters
@@ -536,7 +536,7 @@ GET /api/v1/games/bms/7K/tableID/insane
## Retrieve the PB leaderboard for this Game.
`GET /api/v1/games/:game/:playtype/pb-leaderboard`
`GET /api/v1/games/:gameGroup/:playtype/pb-leaderboard`
### Parameters
@@ -558,7 +558,7 @@ GET /api/v1/games/bms/7K/tableID/insane
## Get the distribution of players for a provided class.
`GET /api/v1/games/:game/:playtype/player-distribution`
`GET /api/v1/games/:gameGroup/:playtype/player-distribution`
### Parameters
@@ -596,7 +596,7 @@ GET /api/v1/games/bms/7K/player-distribution?class=stslDan
```
!!! info
You can find the humanised conversions for these classes in the gptConfig for this GPT.
You can find the humanised conversions for these classes in the gameConfig for this GPT.
See [tachi/common](https://github.com/zkldi/Tachi/tree/main/common) for more information.
@@ -604,7 +604,7 @@ You can find the humanised conversions for these classes in the gptConfig for th
## Retrieve recent class updates from all users on this game.
`GET /api/v1/games/:game/:playtype/player-distribution`
`GET /api/v1/games/:gameGroup/:playtype/player-distribution`
### Parameters
@@ -623,7 +623,7 @@ You can find the humanised conversions for these classes in the gptConfig for th
## Retrieve the most recent highlighted scores for this GPT.
`GET /api/v1/games/:game/:playtype/scores/highlighted`
`GET /api/v1/games/:gameGroup/:playtype/scores/highlighted`
### Parameters
+3 -3
View File
@@ -6,7 +6,7 @@ These endpoints are related to the Statistic Showcase feature.
## Evaluate this users set stats
`GET /api/v1/users/:userID/games/:game/:playtype/showcase`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/showcase`
### Parameters
@@ -53,7 +53,7 @@ GET /api/v1/users/1/games/iidx/SP/showcase
## Replace a user's stat showcase.
`PATCH /api/v1/users/:userID/games/:game/:playtype/showcase`
`PATCH /api/v1/users/:userID/games/:gameGroup/:playtype/showcase`
### Permissions
@@ -101,7 +101,7 @@ PATCH /api/v1/users/1/games/iidx/SP/showcase
## Evaluate a custom stat on this user.
`GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/showcase/custom`
### Parameters
+6 -6
View File
@@ -10,7 +10,7 @@ For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endp
## Retrieve a user's recently achieved targets.
`GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/recently-achieved`
### Parameters
@@ -30,7 +30,7 @@ None.
## Retrieve a user's recently raised targets.
`GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/recently-raised`
!!! info
Recently raised means that the user recently increased their `progress` on the target. It explicitly excludes achieved goals -- only things that they are getting closer to.
@@ -53,7 +53,7 @@ None.
## Retrieve this user's quest subscriptions.
`GET /api/v1/users/:userID/games/:game/:playtype/targets/quests`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests`
### Parameters
@@ -70,7 +70,7 @@ None.
## Get a user's progress on a specific quest they are subscribed to.
`GET /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID`
!!! info
If you're looking to evaluate a quest against a user, without knowing whether they're
@@ -106,7 +106,7 @@ None.
## Subscribe to a quest.
`PUT /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID`
`PUT /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID`
### Permissions
@@ -130,7 +130,7 @@ None. (All relevant info is in the URL.)
## Unsubscribe from a quest.
`DELETE /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID`
`DELETE /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID`
### Permissions
+17 -17
View File
@@ -8,7 +8,7 @@ This scenario appears frequently, and is typically shortened to UGPT.
## Get information about a user's plays on a game + playtype.
`GET /api/v1/users/:userID/games/:game/:playtype`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype`
### Parameters
@@ -71,7 +71,7 @@ GET /api/v1/users/zkldi/games/iidx/SP
## Search a user's personal bests.
`GET /api/v1/users/:userID/games/:game/:playtype/pbs`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs`
### Parameters
@@ -125,7 +125,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs?search=Verfl
## Get a user's best 100 personal bests.
`GET /api/v1/users/:userID/games/:game/:playtype/pbs/best`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/best`
This returns the users' best 100 personal bests according
to the [Default Rating Algorithm](../../codebase/implementation-details/game-configuration) for this game.
@@ -204,7 +204,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs/best?alg=BPI
## Returns all of a users personal bests.
`GET /api/v1/users/:userID/games/:game/:playtype/pbs/all`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/all`
### Parameters
@@ -258,7 +258,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs/all
## Get A User's PB for a given chart.
`GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/:chartID`
### Parameters
@@ -302,7 +302,7 @@ GET /api/v1/users/1/games/iidx/SP/pbs/some_chart_id
## Search a user's individual scores.
`GET /api/v1/users/:userID/games/:game/:playtype/scores`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/scores`
### Parameters
@@ -365,7 +365,7 @@ GET /api/v1/users/zkldimes/iidx/SP/scores?search=Verfl
## Get a user's most recent 100 scores.
`GET /api/v1/users/:userID/games/:game/:playtype/scores/recent`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/scores/recent`
### Parameters
@@ -419,7 +419,7 @@ GET /api/v1/users/zkldimes/iidx/SP/scores/recent
## Search a user's sessions.
`GET /api/v1/users/:userID/games/:game/:playtype/sessions`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions`
Searches the names of sessions from a given user. This
does not search session descriptions, nor does it search
@@ -461,7 +461,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions?search=epic%20session
## Get a user's best 100 sessions.
`GET /api/v1/users/:userID/games/:game/:playtype/sessions/best`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/best`
Retrieves a user's best 100 sessions according to the
game + playtypes default algorithm. The algorithm can
@@ -520,7 +520,7 @@ The default rating algorithm for IIDX:SP is `ktRating`.
## Get a user's most recent 100 sessions.
`GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/recent`
Retrieves a user's most recent 100 sessions for this game.
@@ -540,7 +540,7 @@ None.
## Get a user's most recent session.
`GET /api/v1/users/:userID/games/:game/:playtype/sessions/last`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/last`
!!! info
This endpoint will return 404 if the user has never had a
@@ -576,7 +576,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions/last
## Get a user's most recent 100 highlighted sessions.
`GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/highlighted`
Retrieves a user's most recent 100 highlighted sessions for this game.
@@ -622,7 +622,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions/highlighted
## Get a user's most played charts.
`GET /api/v1/users/:userID/games/:game/:playtype/most-played`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/most-played`
### Parameters
@@ -689,7 +689,7 @@ GET /api/v1/users/zkldimes/iidx/SP/most-played
## Retrieve a leaderboard around a user.
`GET /api/v1/users/:userID/games/:game/:playtype/leaderboard-adjacent`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/leaderboard-adjacent`
### Parameters
@@ -768,7 +768,7 @@ GET /api/v1/users/zkldimes/iidx/SP/leaderboard-adjacent
## Retrieve this user's GPT stat history.
`GET /api/v1/users/:userID/games/:game/:playtype/history`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/history`
Every day, a user's game stats are snapshotted and saved. This returns the recent ones.
@@ -815,7 +815,7 @@ GET /api/v1/users/1/games/iidx/SP/history
## Retrieve this user's GPT settings.
`GET /api/v1/users/:userID/games/:game/:playtype/settings`
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/settings`
!!! warning
Unlike most other applications, your settings are completely public. GPT Settings only concern
@@ -854,7 +854,7 @@ GET /api/v1/users/1/games/iidx/SP/settings
## Modify your UGPT settings.
`PATCH /api/v1/users/:userID/games/:game/:playtype/settings`
`PATCH /api/v1/users/:userID/games/:gameGroup/:playtype/settings`
!!! note
Although `stats` are part of your settings, they are not modifiable under these endpoints,
+11 -14
View File
@@ -228,30 +228,27 @@ How many unique players have to have played a chart on the beatoraja IR for it t
- Type: String
Where *this* server is hosted. This is used to
provide callback URLs inside emails. You may stub
it out if emails are unsupported.
provide callback URLs inside emails.
### EMAIL_CONFIG
- Type: EMAIL_CONFIG | undefined.
- Type: EMAIL_CONFIG (required)
Configures how emails will be sent by Tachi.
If not present, email calls will become no-ops, and
certain features (such as resetting passwords)
will be disabled.
SMTP is always configured. Set:
`FROM` determines the email `From` header, and optionally
`SENDMAIL_BIN` can override the location of the `sendmail`
binary. Defaults to `/usr/bin/sendmail`, but some distros
may have it in `sbin`.
- `TACHI_EMAIL_FROM``From` header (must match a verified sender when using Postmark).
- `TACHI_EMAIL_AUTH_POSTMARK``true` or `false`. When `true`, uses Postmarks SMTP
endpoint; set `TACHI_EMAIL_AUTH_USER` and `TACHI_EMAIL_AUTH_PASS` to your server API token
(both are the token for Postmark SMTP).
- When Postmark is `false`: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE`
(`true` / `false`), and optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS`
for servers that require auth (local Mailpit typically needs no auth).
`TRANSPORT_OPS` Passes a set of options to the email transport. For more
information, see the nodemailer documentation for SMTPTransport.Options.
`TRANSPORT_OPS` is derived from these variables and passed to Nodemailer.
```ts
interface EMAIL_CONFIG {
FROM: string;
SENDMAIL_BIN?: string
TRANSPORT_OPS: any;
}
```
-15
View File
@@ -1,18 +1,3 @@
# Tachi landing page
This is the landing page for [https://tachi.ac](https://tachi.ac).
## Contributing
Basic landing page with SCSS compile step.
```shell
pnpm install
pnpm run watch # Refresh dist/ on src/ change
pnpm run compile # Compile site to dist/
# then open `dist/index.html` in your browser.
```
Tested with Node.js `20.10.0` & pnpm `8.12.0` - Please ensure you are using these versions.
+1
View File
@@ -129,6 +129,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-perfectionist": "^3.9.1",
"eslint-plugin-react": "^7.37.4",
+4 -3
View File
@@ -95,9 +95,10 @@ export function MakeActionGuts({
const inputParsed = inputSchema.safeParse(input);
if (!inputParsed.success) {
throw new Error(
`Action ${kind} received invalid input: ${inputParsed.error.message}`,
);
const msg = inputParsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
throw new ExpectedErr(400, msg || `Action ${kind} received invalid input.`);
}
retval = await actionBodyFn(taker, input);
+1 -3
View File
@@ -7,13 +7,11 @@
"license": "MIT",
"scripts": {
"build": "tsgo -b",
"start": "pnpm build && node -r ts-node/register -r tsconfig-paths/register js/main.js",
"start-no-build": "node -r ts-node/register -r tsconfig-paths/register js/main.js",
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit",
"test": "vitest",
"bench": "vitest bench --passWithNoTests"
"bench": "vitest bench"
},
"devDependencies": {
"@types/express": "catalog:",
+1 -1
View File
@@ -21,7 +21,7 @@ export const ActionSignatures = {
session_count: z.number().int(),
error_count: z.number().int(),
user_id: z.number().int(),
game: z.string(),
games: z.string().array(),
}),
},
} satisfies Record<string, ActionSignature>;
+9 -10
View File
@@ -1,4 +1,4 @@
import type { MONGO_ImportDocument } from "tachi-common";
import type { ImportDocument } from "tachi-common";
import db from "#services/pg/db";
import { createTestAccount } from "#test-utils/db";
@@ -13,11 +13,12 @@ vi.mock("../utils/api-requests");
const { PerformScoreImport } = await import("../utils/api-requests");
function makeImportDoc(overrides: Partial<MONGO_ImportDocument> = {}): MONGO_ImportDocument {
function makeImportDoc(overrides: Partial<ImportDocument> = {}): ImportDocument {
return {
importID: "import-abc-123",
userID: 1,
game: "iidx-sp",
gameGroup: "iidx",
games: ["iidx-sp"],
scoreIDs: ["s1", "s2"],
createdSessions: [{ sessionID: "sess1", type: "Created" }],
errors: [],
@@ -25,7 +26,7 @@ function makeImportDoc(overrides: Partial<MONGO_ImportDocument> = {}): MONGO_Imp
goalProgress: [],
questProgress: [],
...overrides,
} as unknown as MONGO_ImportDocument;
} as unknown as ImportDocument;
}
describe("ACTION_Sync", () => {
@@ -50,7 +51,7 @@ describe("ACTION_Sync", () => {
session_count: 1,
error_count: 0,
user_id: 1,
game: "iidx-sp",
games: ["iidx-sp"],
});
});
@@ -82,7 +83,7 @@ describe("ACTION_Sync", () => {
it("throws ExpectedErr when PerformScoreImport returns an error string", async () => {
vi.mocked(PerformScoreImport).mockResolvedValue(
"Your API key has expired." as unknown as MONGO_ImportDocument,
"Your API key has expired." as unknown as ImportDocument,
);
await expect(ACTION_Sync(taker, syncInput)).rejects.toMatchObject({
@@ -93,7 +94,7 @@ describe("ACTION_Sync", () => {
it("the ExpectedErr thrown on failure is an ExpectedErr instance", async () => {
vi.mocked(PerformScoreImport).mockResolvedValue(
"Import failed." as unknown as MONGO_ImportDocument,
"Import failed." as unknown as ImportDocument,
);
await expect(ACTION_Sync(taker, syncInput)).rejects.toBeInstanceOf(ExpectedErr);
@@ -120,9 +121,7 @@ describe("ACTION_Sync", () => {
});
it("writes a BAD action row when PerformScoreImport returns an error string", async () => {
vi.mocked(PerformScoreImport).mockResolvedValue(
"Bad import." as unknown as MONGO_ImportDocument,
);
vi.mocked(PerformScoreImport).mockResolvedValue("Bad import." as unknown as ImportDocument);
await expect(ACTION_Sync(taker, syncInput)).rejects.toBeInstanceOf(ExpectedErr);
+1 -1
View File
@@ -20,7 +20,7 @@ export const ACTION_Sync = MakeAction(
session_count: importDoc.createdSessions.length,
error_count: importDoc.errors.length,
user_id: importDoc.userID,
game: importDoc.game,
games: importDoc.games,
};
},
);
+3 -3
View File
@@ -1,4 +1,4 @@
import type { GameGroup, integer, TachiServerCoreConfig } from "tachi-common";
import type { GameGroup, integer, TachiServerCoreConfig, V3Game } from "tachi-common";
import { log } from "bliss/log";
import { config } from "dotenv";
@@ -49,7 +49,7 @@ export interface ProcessEnvironment {
OAUTH_CLIENT_SECRET: string;
DISCORD_TOKEN: string;
DISCORD_SERVER_ID: string;
DISCORD_GAME_CHANNELS: Partial<Record<GameGroup, string>>;
DISCORD_GAME_CHANNELS: Partial<Record<V3Game, string>>;
DISCORD_ADMIN_USERS: string[];
DISCORD_APPROVED_ROLE: string | undefined;
DISCORD_LIMBO_CHANNEL: string | undefined;
@@ -135,7 +135,7 @@ export const ServerConfig = await GetServerConfig();
// General warnings for config misuse.
// This warns people if their parent server supports games that they aren't acknowledging.
for (const game of ServerConfig.GAMES) {
for (const game of ServerConfig.GAME_GROUPS) {
if (!Object.prototype.hasOwnProperty.call(Env.DISCORD_GAME_CHANNELS, game)) {
log.warn(
`${ServerConfig.NAME} declares support for ${game}, but no channel is mapped to it. Set DISCORD_GAME_CHANNELS to include it.`,
+3 -3
View File
@@ -1,4 +1,4 @@
import type { MONGO_APITokenDocument, MONGO_UserDocument, WebhookEvents } from "tachi-common";
import type { APITokenDocument, UserDocument, WebhookEvents } from "tachi-common";
import { log } from "#utils/log";
import { HandleQuestAchievedV1 } from "#webhook-handlers/quest-achieved";
@@ -58,7 +58,7 @@ app.get("/oauth/callback", async (req, res) => {
return res.status(400).send("Bad Request.");
}
const tokenRes = await TachiServerV1Request<MONGO_APITokenDocument>(
const tokenRes = await TachiServerV1Request<APITokenDocument>(
RequestTypes.POST,
"/oauth/token",
null,
@@ -84,7 +84,7 @@ app.get("/oauth/callback", async (req, res) => {
const discordID = req.query.context;
const apiToken = tokenRes.body.token!;
const whoamiRes = await TachiServerV1Get<MONGO_UserDocument>("/users/me", apiToken);
const whoamiRes = await TachiServerV1Get<UserDocument>("/users/me", apiToken);
if (!whoamiRes.success) {
log.error({ discordID }, "Failed to request user with token we just got?");
@@ -66,7 +66,7 @@ const command: SlashCommand = {
.addField("Errors", result.error_count.toString(), true)
.addField(
"Your Profile",
`${Env.TACHI_SERVER_LOCATION}/u/${result.user_id}/games/${result.game}`,
`${Env.TACHI_SERVER_LOCATION}/u/${result.user_id}/games/${result.games[0]}`,
);
} catch (e) {
if (ExpectedErr.is(e)) {
+28 -37
View File
@@ -1,19 +1,18 @@
import type { CommandInteraction } from "discord.js";
import type {
GameGroup,
integer,
MONGO_ChartDocument,
MONGO_GoalDocument,
MONGO_ImportDocument,
MONGO_PBScoreDocument,
MONGO_QuestDocument,
MONGO_SongDocument,
MONGO_UserDocument,
Playtype,
} from "tachi-common";
import { Env } from "#config";
import { log } from "#utils/log";
import {
type ChartDocument,
type GoalDocument,
type ImportDocument,
type integer,
type PBScoreDocument,
type QuestDocument,
type SongDocument,
type UserDocument,
type V3Game,
} from "tachi-common";
import type { ImportDeferred, ImportPollStatus, UGPTStats } from "./return-types";
@@ -21,7 +20,7 @@ import { RequestTypes, TachiServerV1Get, TachiServerV1Request } from "./fetch-ta
import { Sleep } from "./misc";
export async function GetUserInfo(userID: string | integer) {
const res = await TachiServerV1Get<MONGO_UserDocument>(`/users/${userID}`, null);
const res = await TachiServerV1Get<UserDocument>(`/users/${userID}`, null);
if (!res.success) {
throw new Error(`Failed to fetch user with userID ${userID}.`);
@@ -30,22 +29,19 @@ export async function GetUserInfo(userID: string | integer) {
return res.body;
}
export async function GetUGPTStats(userID: string | integer, game: GameGroup, playtype: Playtype) {
const res = await TachiServerV1Get<UGPTStats>(
`/users/${userID}/games/${game}/${playtype}`,
null,
);
export async function GetUGPTStats(userID: string | integer, game: V3Game) {
const res = await TachiServerV1Get<UGPTStats>(`/users/${userID}/games/${game}`, null);
if (!res.success) {
throw new Error(`Failed to fetch UGPT stats for userID ${userID}, ${game}, ${playtype}.`);
throw new Error(`Failed to fetch UGPT stats for userID ${userID}, ${game}.`);
}
return res.body;
}
export async function GetGoalWithID(goalID: string, game: GameGroup, playtype: Playtype) {
const res = await TachiServerV1Get<{ goal: MONGO_GoalDocument }>(
`/games/${game}/${playtype}/targets/goals/${goalID}`,
export async function GetGoalWithID(goalID: string, game: V3Game) {
const res = await TachiServerV1Get<{ goal: GoalDocument }>(
`/games/${game}/targets/goals/${goalID}`,
null,
);
@@ -56,9 +52,9 @@ export async function GetGoalWithID(goalID: string, game: GameGroup, playtype: P
return res.body.goal;
}
export async function GetQuestWithID(questID: string, game: GameGroup, playtype: Playtype) {
const res = await TachiServerV1Get<{ quest: MONGO_QuestDocument }>(
`/games/${game}/${playtype}/targets/quests/${questID}`,
export async function GetQuestWithID(questID: string, game: V3Game) {
const res = await TachiServerV1Get<{ quest: QuestDocument }>(
`/games/${game}/targets/quests/${questID}`,
null,
);
@@ -69,14 +65,9 @@ export async function GetQuestWithID(questID: string, game: GameGroup, playtype:
return res.body.quest;
}
export async function GetChartInfoForUser(
userID: string | integer,
chartID: string,
game: GameGroup,
playtype: Playtype,
) {
const res = await TachiServerV1Get<{ chart: MONGO_ChartDocument; song: MONGO_SongDocument }>(
`/games/${game}/${playtype}/charts/${chartID}`,
export async function GetChartInfoForUser(userID: string | integer, chartID: string, game: V3Game) {
const res = await TachiServerV1Get<{ chart: ChartDocument; song: SongDocument }>(
`/games/${game}/charts/${chartID}`,
null,
);
@@ -84,8 +75,8 @@ export async function GetChartInfoForUser(
throw new Error(`Failed to fetch song/chart with chartID ${chartID}.`);
}
const pbRes = await TachiServerV1Get<{ chart: MONGO_ChartDocument; pb: MONGO_PBScoreDocument }>(
`/users/${userID}/games/${game}/${playtype}/pbs/${chartID}`,
const pbRes = await TachiServerV1Get<{ chart: ChartDocument; pb: PBScoreDocument }>(
`/users/${userID}/games/${game}/pbs/${chartID}`,
null,
);
@@ -104,7 +95,7 @@ export async function PerformScoreImport(
body: Record<string, unknown>,
interaction?: CommandInteraction,
) {
const initRes = await TachiServerV1Request<ImportDeferred | MONGO_ImportDocument>(
const initRes = await TachiServerV1Request<ImportDeferred | ImportDocument>(
RequestTypes.POST,
url,
authToken,
@@ -122,7 +113,7 @@ export async function PerformScoreImport(
// this server does not defer imports to a scorequeue
if (initRes.statusCode === 200) {
const result = initRes.body as MONGO_ImportDocument;
const result = initRes.body as ImportDocument;
return result;
} else if (initRes.statusCode === 202) {
+10 -8
View File
@@ -1,23 +1,21 @@
import type { CommandInteraction } from "discord.js";
import type { PrivDiscordUserMap } from "tachi-db";
import { type GameGroup, type MONGO_UserDocument, type Playtype } from "tachi-common";
import { IsValidGame, type UserDocument, type V3Game } from "tachi-common";
import type { Emittable } from "../slash-commands/types";
import { GetUserInfo } from "./api-requests";
import { ParseGPT } from "./misc";
/**
* Utility parser for getting the game, playtype and requesting user, since this is
* Utility parser for getting the game and requesting user, since this is
* a common pattern in the bot.
*/
export async function GetGPTAndUser(
export async function GetGameAndUser(
interaction: CommandInteraction,
requestingUser: PrivDiscordUserMap,
): Promise<
| { content: { game: GameGroup; playtype: Playtype; userDoc: MONGO_UserDocument }; error: null }
| { error: Emittable }
{ content: { game: V3Game; userDoc: UserDocument }; error: null } | { error: Emittable }
> {
const userID = interaction.options.getString("other_user") ?? requestingUser.user_id.toString();
@@ -33,7 +31,11 @@ export async function GetGPTAndUser(
return { error: `This user does not exist.` };
}
const { game, playtype } = ParseGPT(interaction.options.getString("game", true));
const game = interaction.options.getString("game", true);
return { error: null, content: { userDoc, game, playtype } };
if (!IsValidGame(game)) {
return { error: `Invalid game: ${game}.` };
}
return { error: null, content: { userDoc, game } };
}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { integer, MONGO_ImportDocument, MONGO_UserDocument } from "tachi-common";
import type { ImportDocument, integer, UserDocument } from "tachi-common";
import { MessageEmbed } from "discord.js";
@@ -18,7 +18,7 @@ export function CreateEmbed(userID?: integer) {
return embed;
}
export function CreateImportEmbed(importDoc: MONGO_ImportDocument) {
export function CreateImportEmbed(importDoc: ImportDocument) {
return CreateEmbed()
.setTitle(
`Imported ${importDoc.scoreIDs.length} ${Pluralise(
@@ -30,11 +30,11 @@ export function CreateImportEmbed(importDoc: MONGO_ImportDocument) {
.addField("Errors", importDoc.errors.length.toString(), true)
.addField(
"Your Profile",
`${Env.TACHI_SERVER_LOCATION}/u/${importDoc.userID}/games/${importDoc.game}`,
`${Env.TACHI_SERVER_LOCATION}/u/${importDoc.userID}/games/${importDoc.games[0]}`,
);
}
export function CreateUserEmbed(userDoc: MONGO_UserDocument) {
export function CreateUserEmbed(userDoc: UserDocument) {
return CreateEmbed()
.setTitle(`${userDoc.username} (ID: ${userDoc.id})`)
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`))
+10 -43
View File
@@ -4,15 +4,11 @@ import type { ClassInfo } from "tachi-common/types/game-config-utils";
import _ from "lodash";
import { DateTime } from "luxon";
import {
type ChartDocument,
type Classes,
type GameGroup,
type GameGroupConfig,
GetGameGroupConfig,
GetGamePTConfig,
type GPTString,
GetGameConfig,
type integer,
type MONGO_ChartDocument,
type Playtype,
type V3Game,
} from "tachi-common";
import { Env } from "../config";
@@ -40,22 +36,6 @@ export function Entries<K extends string, V>(rec: Partial<Record<K, V>>): Array<
return Object.entries(rec) as Array<[K, V]>;
}
export function ParseGPT(str: string) {
const spl = str.split(":");
const game = spl[0] as GameGroup;
const playtype = spl[1] as Playtype;
// this is an interesting way of game checking noah
const gameConfig = GetGameGroupConfig(game) as GameGroupConfig | undefined;
if (!gameConfig?.playtypes.includes(playtype)) {
throw new Error(`Invalid GPT Combination '${str}'.`);
}
return { game, playtype };
}
export function UppercaseFirst(str: string) {
if (!str[0]) {
return "";
@@ -72,24 +52,17 @@ export function FormatDate(ms: number) {
return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_HUGE);
}
export function FormatClass(
game: GameGroup,
playtype: Playtype,
classSet: Classes[GPTString],
classValue: string,
) {
const gptConfig = GetGamePTConfig(game, playtype);
export function FormatClass(game: V3Game, classSet: Classes[V3Game], classValue: string) {
const gameConfig = GetGameConfig(game);
// @ts-expect-error hacky access
const classInfo: ClassInfo = gptConfig.classes[classSet]?.values?.find?.(
const classInfo: ClassInfo = gameConfig.classes[classSet]?.values?.find?.(
(k) => k.id === classValue,
);
if (!classInfo) {
throw new Error(
`Couldn't find a class at index ${classValue} for ${game} ${playtype} ${classSet}?`,
);
throw new Error(`Couldn't find a class at index ${classValue} for ${game} ${classSet}?`);
}
return classInfo.display as string;
@@ -98,7 +71,7 @@ export function FormatClass(
/**
* Given a game, return the discord channel it's associated with.
*/
export function GetGameChannel(client: Client, game: GameGroup) {
export function GetGameChannel(client: Client, game: V3Game) {
const gameChannelID = Env.DISCORD_GAME_CHANNELS[game];
if (!gameChannelID) {
@@ -145,14 +118,8 @@ export function GetLimboChannel(client: Client) {
/**
* Given a chart and a game, return a link to the site for that chart.
*/
export function CreateChartLink(chart: MONGO_ChartDocument, game: GameGroup) {
if (chart.isPrimary) {
return `${Env.TACHI_SERVER_LOCATION}/games/${game}/${chart.playtype}/songs/${
chart.songID
}/${encodeURIComponent(chart.difficulty)}`;
}
return `${Env.TACHI_SERVER_LOCATION}/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`;
export function CreateChartLink(chart: ChartDocument) {
return `${Env.TACHI_SERVER_LOCATION}/games/${chart.game}/charts/${chart.chartID}`;
}
export function ConvertInputIntoGenerousRegex(input: string) {
+9 -9
View File
@@ -1,25 +1,25 @@
import type { SlashCommandStringOption } from "@discordjs/builders";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GetGameGroupConfig } from "tachi-common";
import { ServerConfig } from "../config";
/**
* Game Playtype options. Frequently used by things that might need
* Game options. Frequently used by things that might need
* game specific listening.
*/
const GPTChoices: Array<[string, string]> = [];
const GameChoices: Array<[string, string]> = [];
for (const game of ServerConfig.GAMES) {
const gameConfig = GetGameGroupConfig(game);
for (const gameGroup of ServerConfig.GAME_GROUPS) {
const gameGroupConfig = GetGameGroupConfig(gameGroup);
for (const playtype of gameConfig.playtypes) {
GPTChoices.push([FormatGameGroup(game, playtype), `${game}:${playtype}`]);
for (const game of gameGroupConfig.games) {
GameChoices.push([FormatGame(game), game]);
}
}
export const GPTOptions = (str: SlashCommandStringOption) =>
str.setName("game").setDescription("Pick the relevant game.").addChoices(GPTChoices);
export const GameOptions = (str: SlashCommandStringOption) =>
str.setName("game").setDescription("Pick the relevant game.").addChoices(GameChoices);
export function MakeRequired(fn: (str: SlashCommandStringOption) => SlashCommandStringOption) {
return (str: SlashCommandStringOption) => fn(str).setRequired(true);
+14 -14
View File
@@ -1,13 +1,13 @@
import type {
APIPermissions,
GPTString,
ChartDocument,
ImportDocument,
integer,
MONGO_ChartDocument,
MONGO_ImportDocument,
MONGO_ScoreDocument,
MONGO_SongDocument,
MONGO_UserGameStats,
ProfileRatingAlgorithms,
ScoreDocument,
SongDocument,
UserGameStats,
V3Game,
} from "tachi-common";
export interface ServerStatus {
@@ -25,7 +25,7 @@ export interface ImportDeferred {
export type ImportPollStatus =
| {
import: MONGO_ImportDocument;
import: ImportDocument;
importStatus: "completed";
}
| {
@@ -36,15 +36,15 @@ export type ImportPollStatus =
};
};
export interface UGPTStats<GPT extends GPTString = GPTString> {
gameStats: MONGO_UserGameStats;
firstScore: MONGO_ScoreDocument;
mostRecentScore: MONGO_ScoreDocument;
export interface UGPTStats<TGame extends V3Game = V3Game> {
gameStats: UserGameStats;
firstScore: ScoreDocument;
mostRecentScore: ScoreDocument;
totalScores: integer;
rankingData: Record<ProfileRatingAlgorithms[GPT], { outOf: integer; ranking: integer }>;
rankingData: Record<ProfileRatingAlgorithms[TGame], { outOf: integer; ranking: integer }>;
}
export interface ChartQueryReturns {
charts: Array<{ __playcount: integer } & MONGO_ChartDocument>;
songs: Array<MONGO_SongDocument>;
charts: Array<{ __playcount: integer } & ChartDocument>;
songs: Array<SongDocument>;
}
@@ -1,12 +1,10 @@
import { log } from "#utils/log";
import {
type Classes,
FormatGameGroup,
type GameGroup,
GetGamePTConfig,
type GPTString,
FormatGame,
GetGameConfig,
type integer,
type Playtype,
type V3Game,
type WebhookEventClassUpdateV1,
} from "tachi-common";
@@ -20,7 +18,7 @@ import { FormatClass, GetGameChannel } from "../utils/misc";
export async function HandleClassUpdateV1(
event: WebhookEventClassUpdateV1["content"],
): Promise<integer> {
const { game, playtype } = event;
const { game } = event;
let channel;
@@ -33,7 +31,7 @@ export async function HandleClassUpdateV1(
return 500;
}
if (!ShouldRenderUpdate(game, playtype, event.set, event.new)) {
if (!ShouldRenderUpdate(game, event.set, event.new)) {
log.info(
`Not rendering class update ${event.set}: ${event.old} -> ${event.new} (not relevant).`,
);
@@ -42,10 +40,10 @@ export async function HandleClassUpdateV1(
const userDoc = await GetUserInfo(event.userID);
const minimumNecessaryScores = GetMinimumScores(game, playtype, event.set);
const minimumNecessaryScores = GetMinimumScores(game, event.set);
if (minimumNecessaryScores !== null) {
const { totalScores } = await GetUGPTStats(userDoc.id, game, playtype);
const { totalScores } = await GetUGPTStats(userDoc.id, game);
// Do not render if the user hasn't hit the score cap.
if (totalScores < minimumNecessaryScores) {
@@ -56,19 +54,15 @@ export async function HandleClassUpdateV1(
}
}
const newClass = FormatClass(game, playtype, event.set, event.new);
const newClass = FormatClass(game, event.set, event.new);
const embed = CreateEmbed()
.setTitle(
`${userDoc.username} just achieved ${newClass} in ${FormatGameGroup(game, playtype)}!`,
)
.setURL(`${Env.TACHI_SERVER_LOCATION}/users/${userDoc.username}/games/${game}/${playtype}`)
.setTitle(`${userDoc.username} just achieved ${newClass} in ${FormatGame(game)}!`)
.setURL(`${Env.TACHI_SERVER_LOCATION}/u/${userDoc.username}/games/${game}`)
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`));
if (event.old !== null) {
embed.setDescription(
`(This was raised from ${FormatClass(game, playtype, event.set, event.old)}.)`,
);
embed.setDescription(`(This was raised from ${FormatClass(game, event.set, event.old)}.)`);
}
await channel.send({ embeds: [embed] });
@@ -79,17 +73,12 @@ export async function HandleClassUpdateV1(
/**
* Returns Whether this class update is notable enough to be rendered or not.
*/
function ShouldRenderUpdate(
game: GameGroup,
playtype: Playtype,
classSet: Classes[GPTString],
classValue: string,
) {
const config = GetGamePTConfig(game, playtype);
function ShouldRenderUpdate(game: V3Game, classSet: Classes[V3Game], classValue: string) {
const config = GetGameConfig(game);
const classSpec = config.classes[classSet];
if (classSpec === undefined) {
log.error(`Invalid class ${classSet} for ${game} ${playtype}`);
log.error(`Invalid class ${classSet} for ${game}`);
return false;
}
@@ -103,28 +92,24 @@ function ShouldRenderUpdate(
const minimumId = ids.indexOf(classSpec.minimumRelevantValue);
if (currentId < 0) {
log.error(`Invalid classValue ${classValue} for ${game} ${playtype}`);
log.error(`Invalid classValue ${classValue} for ${game}`);
return false;
}
if (minimumId < 0) {
log.error(`Invalid minimum classValue ${classValue} for ${game} ${playtype}`);
log.error(`Invalid minimum classValue ${classValue} for ${game}`);
return false;
}
return currentId >= minimumId;
}
function GetMinimumScores(
game: GameGroup,
playtype: Playtype,
classSet: Classes[GPTString],
): integer | null {
const config = GetGamePTConfig(game, playtype);
function GetMinimumScores(game: V3Game, classSet: Classes[V3Game]): integer | null {
const config = GetGameConfig(game);
const classSpec = config.classes[classSet];
if (classSpec === undefined) {
log.error(`Invalid class ${classSet} for ${game} ${playtype}`);
log.error(`Invalid class ${classSet} for ${game}`);
return null;
}
@@ -2,9 +2,9 @@ import { Env } from "#config";
import { PrependTachiUrl } from "#utils/fetch-tachi";
import { log } from "#utils/log";
import {
GetGameGroupConfig,
FormatGame,
type GoalDocument,
type integer,
type MONGO_GoalDocument,
type WebhookEventGoalAchievedV1,
} from "tachi-common";
@@ -32,19 +32,15 @@ export async function HandleGoalAchievedV1(
const userDoc = await GetUserInfo(event.userID);
const goalDocuments = await Promise.all(
event.goals.map((e) => GetGoalWithID(e.goalID, game, e.playtype)),
event.goals.map((e) => GetGoalWithID(e.goalID, e.game)),
);
const goalMap = new Map<string, MONGO_GoalDocument>();
const goalMap = new Map<string, GoalDocument>();
for (const goalDoc of goalDocuments) {
goalMap.set(goalDoc.goalID, goalDoc);
}
const gameConfig = GetGameGroupConfig(game);
const shouldShowPlaytype = gameConfig.playtypes.length !== 1;
const embed = CreateEmbed(userDoc.id)
.setTitle(
`${userDoc.username} just achieved ${event.goals.length} ${Pluralise(
@@ -66,7 +62,7 @@ export async function HandleGoalAchievedV1(
: `${e.old.progressHuman}/${e.old.outOfHuman} -> ${e.new.progressHuman}/${e.new.outOfHuman}`;
return {
name: `${goal.name}${shouldShowPlaytype ? ` (${e.playtype})` : ""}`,
name: `${goal.name} (${FormatGame(e.game)})`,
value,
};
}),
@@ -1,7 +1,7 @@
import { Env } from "#config";
import { PrependTachiUrl } from "#utils/fetch-tachi";
import { log } from "#utils/log";
import { GetGameGroupConfig, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common";
import { FormatGame, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common";
import { client } from "../main";
import { GetQuestWithID, GetUserInfo } from "../utils/api-requests";
@@ -11,7 +11,7 @@ import { GetGameChannel } from "../utils/misc";
export async function HandleQuestAchievedV1(
event: WebhookEventQuestAchievedV1["content"],
): Promise<integer> {
const { game, playtype } = event;
const { game } = event;
let channel;
@@ -26,16 +26,13 @@ export async function HandleQuestAchievedV1(
const userDoc = await GetUserInfo(event.userID);
const quest = await GetQuestWithID(event.questID, game, playtype);
const gameConfig = GetGameGroupConfig(game);
const shouldShowPlaytype = gameConfig.playtypes.length > 1 ? ` (${playtype})` : "";
const quest = await GetQuestWithID(event.questID, game);
const embed = CreateEmbed(userDoc.id)
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`))
.setURL(`${Env.TACHI_SERVER_LOCATION}/u/${userDoc.username}`)
.setTitle(
`${userDoc.username} just completed the ${quest.name}${shouldShowPlaytype} quest!`,
`${userDoc.username} just completed the ${quest.name} (${FormatGame(game)}) quest!`,
);
await channel.send({ embeds: [embed] });
+4
View File
@@ -17,6 +17,8 @@ export default defineConfig({
},
test: {
passWithNoTests: true,
// Static env vars. POSTGRES_URL is set dynamically per-worker in vitest.setup.ts
// so each worker gets its own isolated database.
env: {
@@ -38,7 +40,9 @@ export default defineConfig({
pool: "forks",
coverage: {
enabled: true,
provider: "v8",
reporter: ["text", "html", "clover", "json", "lcov"],
include: ["src/actions/**"],
},
},
+5 -4
View File
@@ -11,6 +11,7 @@
*/
import crypto from "node:crypto";
import { allImportTypes } from "tachi-common/constants/import-types";
const WORKER_ID = crypto.randomUUID().slice(0, 8);
const WORKER_DB_NAME = `tachi_bot_test_${WORKER_ID}`;
@@ -35,10 +36,10 @@ globalThis.fetch = (url: string | Request | URL): Promise<Response> => {
success: true,
body: {
NAME: "Test Tachi",
GAMES: [],
VERSIONS: {},
SUPPORTS_OAUTH2: false,
OAUTH_CLIENT_ID: null,
TYPE: "boku",
SIGNUPS_ENABLED: true,
GAME_GROUPS: ["iidx"],
IMPORT_TYPES: [...allImportTypes],
},
}),
} as unknown as Response);
@@ -8,7 +8,7 @@ import { TachiConfig } from "#lib/config";
import { APIFetchV1 } from "#util/api";
import React, { useContext, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { type MONGO_APITokenDocument, type MONGO_TachiAPIClientDocument } from "tachi-common";
import { type APITokenDocument, type TachiAPIClientDocument } from "tachi-common";
export default function ClientFileFlowPage() {
const { clientID } = useParams<{ clientID: string }>();
@@ -17,7 +17,7 @@ export default function ClientFileFlowPage() {
const { user } = useContext(UserContext);
const { data, error } = useApiQuery<Omit<MONGO_TachiAPIClientDocument, "clientSecret">>(
const { data, error } = useApiQuery<Omit<TachiAPIClientDocument, "clientSecret">>(
`/clients/${clientID}`,
);
@@ -60,7 +60,7 @@ export default function ClientFileFlowPage() {
<button
className="btn btn-success"
onClick={async () => {
const res = await APIFetchV1<MONGO_APITokenDocument>(
const res = await APIFetchV1<APITokenDocument>(
`/users/${user?.id}/api-tokens/create`,
{
method: "POST",
@@ -10,7 +10,7 @@ import { Alert, Button, Form } from "react-bootstrap";
import ReCAPTCHA from "react-google-recaptcha";
import toast from "react-hot-toast";
import { Link, useHistory } from "react-router-dom";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export default function LoginPage() {
useSetSubheader("Login");
@@ -83,7 +83,7 @@ export default function LoginPage() {
return;
}
const userRJ = await APIFetchV1<MONGO_UserDocument>("/users/me");
const userRJ = await APIFetchV1<UserDocument>("/users/me");
if (userRJ.statusCode === 403) {
setErr("You are banned.");
@@ -8,7 +8,7 @@ import useQueryString from "#components/util/useQueryString";
import { APIFetchV1 } from "#util/api";
import React from "react";
import { Link } from "react-router-dom";
import { type integer, type MONGO_TachiAPIClientDocument } from "tachi-common";
import { type integer, type TachiAPIClientDocument } from "tachi-common";
export default function OAuthRequestAuthPage() {
const params = useQueryString();
@@ -32,7 +32,7 @@ export default function OAuthRequestAuthPage() {
}
function OAuthRequestAuthLoader({ clientID }: { clientID: string }) {
const { error, data } = useApiQuery<Omit<MONGO_TachiAPIClientDocument, "clientSecret">>(
const { error, data } = useApiQuery<Omit<TachiAPIClientDocument, "clientSecret">>(
`/clients/${clientID}`,
);
@@ -61,7 +61,7 @@ function OAuthRequestAuthLoader({ clientID }: { clientID: string }) {
function OAuthRequestAuthMain({
client,
}: {
client: Omit<MONGO_TachiAPIClientDocument, "clientSecret">;
client: Omit<TachiAPIClientDocument, "clientSecret">;
}) {
const params = useQueryString();
@@ -13,7 +13,7 @@ import { Alert, Button, Form } from "react-bootstrap";
import ReCAPTCHA from "react-google-recaptcha";
import toast from "react-hot-toast";
import { Link, useHistory } from "react-router-dom";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
// seconds it takes for a user to actually read the rules.
const RULES_READ_TIME = Number(import.meta.env.VITE_RULES_READ_TIME) || 30;
@@ -62,7 +62,7 @@ export default function RegisterPage() {
return;
}
const rj = await APIFetchV1<MONGO_UserDocument>(
const rj = await APIFetchV1<UserDocument>(
"/auth/register",
{
method: "POST",
@@ -54,22 +54,11 @@ function resultVariant(result: string): "danger" | "secondary" | "success" | "wa
return "secondary";
}
function JsonCell({ label, value }: { label: string; value: unknown }) {
if (value === null || value === undefined) {
return <span className="text-body-secondary"></span>;
function formatJson(value: unknown): string {
if (value === undefined) {
return "";
}
const json = JSON.stringify(value, null, 2);
return (
<details>
<summary className="small text-body-secondary">{label}</summary>
<pre
className="small mb-0 mt-1 p-2 bg-body-secondary rounded"
style={{ maxWidth: "20rem" }}
>
{json}
</pre>
</details>
);
return JSON.stringify(value, null, 2);
}
export default function AdminActionsPage() {
@@ -78,8 +67,22 @@ export default function AdminActionsPage() {
const history = useHistory();
const apiUrl = `/admin/actions${location.search}`;
const [expandedIds, setExpandedIds] = React.useState(() => new Set<string>());
const { data, error, isLoading } = useApiQuery<ActionsResponse>(apiUrl);
function toggleIoExpanded(rowId: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
return next;
});
}
if (error) {
return <p className="text-danger">Failed to load actions.</p>;
}
@@ -159,50 +162,102 @@ export default function AdminActionsPage() {
<th>Result</th>
<th>User</th>
<th>IP</th>
<th>Input</th>
<th>Output</th>
<th className="text-nowrap">Input / output</th>
</tr>
</thead>
<tbody>
{actions.items.length === 0 ? (
<tr>
<td className="text-body-secondary" colSpan={8}>
<td className="text-body-secondary" colSpan={7}>
No actions found.
</td>
</tr>
) : (
actions.items.map((action) => (
<tr key={action.row_id}>
<td className="small text-nowrap">
{MillisToSince(Date.parse(action.ts_start))}
</td>
<td className="font-monospace small">
{durationMs(action.ts_start, action.ts_end)}
</td>
<td className="small">{action.kind}</td>
<td>
<Badge bg={resultVariant(action.result)}>
{action.result}
</Badge>
</td>
<td>
{action.username ? (
<Link to={`/u/${action.username}`}>
{action.username}
</Link>
) : (
<span className="text-body-secondary"></span>
actions.items.map((action) => {
const ioOpen = expandedIds.has(action.row_id);
return (
<React.Fragment key={action.row_id}>
<tr>
<td className="small text-nowrap">
{MillisToSince(Date.parse(action.ts_start))}
</td>
<td className="font-monospace small">
{durationMs(action.ts_start, action.ts_end)}
</td>
<td className="small">{action.kind}</td>
<td>
<Badge bg={resultVariant(action.result)}>
{action.result}
</Badge>
</td>
<td>
{action.username ? (
<Link to={`/u/${action.username}`}>
{action.username}
</Link>
) : (
<span className="text-body-secondary"></span>
)}
</td>
<td className="font-monospace small">
{action.ip ?? "—"}
</td>
<td className="align-middle">
<Button
aria-expanded={ioOpen}
className="text-nowrap"
onClick={() => toggleIoExpanded(action.row_id)}
size="sm"
type="button"
variant="outline-secondary"
>
{ioOpen ? "Hide I/O" : "Show I/O"}
</Button>
</td>
</tr>
{ioOpen && (
<tr className="table-light">
<td className="border-top-0 p-3" colSpan={7}>
<div className="d-flex flex-column flex-lg-row gap-3 align-items-stretch">
<div className="flex-fill d-flex flex-column min-w-0">
<Form.Label className="small mb-1 text-body-secondary">
Input
</Form.Label>
<Form.Control
as="textarea"
className="font-monospace small"
readOnly
rows={14}
spellCheck={false}
value={formatJson(action.input)}
/>
</div>
<div
aria-hidden
className="d-flex align-items-center justify-content-center px-1 text-body-secondary fs-4"
>
</div>
<div className="flex-fill d-flex flex-column min-w-0">
<Form.Label className="small mb-1 text-body-secondary">
Output
</Form.Label>
<Form.Control
as="textarea"
className="font-monospace small"
readOnly
rows={14}
spellCheck={false}
value={formatJson(action.output)}
/>
</div>
</div>
</td>
</tr>
)}
</td>
<td className="font-monospace small">{action.ip ?? "—"}</td>
<td>
<JsonCell label="Input" value={action.input} />
</td>
<td>
<JsonCell label="Output" value={action.output} />
</td>
</tr>
))
</React.Fragment>
);
})
)}
</tbody>
</Table>
@@ -12,14 +12,14 @@ export default function AdminDestructivePage() {
const [deleteSessionId, setDeleteSessionId] = useState("");
const [ugptUserId, setUgptUserId] = useState("");
const [ugptGame, setUgptGame] = useState<GameGroup>(TachiConfig.GAMES[0]);
const [ugptGame, setUgptGame] = useState<GameGroup>(TachiConfig.GAME_GROUPS[0]);
const ugptGameConfig = useMemo(() => GetGameGroupConfig(ugptGame), [ugptGame]);
const [ugptPlaytype, setUgptPlaytype] = useState<string>(
() => GetGameGroupConfig(TachiConfig.GAMES[0]).playtypes[0],
() => GetGameGroupConfig(TachiConfig.GAME_GROUPS[0]).playtypes[0],
);
const [destroyChartId, setDestroyChartId] = useState("");
const [destroyChartGame, setDestroyChartGame] = useState<GameGroup>(TachiConfig.GAMES[0]);
const [destroyChartGame, setDestroyChartGame] = useState<GameGroup>(TachiConfig.GAME_GROUPS[0]);
function confirmDelete(message: string): boolean {
return window.confirm(message);
@@ -138,7 +138,7 @@ export default function AdminDestructivePage() {
}}
value={ugptGame}
>
{TachiConfig.GAMES.map((g) => (
{TachiConfig.GAME_GROUPS.map((g) => (
<option key={g} value={g}>
{g}
</option>
@@ -208,7 +208,7 @@ export default function AdminDestructivePage() {
onChange={(e) => setDestroyChartGame(e.target.value as GameGroup)}
value={destroyChartGame}
>
{TachiConfig.GAMES.map((g) => (
{TachiConfig.GAME_GROUPS.map((g) => (
<option key={g} value={g}>
{g}
</option>
@@ -3,7 +3,12 @@ import { TachiConfig } from "#lib/config";
import { APIFetchV1 } from "#util/api";
import React, { useState } from "react";
import { Button, Card, Col, Form, Row } from "react-bootstrap";
import { FormatGameGroup, type GameGroup, GetGameGroupConfig } from "tachi-common";
import {
FormatGame,
type GameGroup,
GetGameGroupConfig,
LEGACY_GameGroupPTToGame,
} from "tachi-common";
export default function AdminOperationsPage() {
useSetSubheader(["Admin", "Operations"]);
@@ -46,7 +51,7 @@ export default function AdminOperationsPage() {
value={announcementGame === "" ? "" : announcementGame}
>
<option value=""> Site-wide </option>
{TachiConfig.GAMES.map((g) => (
{TachiConfig.GAME_GROUPS.map((g) => (
<option key={g} value={g}>
{g}
</option>
@@ -63,7 +68,12 @@ export default function AdminOperationsPage() {
<option value=""></option>
{announcementGameConfig.playtypes.map((pt) => (
<option key={pt} value={pt}>
{FormatGameGroup(announcementGame as GameGroup, pt)}
{FormatGame(
LEGACY_GameGroupPTToGame(
announcementGame as GameGroup,
pt,
),
)}
</option>
))}
</Form.Select>
@@ -24,7 +24,7 @@ import React, { useContext, useMemo } from "react";
import Alert from "react-bootstrap/Alert";
import Stack from "react-bootstrap/Stack";
import { Link, Route, Switch } from "react-router-dom";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
import SupportBanner from "./misc/SupportBanner";
@@ -42,7 +42,7 @@ export function DashboardPage() {
return <DashboardLoggedIn user={user} />;
}
function DashboardLoggedIn({ user }: { user: MONGO_UserDocument }) {
function DashboardLoggedIn({ user }: { user: UserDocument }) {
const splash = useMemo(() => RFA(heySplashes), []);
return (
@@ -78,7 +78,7 @@ function DashboardLoggedIn({ user }: { user: MONGO_UserDocument }) {
);
}
function RecentInfo({ user }: { user: MONGO_UserDocument }) {
function RecentInfo({ user }: { user: UserDocument }) {
const { data, error } = useApiQuery<UserRecentSummary>(`/users/${user.id}/recent-summary`);
if (error) {
@@ -91,7 +91,7 @@ function RecentInfo({ user }: { user: MONGO_UserDocument }) {
const folderInfoMap = new Map();
for (const folderInfo of data.recentFolderStats) {
folderInfoMap.set(folderInfo.folderID, folderInfo);
folderInfoMap.set(folderInfo.slug, folderInfo);
}
const goalMap = CreateGoalMap(data.recentGoals);
@@ -35,31 +35,36 @@ import Stack from "react-bootstrap/Stack";
import { useQuery } from "react-query";
import { Link, Route, Switch } from "react-router-dom";
import {
type ChartDocument,
FormatDifficulty,
GameToGameGroup,
GetGameGroupConfig,
type integer,
type MONGO_ChartDocument,
type MONGO_PBScoreDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
type PBScoreDocument,
type SongDocument,
type UserDocument,
} from "tachi-common";
// Wrapper around the chart leaderboard UI; `chart` comes from the `/charts/:chartID` route.
export default function GPTChartPage({
chart,
game,
song,
playtype,
}: {
chart: MONGO_ChartDocument | null;
song: MONGO_SongDocument;
chart: ChartDocument | null;
song: SongDocument;
} & GamePT) {
const formatSongTitle = `${song.artist} - ${song.title}`;
const formatDiff = chart ? FormatDifficulty(chart, game) : "Loading...";
const formatDiff = chart ? FormatDifficulty(chart) : "Loading...";
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Songs", formatSongTitle, formatDiff],
[game, playtype, chart],
[
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Songs",
formatSongTitle,
formatDiff,
],
[game, chart],
`${formatSongTitle} (${formatDiff})`,
);
@@ -67,7 +72,7 @@ export default function GPTChartPage({
return <Loading />;
}
return <InternalGPTChartPage chart={chart} game={game} playtype={playtype} song={song} />;
return <InternalGPTChartPage chart={chart} game={game} song={song} />;
}
interface ChartPBData {
@@ -81,10 +86,9 @@ function InternalGPTChartPage({
chart,
game,
song,
playtype,
}: {
chart: MONGO_ChartDocument;
song: MONGO_SongDocument;
chart: ChartDocument;
song: SongDocument;
} & GamePT) {
const { user } = useContext(UserContext);
@@ -92,7 +96,7 @@ function InternalGPTChartPage({
["PBInfo", chart.chartID],
async () => {
const lRes = await APIFetchV1<ChartPBLeaderboardReturn>(
`/games/${game}/${playtype}/charts/${chart.chartID}/pbs`,
`/games/${game}/charts/${chart.chartID}/pbs`,
);
if (!lRes.success) {
@@ -100,7 +104,7 @@ function InternalGPTChartPage({
}
const pRes = await APIFetchV1<{ count: integer }>(
`/games/${game}/${playtype}/charts/${chart.chartID}/playcount`,
`/games/${game}/charts/${chart.chartID}/playcount`,
);
if (!pRes.success) {
@@ -109,11 +113,11 @@ function InternalGPTChartPage({
if (user) {
const nRes = await APIFetchV1<UGPTChartLeaderboardAdjacent>(
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/leaderboard-adjacent`,
`/users/${user.id}/games/${game}/pbs/${chart.chartID}/leaderboard-adjacent`,
);
const rRes = await APIFetchV1<ChartRivalsReturn>(
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals`,
`/users/${user.id}/games/${game}/pbs/${chart.chartID}/rivals`,
);
const returnValue: ChartPBData = {
@@ -163,11 +167,10 @@ function InternalGPTChartPage({
}
if (user) {
// Add current user, since there's no guarantee they are returned from either API.
userMap.set(user.id, user);
}
const base = CreateChartLink(chart, game);
const base = CreateChartLink(chart);
return (
<>
@@ -203,16 +206,15 @@ function InternalGPTChartPage({
</Row>
<div className="mt-4">
<Switch>
<Route exact path="/games/:game/:playtype/charts/:chartID/targets">
<ChartTargetInfo {...{ chart, game, playtype, song, user: user! }} />
<Route exact path="/games/:game/charts/:chartID/targets">
<ChartTargetInfo {...{ chart, game, song, user: user! }} />
</Route>
<Route exact path="/games/:game/:playtype/charts/:chartID">
<Route exact path="/games/:game/charts/:chartID">
<ChartLeaderboardTable
{...{
data,
game,
playtype,
user,
userMap,
chart,
@@ -222,12 +224,11 @@ function InternalGPTChartPage({
/>
</Route>
<Route exact path="/games/:game/:playtype/charts/:chartID/me">
<Route exact path="/games/:game/charts/:chartID/me">
<ChartLeaderboardTable
{...{
data,
game,
playtype,
user,
userMap,
chart,
@@ -237,12 +238,11 @@ function InternalGPTChartPage({
/>
</Route>
<Route exact path="/games/:game/:playtype/charts/:chartID/rivals">
<Route exact path="/games/:game/charts/:chartID/rivals">
<ChartLeaderboardTable
{...{
data,
game,
playtype,
user,
userMap,
chart,
@@ -260,21 +260,19 @@ function InternalGPTChartPage({
function ChartTargetInfo({
user,
game,
playtype,
chart,
song,
}: {
chart: MONGO_ChartDocument;
song: MONGO_SongDocument;
user: MONGO_UserDocument;
chart: ChartDocument;
song: SongDocument;
user: UserDocument;
} & GamePT) {
const { reloadTargets } = useContext(TargetsContext);
const [shouldReload, setShouldReload] = useState(0);
const { error, data } = useApiQuery<GoalsOnChartReturn>(
`/users/${user.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${chart.chartID}`,
`/users/${user.id ?? ""}/games/${game}/targets/on-chart/${chart.chartID}`,
undefined,
// force a reload of this data when the user adds a new goal
[shouldReload.toString()],
);
@@ -287,11 +285,9 @@ function ChartTargetInfo({
data,
error,
game,
playtype,
reqUser: user,
song,
onGoalSet: () => {
// reload local query, then reload global targets.
setShouldReload(shouldReload + 1);
reloadTargets();
},
@@ -306,24 +302,23 @@ function ChartLeaderboardTable({
userMap,
user: _user,
game,
playtype,
mode,
chart,
song,
}: {
chart: MONGO_ChartDocument;
chart: ChartDocument;
data: ChartPBData;
mode: "adjacent" | "leaderboard" | "rivals";
song: MONGO_SongDocument;
user: MONGO_UserDocument | null;
userMap: Map<integer, MONGO_UserDocument>;
song: SongDocument;
user: UserDocument | null;
userMap: Map<integer, UserDocument>;
} & GamePT) {
const { settings } = useLUGPTSettings();
const dataset: PBDataset = useMemo(() => {
const ds: PBDataset = [];
let pbs: Array<MONGO_PBScoreDocument> = [];
let pbs: Array<PBScoreDocument> = [];
if (mode === "leaderboard") {
pbs = data.leaderboard.pbs;
} else if (mode === "adjacent") {
@@ -358,7 +353,6 @@ function ChartLeaderboardTable({
defaultRankingViewMode={mode === "rivals" ? "global-no-switch" : "both-if-self"}
game={game}
key={mode}
playtype={playtype}
showChart={false}
showUser
/>
@@ -371,20 +365,14 @@ function TopShowcase({
userMap,
chart,
}: {
chart: MONGO_ChartDocument;
chart: ChartDocument;
data: ChartPBData;
user: MONGO_UserDocument | null;
userMap: Map<integer, MONGO_UserDocument>;
user: UserDocument | null;
userMap: Map<integer, UserDocument>;
}) {
// We have a couple of conditions.
// User is #1: col-12 #1,
// User has played: col-6 col-6,
// User has not played: col-12 #1,
const bestPlay = data.leaderboard.pbs[0]!;
const bestUser = userMap.get(bestPlay.userID)!;
// User hasn't played, or isn't logged in or something.
if (user?.id === bestPlay.userID) {
return (
<Col xs={12}>
@@ -428,28 +416,29 @@ function PlayCard({
name,
chart,
}: {
chart: MONGO_ChartDocument;
chart: ChartDocument;
name: string;
pb: MONGO_PBScoreDocument;
user: MONGO_UserDocument;
pb: PBScoreDocument;
user: UserDocument;
}) {
const {
breakpoint: { isLg },
} = useContext(WindowContext);
const pbGame = pb.game;
return (
<Card cardBodyClassName="vstack gap-4" header={name}>
<Stack
className="flex-grow-1 align-items-lg-start align-items-center justify-content-around"
direction={isLg ? "horizontal" : "vertical"}
>
<ProfilePicture toGPT={{ game: pb.game, playtype: pb.playtype }} user={user} />
<ProfilePicture toGPT={{ game: pbGame }} user={user} />
<div
className="d-flex flex-column align-self-stretch justify-content-between align-items-center"
style={{ maxHeight: 128, minWidth: 256 }}
>
<Link
className="text-decoration-none fs-4 fw-bold text-break text-center"
to={`/u/${user.username}/games/${pb.game}/${pb.playtype}`}
to={`/u/${user.username}/games/${pbGame}`}
>
{user.username}
</Link>
@@ -465,7 +454,7 @@ function PlayCard({
<Col>
<MiniTable colSpan={100} headers={["PB Info"]}>
<tr>
<ScoreCoreCells chart={chart} game={pb.game} score={pb} short />
<ScoreCoreCells chart={chart} game={pbGame} score={pb} short />
</tr>
</MiniTable>
<div className="text-center">
@@ -14,18 +14,19 @@ import { NumericSOV, StrSOV } from "#util/sorts";
import React, { useState } from "react";
import { Col, Row } from "react-bootstrap";
import {
FormatGameGroup,
type ChartDocument,
FormatGame,
GameToGameGroup,
GetGameGroupConfig,
type integer,
type MONGO_ChartDocument,
type MONGO_SongDocument,
type SongDocument,
} from "tachi-common";
export default function GPTChartsPage({ game, playtype }: GamePT) {
export default function GPTChartsPage({ game }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Songs"],
[game, playtype],
`${FormatGameGroup(game, playtype)} Songs`,
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Songs"],
[game],
`${FormatGame(game)} Songs`,
);
const [search, setSearch] = useState("");
@@ -37,19 +38,19 @@ export default function GPTChartsPage({ game, playtype }: GamePT) {
<Divider />
</Col>
<Col xs={12}>
<SearchSongsTable game={game} playtype={playtype} search={search} />
<SearchSongsTable game={game} search={search} />
</Col>
</Row>
);
}
function SearchSongsTable({ game, playtype, search }: { search: string } & GamePT) {
function SearchSongsTable({ game, search }: { search: string } & GamePT) {
const params = new URLSearchParams({ search });
const { data, error } = useApiQuery<{
charts: ({ __playcount: integer } & MONGO_ChartDocument)[];
songs: MONGO_SongDocument[];
}>(`/games/${game}/${playtype}/charts${search !== "" ? `?${params.toString()}` : ""}`);
charts: ({ __playcount: integer } & ChartDocument)[];
songs: SongDocument[];
}>(`/games/${game}/charts${search !== "" ? `?${params.toString()}` : ""}`);
if (error) {
return <ApiError error={error} />;
@@ -67,7 +68,7 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP
dataset.push({
...chart,
__related: {
song: songMap.get(chart.songID)!,
song: songMap.get(chart.song.id)!,
},
});
}
@@ -76,7 +77,7 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP
<>
{search === "" && (
<div className="w-100 text-center">
<h4>Displaying the most played charts for {FormatGameGroup(game, playtype)}</h4>
<h4>Displaying the most played charts for {FormatGame(game)}</h4>
<Divider />
</div>
)}
@@ -7,33 +7,30 @@ import { type GamePT } from "#types/react";
import React from "react";
import {
type Classes,
FormatGameGroup,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
type GPTString,
type V3Game,
} from "tachi-common";
export default function GPTDevInfo({ game, playtype }: GamePT) {
export default function GPTDevInfo({ game }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Dev Info"],
[game, playtype],
`${FormatGameGroup(game, playtype)} Dev Info`,
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Dev Info"],
[game],
`${FormatGame(game)} Dev Info`,
);
const gameConfig = GetGameGroupConfig(game);
const gptConfig = GetGamePTConfig(game, playtype);
const gameGroupConfig = GetGameConfig(game);
return (
<>
<Card header="Game Configuration">
<DebugContent data={gameConfig} />
</Card>
<Card className="mt-4" header="GPT Configuration">
<DebugContent data={gptConfig} />
<Card header="GPT Configuration">
<DebugContent data={gameGroupConfig} />
</Card>
<Card className="mt-4" header="Class Badges">
<div className="d-flex w-100 justify-content-center" style={{ gap: "30px" }}>
{Object.entries(gptConfig.classes).map(([classSet, conf]) => (
{Object.entries(gameGroupConfig.classes).map(([classSet, conf]) => (
<div key={classSet}>
<MiniTable colSpan={2} headers={[classSet]}>
{conf.values.map((e) => (
@@ -41,10 +38,9 @@ export default function GPTDevInfo({ game, playtype }: GamePT) {
<td>{e.id}</td>
<td>
<ClassBadge
classSet={classSet as Classes[GPTString]}
classSet={classSet as Classes[V3Game]}
classValue={e.id}
game={game}
playtype={playtype}
/>
</td>
</tr>
@@ -22,16 +22,19 @@ import React, { useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import {
type AnyProfileRatingAlg,
FormatGameGroup,
type Classes,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
type V3Game,
} from "tachi-common";
export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
export default function GPTLeaderboardsPage({ game }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Leaderboards"],
[game, playtype],
`${FormatGameGroup(game, playtype)} Leaderboards`,
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Leaderboards"],
[game],
`${FormatGame(game)} Leaderboards`,
);
const [mode, setMode] = useState<"profile" | "score">("profile");
@@ -51,39 +54,35 @@ export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
<Col xs={12}>
<Divider />
{mode === "profile" ? (
<ProfileLeaderboard game={game} playtype={playtype} />
<ProfileLeaderboard game={game} />
) : (
<ScoreLeaderboard
game={game}
playtype={playtype}
url={`/games/${game}/${playtype}/pb-leaderboard`}
/>
<ScoreLeaderboard game={game} url={`/games/${game}/pb-leaderboard`} />
)}
</Col>
</Row>
);
}
function ProfileLeaderboard({ game, playtype }: GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
function ProfileLeaderboard({ game }: GamePT) {
const gameConfig = GetGameConfig(game);
const defaultAlg = useProfileRatingAlg(game, playtype);
const defaultAlg = useProfileRatingAlg(game);
const [alg, setAlg] = useState(defaultAlg);
const SelectComponent =
Object.keys(gptConfig.profileRatingAlgs).length > 1 ? (
Object.keys(gameConfig.profileRatingAlgs).length > 1 ? (
<Form.Select onChange={(e) => setAlg(e.target.value as any)} value={alg}>
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTProfileRatingName(game, playtype, e)}
{FormatGPTProfileRatingName(game, e)}
</option>
))}
</Form.Select>
) : null;
const { data, error } = useApiQuery<UserLeaderboardReturns>(
`/games/${game}/${playtype}/leaderboard?alg=${alg}&limit=500`,
`/games/${game}/leaderboard?alg=${alg}&limit=500`,
);
if (error) {
@@ -128,11 +127,13 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
headers={[
["Ranking", "Rank", NumericSOV((x) => x.__related.index)],
["User", "User", StrSOV((x) => x.__related.user.username)],
...(Object.keys(gptConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>).map(
...(
Object.keys(gameConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
).map(
(e) =>
[
FormatGPTProfileRatingName(game, playtype, e),
FormatGPTProfileRatingName(game, playtype, e),
FormatGPTProfileRatingName(game, e),
FormatGPTProfileRatingName(game, e),
NumericSOV((x) => x.ratings[e] ?? -Infinity),
] as Header<UGSDataset[0]>,
),
@@ -141,13 +142,13 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
rowFunction={(r) => (
<tr>
<IndexCell index={r.__related.index} />
<UserCell game={game} playtype={playtype} user={r.__related.user} />
<UserCell game={game} user={r.__related.user} />
{(
Object.keys(gptConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
Object.keys(gameConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
).map((e) => (
<td key={e}>
{r.ratings[e]
? FormatGPTProfileRating(game, playtype, e, r.ratings[e]!)
? FormatGPTProfileRating(game, e, r.ratings[e]!)
: "No Data."}
</td>
))}
@@ -161,11 +162,10 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
([k, v]) =>
v && (
<ClassBadge
classSet={k as keyof typeof r.classes}
classSet={k as Classes[V3Game]}
classValue={v}
game={game}
key={k}
playtype={playtype}
/>
),
)
@@ -2,14 +2,14 @@ import Activity from "#components/activity/Activity";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import { type GamePT } from "#types/react";
import React from "react";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
export default function GPTMainPage({ game, playtype }: GamePT) {
export default function GPTMainPage({ game }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype],
[game, playtype],
FormatGameGroup(game, playtype),
["Games", GetGameGroupConfig(GameToGameGroup(game)).name],
[game],
FormatGame(game),
);
return <Activity url={`/games/${game}/${playtype}/activity`} />;
return <Activity url={`/games/${game}/activity`} />;
}
@@ -2,13 +2,13 @@ import ImportFileInfo from "#components/imports/ImportFileInfo";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import { TachiConfig } from "#lib/config";
import { p } from "prudence";
import React, { useState } from "react";
import React from "react";
import {
type BatchManual,
FormatGameGroup,
FormatGame,
FormatPrError,
GetGameGroupConfig,
GetGamePTConfig,
LEGACY_GameGroupPTToGame,
} from "tachi-common";
import { PR_BATCH_MANUAL } from "tachi-common/lib/schemas";
@@ -23,23 +23,23 @@ export default function BatchManualPage() {
parseFunction={(text: string) => {
const data: BatchManual = JSON.parse(text);
const gameConfig = GetGameGroupConfig(data.meta.game);
const gameGroupConfig = GetGameGroupConfig(data.meta.game);
if (!gameConfig) {
if (!gameGroupConfig) {
throw new Error(
`Invalid game ${data.meta.game}. Expected any of ${TachiConfig.GAMES}.`,
`Invalid game ${data.meta.game}. Expected any of ${TachiConfig.GAME_GROUPS}.`,
);
}
const gptConfig = GetGamePTConfig(data.meta.game, data.meta.playtype);
if (!gptConfig) {
if (!gameGroupConfig.playtypes.includes(data.meta.playtype as any)) {
throw new Error(
`Invalid Playtype ${data.meta.playtype}. Expected any of ${gameConfig.playtypes}.`,
`Invalid Playtype ${data.meta.playtype}. Expected any of ${gameGroupConfig.playtypes}.`,
);
}
const err = p(data, PR_BATCH_MANUAL(data.meta.game, data.meta.playtype));
const game = LEGACY_GameGroupPTToGame(data.meta.game, data.meta.playtype);
const err = p(data, PR_BATCH_MANUAL(game));
if (err) {
throw new Error(FormatPrError(err, "Invalid BATCH-MANUAL: "));
@@ -48,7 +48,7 @@ export default function BatchManualPage() {
return {
valid: true,
info: {
Game: FormatGameGroup(data.meta.game, data.meta.playtype),
Game: FormatGame(game),
Scores: data.scores.length,
},
};
@@ -23,10 +23,10 @@ import {
GetGameGroupConfig,
type ImportTypes,
type integer,
type MONGO_UserDocument,
type UserDocument,
} from "tachi-common";
export default function ImportPage({ user }: { user: MONGO_UserDocument }) {
export default function ImportPage({ user }: { user: UserDocument }) {
useSetSubheader(["Import Scores"]);
const [game, setGame] = useState<GameGroup | null>(null);
@@ -66,7 +66,7 @@ export default function ImportPage({ user }: { user: MONGO_UserDocument }) {
value={game ?? ""}
>
<option value="">Please select a game.</option>
{TachiConfig.GAMES.map((e) => (
{TachiConfig.GAME_GROUPS.map((e) => (
<option key={e} value={e}>
{GetGameGroupConfig(e).name}
</option>
@@ -89,7 +89,7 @@ function ShowRecentImports() {
return <InnerShowRecentImports user={user} />;
}
function InnerShowRecentImports({ user }: { user: MONGO_UserDocument }) {
function InnerShowRecentImports({ user }: { user: UserDocument }) {
const { data, error } = useApiQuery<{ count: integer; importType: ImportTypes }[]>(
`/users/${user.id}/recent-imports`,
);
@@ -5,9 +5,9 @@ import useApiQuery from "#components/util/query/useApiQuery";
import { TachiConfig } from "#lib/config";
import { ONE_DAY } from "#util/constants/time";
import React, { useEffect, useState } from "react";
import { type integer, type MONGO_UserDocument } from "tachi-common";
import { type integer, type UserDocument } from "tachi-common";
export default function SupportBanner({ user }: { user: MONGO_UserDocument }) {
export default function SupportBanner({ user }: { user: UserDocument }) {
const { data, error } = useApiQuery<{ scores: integer; sessions: integer }>(
`/users/${user.id}/stats`,
);
@@ -19,13 +19,12 @@ import React, { useContext, useEffect, useLayoutEffect, useState } from "react";
import { Badge, Col, Form, Row } from "react-bootstrap";
import { Link, useHistory, useLocation } from "react-router-dom";
import {
FormatGameGroup,
type GPTString,
type ChartDocument,
FormatGame,
type integer,
type MONGO_ChartDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
SplitGPT,
type SongDocument,
type UserDocument,
type V3Game,
} from "tachi-common";
function parseSearchParams(search: string) {
@@ -48,48 +47,40 @@ export default function SearchPage() {
const [hasPlayedGame, setHasPlayedGame] = useState(initialHasPlayed);
useLayoutEffect(() => {
const { q, hasPlayedGame: h } = parseSearchParams(location.search);
setSearch(q);
setHasPlayedGame(h);
const params = parseSearchParams(location.search);
setSearch(params.q);
setHasPlayedGame(params.hasPlayedGame);
}, [location.search]);
useEffect(() => {
const next = new URLSearchParams();
if (search) {
next.set("q", search);
const qs = `?q=${encodeURIComponent(search)}&hasPlayedGame=${hasPlayedGame}`;
if (location.search !== qs) {
history.replace(`/search${qs}`);
}
if (!hasPlayedGame) {
next.set("hasPlayedGame", "false");
}
const nextStr = next.toString();
const current = new URLSearchParams(history.location.search).toString();
if (nextStr !== current) {
history.replace({
pathname: location.pathname,
search: nextStr ? `?${nextStr}` : "",
});
}
}, [search, hasPlayedGame, history, location.pathname]);
}, [search, hasPlayedGame]);
return (
<Row>
<Col xs={12}>
<DebounceSearch
autoFocus
committedSearch={search}
placeholder="Search songs, users..."
setSearch={setSearch}
/>
{user && (
<div className="w-100 mt-4 ms-1">
<Form.Check
checked={hasPlayedGame}
label="Hide games you haven't played?"
onChange={(e) => setHasPlayedGame(e.target.checked)}
/>
</div>
)}
<Divider />
<Card className="mt-8" header="Search Tachi">
<DebounceSearch
autoFocus
className="form-control-lg"
committedSearch={search}
placeholder="Search songs, users..."
setSearch={setSearch}
/>
{user && (
<div className="w-100 mt-4 ms-1">
<Form.Check
checked={hasPlayedGame}
label="Hide games you haven't played?"
onChange={(e) => setHasPlayedGame(e.target.checked)}
/>
</div>
)}
<Divider />
</Card>
</Col>
<Col xs={12}>
<SearchResults hasPlayedGame={hasPlayedGame} search={search} />
@@ -100,7 +91,7 @@ export default function SearchPage() {
function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; search: string }) {
const { data, error } = useTachiSearch(search, hasPlayedGame);
const [mode, setMode] = useState<"users" | GPTString | null>(null);
const [mode, setMode] = useState<"users" | V3Game | null>(null);
useEffect(() => {
if (data) {
@@ -109,7 +100,7 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
.filter((k) => k[1].length > 0);
if (thingsWithCharts.length > 0) {
setMode(thingsWithCharts[0][0] as GPTString);
setMode(thingsWithCharts[0][0] as V3Game);
} else if (data.users.length > 0) {
setMode("users");
} else {
@@ -161,13 +152,11 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
return null;
}
const gpt = g as GPTString;
const [game, playtype] = SplitGPT(gpt);
const game = g as V3Game;
return (
<SelectButton id={gpt} key={gpt} setValue={setMode} value={mode}>
{FormatGameGroup(game, playtype)}
<SelectButton id={game} key={game} setValue={setMode} value={mode}>
{FormatGame(game)}
<Badge bg="secondary" className="ms-2 text-light">
{charts.length}
</Badge>
@@ -198,7 +187,7 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
{mode === "users" ? (
<UsersView users={data.users} />
) : (
<ChartView charts={data.charts[mode]!} gpt={mode} />
<ChartView charts={data.charts[mode]!} game={mode} />
)}
</Col>
)}
@@ -208,17 +197,15 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
function ChartView({
charts,
gpt,
game,
}: {
charts: Array<{
chart: MONGO_ChartDocument;
chart: ChartDocument;
playcount: integer;
song: MONGO_SongDocument;
song: SongDocument;
}>;
gpt: GPTString;
game: V3Game;
}) {
const [game, playtype] = SplitGPT(gpt);
return (
<TachiTable
dataset={charts}
@@ -248,52 +235,72 @@ function ChartView({
);
}
function UsersView({ users }: { users: Array<MONGO_UserDocument> }) {
function UsersView({ users }: { users: Array<UserDocument> }) {
return (
<Row>
<div
className="w-100 d-flex"
style={{
flexWrap: "wrap",
}}
></div>
{users.map((user) => (
<Col key={user.id} lg={6} xs={12}>
<Card className="mb-4">
<div className="d-flex h-100">
<div>
<ProfilePicture user={user} />
</div>
<div
className="ms-4 d-flex w-100 h-100"
style={{
flexWrap: "wrap",
flexDirection: "column",
}}
>
<div>
<h4>
<Link
className="text-decoration-none"
to={`/u/${user.username}`}
>
{user.username}
</Link>
</h4>
</div>
<div>
<Muted>{user.status ?? "I haven't set my status."}</Muted>
</div>
{Date.now() - user.lastSeen < ONE_MINUTE * 5 && (
<div className="mt-2">
<Badge bg="success">ONLINE</Badge>
</div>
)}
</div>
</div>
</Card>
</Col>
))}
</Row>
<TachiTable
dataset={users}
defaultSortMode="Last Seen"
entryName="Users"
headers={[
["User", "User", StrSOV((x) => x.username)],
["Status", "Status", StrSOV((x) => x.status ?? "")],
["Last Seen", "Last Seen", NumericSOV((x) => x.lastSeen)],
]}
rowFunction={(u) => (
<tr>
<td>
<Link to={`/u/${u.username}`}>
<ProfilePicture size="sm" user={u} />
{u.username}
</Link>
</td>
<td>
<Muted>{u.status ?? "No status."}</Muted>
</td>
<td>
{u.lastSeen === 0
? "Never"
: new Date(u.lastSeen).toLocaleDateString("en-GB", {
timeZone: "UTC",
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})}
</td>
</tr>
)}
searchFunctions={{
username: (x) => x.username,
status: (x) => x.status,
lastSeen: (x) => {
if (x.lastSeen === 0) {
return null;
}
return new Date(x.lastSeen).toLocaleString("en-GB", {
timeZone: "UTC",
timeZoneName: "short",
});
},
}}
/>
);
}
function LastSeenCell({ time }: { time: number }) {
if (time === 0) {
return <td>Never</td>;
}
const now = Date.now();
if (now - time < ONE_MINUTE) {
return <td>Just Now</td>;
}
const d = new Date(time);
return <td>{d.toLocaleDateString("en-GB")}</td>;
}
@@ -7,10 +7,10 @@ import { isCardIDValid } from "#util/misc";
import React, { useState } from "react";
import { Button, Col, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import { type MONGO_FervidexSettingsDocument, type MONGO_UserDocument } from "tachi-common";
import { type FervidexSettingsDocument, type UserDocument } from "tachi-common";
export default function FervidexIntegrationPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
const { data: settings, error } = useApiQuery<MONGO_FervidexSettingsDocument | null>(
export default function FervidexIntegrationPage({ reqUser }: { reqUser: UserDocument }) {
const { data: settings, error } = useApiQuery<FervidexSettingsDocument | null>(
`/users/${reqUser.id}/integrations/fervidex/settings`,
);
@@ -48,12 +48,10 @@ function FervidexForm({
reqUser,
settings,
}: {
reqUser: MONGO_UserDocument;
settings: MONGO_FervidexSettingsDocument | null;
reqUser: UserDocument;
settings: FervidexSettingsDocument | null;
}) {
const [formSettings, setFormSettings] = useState<
Omit<MONGO_FervidexSettingsDocument, "userID">
>(
const [formSettings, setFormSettings] = useState<Omit<FervidexSettingsDocument, "userID">>(
settings
? { forceStaticImport: settings.forceStaticImport, cards: settings.cards }
: {
@@ -7,13 +7,13 @@ import React, { useState } from "react";
import { Button, Col, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type MONGO_FervidexSettingsDocument,
type MONGO_KsHookSettingsDocument,
type MONGO_UserDocument,
type FervidexSettingsDocument,
type KsHookSettingsDocument,
type UserDocument,
} from "tachi-common";
export default function KsHookSV6CIntegrationPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
const { data: settings, error } = useApiQuery<MONGO_FervidexSettingsDocument | null>(
export default function KsHookSV6CIntegrationPage({ reqUser }: { reqUser: UserDocument }) {
const { data: settings, error } = useApiQuery<FervidexSettingsDocument | null>(
`/users/${reqUser.id}/integrations/kshook-sv6c/settings`,
);
@@ -51,10 +51,10 @@ function KsHookSV6CForm({
reqUser,
settings,
}: {
reqUser: MONGO_UserDocument;
settings: MONGO_KsHookSettingsDocument | null;
reqUser: UserDocument;
settings: KsHookSettingsDocument | null;
}) {
const [formSettings, setFormSettings] = useState<Omit<MONGO_KsHookSettingsDocument, "userID">>(
const [formSettings, setFormSettings] = useState<Omit<KsHookSettingsDocument, "userID">>(
settings ? { forceStaticImport: settings.forceStaticImport } : { forceStaticImport: false },
);
@@ -8,7 +8,7 @@ import { UserContext } from "#context/UserContext";
import { APIFetchV1 } from "#util/api";
import React, { useContext, useEffect, useMemo } from "react";
import { Button, Col, Row } from "react-bootstrap";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export default function NotificationsPage() {
const { user } = useContext(UserContext);
@@ -22,7 +22,7 @@ export default function NotificationsPage() {
return <NotificationsInnerPage user={user} />;
}
function NotificationsInnerPage({ user }: { user: MONGO_UserDocument }) {
function NotificationsInnerPage({ user }: { user: UserDocument }) {
const { notifications, reload } = useContext(NotificationsContext);
const unread = useMemo(() => {
@@ -1,9 +1,9 @@
import useSetSubheader from "#components/layout/header/useSetSubheader";
import UGPTProfiles from "#components/user/UGPTProfiles";
import React from "react";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export default function UserGamesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
export default function UserGamesPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Games"],
[reqUser],
@@ -10,13 +10,9 @@ import SelectLinkButton from "#components/util/SelectLinkButton";
import { type FailedImportDataset, type ImportDataset } from "#types/tables";
import React, { useMemo, useState } from "react";
import { Route, Switch } from "react-router-dom";
import {
type ImportTrackerFailed,
type MONGO_ImportDocument,
type MONGO_UserDocument,
} from "tachi-common";
import { type ImportDocument, type ImportTrackerFailed, type UserDocument } from "tachi-common";
export default function UserImportsPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
export default function UserImportsPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Imports"],
[reqUser],
@@ -73,9 +69,9 @@ function ViewRecentImports({
reqUser,
}: {
params: URLSearchParams;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
}) {
const { data, error } = useApiQuery<Array<MONGO_ImportDocument>>(
const { data, error } = useApiQuery<Array<ImportDocument>>(
`/users/${reqUser.id}/imports?${params.toString()}`,
);
@@ -102,7 +98,7 @@ function ViewRecentFailedImports({
reqUser,
}: {
params: URLSearchParams;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
}) {
const { data, error } = useApiQuery<Array<ImportTrackerFailed>>(
`/users/${reqUser.id}/imports/failed?${params.toString()}`,
@@ -20,18 +20,18 @@ import { Alert, Button, Col, Form, Modal, Row } from "react-bootstrap";
import { Link, Route, Switch } from "react-router-dom";
import {
type APIPermissions,
type APITokenDocument,
type CGCardInfo,
type integer,
type MONGO_APITokenDocument,
type MONGO_CGCardInfo,
type MONGO_MytCardInfo,
type MONGO_TachiAPIClientDocument,
type MONGO_UserDocument,
type MytCardInfo,
type TachiAPIClientDocument,
type UserDocument,
} from "tachi-common";
import FervidexIntegrationPage from "./FervidexIntegrationPage";
import KsHookSV6CIntegrationPage from "./KsHookSV6CIntegrationPage";
export default function UserIntegrationsPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
export default function UserIntegrationsPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Integrations"],
[reqUser],
@@ -102,9 +102,9 @@ function OAuthClientPage() {
}
function OAuthClientInfo() {
const { data, error } = useApiQuery<MONGO_TachiAPIClientDocument[]>("/clients");
const { data, error } = useApiQuery<TachiAPIClientDocument[]>("/clients");
const [clients, setClients] = useState<MONGO_TachiAPIClientDocument[]>([]);
const [clients, setClients] = useState<TachiAPIClientDocument[]>([]);
useEffect(() => {
setClients(data ?? []);
@@ -144,11 +144,7 @@ function OAuthClientInfo() {
);
}
function CreateNewOAuthClient({
setClients,
}: {
setClients: SetState<MONGO_TachiAPIClientDocument[]>;
}) {
function CreateNewOAuthClient({ setClients }: { setClients: SetState<TachiAPIClientDocument[]> }) {
const [show, setShow] = useState(false);
const [name, setName] = useState("");
const [redirectUri, setRedirectUri] = useState("");
@@ -310,9 +306,9 @@ function CreateNewOAuthClient({
}
interface OAuthClientProps {
client: MONGO_TachiAPIClientDocument;
clients: MONGO_TachiAPIClientDocument[];
setClients: SetState<MONGO_TachiAPIClientDocument[]>;
client: TachiAPIClientDocument;
clients: TachiAPIClientDocument[];
setClients: SetState<TachiAPIClientDocument[]>;
}
function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) {
@@ -392,7 +388,7 @@ function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) {
<div className="mt-8">
<Button
onClick={async () => {
const res = await APIFetchV1<MONGO_TachiAPIClientDocument>(
const res = await APIFetchV1<TachiAPIClientDocument>(
`/clients/${client.clientID}/reset-secret`,
{
method: "POST",
@@ -506,7 +502,7 @@ function EditClientModal({
onSubmit={async (e) => {
e.preventDefault();
const res = await APIFetchV1<MONGO_TachiAPIClientDocument>(
const res = await APIFetchV1<TachiAPIClientDocument>(
`/clients/${client.clientID}`,
{
method: "PATCH",
@@ -621,7 +617,7 @@ function EditClientModal({
);
}
function ServicesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ServicesPage({ reqUser }: { reqUser: UserDocument }) {
if (TachiConfig.TYPE === "boku") {
return (
<Row className="text-center">
@@ -761,13 +757,11 @@ function KAIIntegrationStatus({
);
}
function APIKeysPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
const [apiKeys, setApiKeys] = useState<MONGO_APITokenDocument[]>([]);
function APIKeysPage({ reqUser }: { reqUser: UserDocument }) {
const [apiKeys, setApiKeys] = useState<APITokenDocument[]>([]);
const [showModal, setShowModal] = useState(false);
const { data, error } = useApiQuery<MONGO_APITokenDocument[]>(
`/users/${reqUser.id}/api-tokens`,
);
const { data, error } = useApiQuery<APITokenDocument[]>(`/users/${reqUser.id}/api-tokens`);
useEffect(() => {
if (data) {
@@ -827,9 +821,9 @@ function CreateAPIKeyModal({
apiKeys,
setApiKeys,
}: {
apiKeys: MONGO_APITokenDocument[];
reqUser: MONGO_UserDocument;
setApiKeys: SetState<MONGO_APITokenDocument[]>;
apiKeys: APITokenDocument[];
reqUser: UserDocument;
setApiKeys: SetState<APITokenDocument[]>;
setShowModal: SetState<boolean>;
showModal: boolean;
}) {
@@ -843,7 +837,7 @@ function CreateAPIKeyModal({
<form
onSubmit={async (e) => {
e.preventDefault();
const res = await APIFetchV1<MONGO_APITokenDocument>(
const res = await APIFetchV1<APITokenDocument>(
`/users/${reqUser.id}/api-tokens/create`,
{
method: "POST",
@@ -914,9 +908,9 @@ function APIKeyRow({
setApiKeys,
apiKeys,
}: {
apiKey: MONGO_APITokenDocument;
apiKeys: MONGO_APITokenDocument[];
setApiKeys: SetState<MONGO_APITokenDocument[]>;
apiKey: APITokenDocument;
apiKeys: APITokenDocument[];
setApiKeys: SetState<APITokenDocument[]>;
}) {
const [show, setShow] = useState(false);
const [sure, setSure] = useState(false);
@@ -962,7 +956,7 @@ function APIKeyRow({
function CGIntegrationInfo({ cgType, userID }: { cgType: "dev" | "gan" | "nag"; userID: integer }) {
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
const { data, error } = useApiQuery<MONGO_CGCardInfo | null>(
const { data, error } = useApiQuery<CGCardInfo | null>(
`/users/${userID}/integrations/cg/${cgType}`,
undefined,
[reload],
@@ -1007,7 +1001,7 @@ function CGIntegrationInfo({ cgType, userID }: { cgType: "dev" | "gan" | "nag";
function MytIntegrationInfo({ userID }: { userID: integer }) {
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
const { data, error } = useApiQuery<MONGO_MytCardInfo | null>(
const { data, error } = useApiQuery<MytCardInfo | null>(
`/users/${userID}/integrations/myt`,
undefined,
[reload],
@@ -14,12 +14,12 @@ import { Button, Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type integer,
type MONGO_InviteCodeDocument,
type MONGO_UserDocument,
type InviteCodeDocument,
UserAuthLevels,
type UserDocument,
} from "tachi-common";
export default function UserInvitesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
export default function UserInvitesPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Invites"],
[reqUser],
@@ -91,10 +91,10 @@ export default function UserInvitesPage({ reqUser }: { reqUser: MONGO_UserDocume
);
}
function InviteList({ reqUser }: { reqUser: MONGO_UserDocument }) {
function InviteList({ reqUser }: { reqUser: UserDocument }) {
const { data, error } = useApiQuery<{
consumers: MONGO_UserDocument[];
invites: MONGO_InviteCodeDocument[];
consumers: UserDocument[];
invites: InviteCodeDocument[];
}>(`/users/${reqUser.id}/invites`);
if (error) {
@@ -9,10 +9,10 @@ import { APIFetchV1 } from "#util/api";
import React, { useContext, useEffect, useState } from "react";
import { Button } from "react-bootstrap";
import ReactMarkdown from "react-markdown";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
interface Props {
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
}
export default function UserPage({ reqUser }: Props) {
@@ -19,14 +19,10 @@ import Form from "react-bootstrap/Form";
import InputGroup from "react-bootstrap/InputGroup";
import Stack from "react-bootstrap/Stack";
import toast from "react-hot-toast";
import {
type integer,
type MONGO_UserDocument,
type MONGO_UserSettingsDocument,
} from "tachi-common";
import { type integer, type UserDocument, type UserSettingsDocument } from "tachi-common";
interface Props {
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
}
export default function UserSettingsDocumentPage({ reqUser }: Props) {
@@ -101,7 +97,7 @@ export default function UserSettingsDocumentPage({ reqUser }: Props) {
);
}
export function AccountSettings({ reqUser }: { reqUser: MONGO_UserDocument }) {
export function AccountSettings({ reqUser }: { reqUser: UserDocument }) {
const [page, setPage] = useState<"email" | "password" | "username">("email");
return (
@@ -129,7 +125,7 @@ export function AccountSettings({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ChangeEmailForm({ reqUser }: { reqUser: UserDocument }) {
const formikEmail = useFormik({
initialValues: {
"!password": "",
@@ -137,7 +133,7 @@ function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
confEmail: "",
},
onSubmit: async (values) => {
const r = await APIFetchV1<MONGO_UserSettingsDocument>(
const r = await APIFetchV1<UserSettingsDocument>(
`/users/${reqUser.id}/change-email`,
{
method: "POST",
@@ -217,7 +213,7 @@ function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ChangePasswordForm({ reqUser }: { reqUser: UserDocument }) {
const formikPassword = useFormik({
initialValues: {
"!oldPassword": "",
@@ -225,7 +221,7 @@ function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
confPass: "",
},
onSubmit: async (values) => {
const r = await APIFetchV1<MONGO_UserSettingsDocument>(
const r = await APIFetchV1<UserSettingsDocument>(
`/users/${reqUser.id}/change-password`,
{
method: "POST",
@@ -305,7 +301,7 @@ function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ChangeUsernameForm({ reqUser }: { reqUser: UserDocument }) {
const lastUsernameChange = useApiQuery<
{ canChange: false; nextAvailableChange: integer | null } | { canChange: true }
>(`/users/${reqUser.id}/last-username-change`);
@@ -367,7 +363,7 @@ function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
}
const handle = window.setTimeout(async () => {
const usernameQuery = await APIFetchV1<MONGO_UserDocument | null>(
const usernameQuery = await APIFetchV1<UserDocument | null>(
`/users/${nameChangeFormik.values.newUsername}`,
);
@@ -447,7 +443,7 @@ function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function PreferencesForm({ reqUser }: { reqUser: UserDocument }) {
const { settings, setSettings } = useContext(UserSettingsContext);
const theme = getStoredTheme() || "system";
const [themeSetting, setThemeSetting] = useState<"system" | Themes>(theme);
@@ -461,7 +457,7 @@ function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
deletableScores: settings?.preferences.deletableScores ?? false,
},
onSubmit: async (values) => {
const res = await APIFetchV1<MONGO_UserSettingsDocument>(
const res = await APIFetchV1<UserSettingsDocument>(
`/users/${reqUser.id}/settings`,
{
method: "PATCH",
@@ -535,7 +531,7 @@ function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function ImageForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ImageForm({ reqUser }: { reqUser: UserDocument }) {
const [pfp, setPfp] = useState<File | undefined>();
const pfpInput = useRef<HTMLInputElement>(null);
@@ -611,7 +607,7 @@ function ImageForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function SocialMediaForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function SocialMediaForm({ reqUser }: { reqUser: UserDocument }) {
const placeholders = {
discord: "Discord Username",
twitter: "Twitter Handle",
@@ -638,7 +634,7 @@ function SocialMediaForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
valuesClone[vx] = values[vx] || null;
}
const rj = await APIFetchV1<MONGO_UserDocument>(
const rj = await APIFetchV1<UserDocument>(
"/users/me",
{
method: "PATCH",
@@ -716,7 +712,7 @@ function FileUploadController({
reset,
}: {
file?: File;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
reset: () => void;
setFile: SetState<File | undefined>;
type: "banner" | "pfp";
@@ -17,14 +17,15 @@ import { useQuery } from "react-query";
import {
type Classes,
COLOUR_SET,
FormatGameGroup,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
type GPTString,
type integer,
type MONGO_UserDocument,
type MONGO_UserGameStats,
type ProfileRatingAlgorithms,
type UserDocument,
type UserGameStats,
type V3Game,
} from "tachi-common";
interface LeaderboardsData {
@@ -32,18 +33,23 @@ interface LeaderboardsData {
leaderboard: GPTLeaderboard;
}
export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function LeaderboardsPage({ reqUser, game }: UGPT) {
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Leaderboard"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Leaderboard`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Leaderboard",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Leaderboard`,
);
const defaultRating = useProfileRatingAlg(game, playtype);
const defaultRating = useProfileRatingAlg(game);
const [alg, setAlg] = useState(defaultRating);
const url = `/users/${reqUser.id}/games/${game}/${playtype}/leaderboard-adjacent?alg=${alg}`;
const url = `/users/${reqUser.id}/games/${game}/leaderboard-adjacent?alg=${alg}`;
const { data, error } = useQuery<LeaderboardsData, UnsuccessfulAPIFetchResponse>(
url,
@@ -55,7 +61,7 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
}
const lRes = await APIFetchV1<GPTLeaderboard>(
`/games/${game}/${playtype}/leaderboard?limit=3&alg=${alg}`,
`/games/${game}/leaderboard?limit=3&alg=${alg}`,
);
if (!lRes.success) {
@@ -71,7 +77,7 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
return (
<LoadingWrapper {...{ dataset: data, error }}>
<LeaderboardsPageContent {...{ reqUser, game, playtype, data: data!, alg, setAlg }} />
<LeaderboardsPageContent {...{ reqUser, game, data: data!, alg, setAlg }} />
</LoadingWrapper>
);
}
@@ -79,20 +85,19 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
function LeaderboardsPageContent({
reqUser,
game,
playtype,
data,
alg,
}: {
alg: ProfileRatingAlgorithms[GPTString];
alg: ProfileRatingAlgorithms[V3Game];
data: LeaderboardsData;
reqUser: MONGO_UserDocument;
setAlg: SetState<ProfileRatingAlgorithms[GPTString]>;
reqUser: UserDocument;
setAlg: SetState<ProfileRatingAlgorithms[V3Game]>;
} & GamePT) {
const { stats, leaderboard } = data;
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const userMap = new Map<integer, MONGO_UserDocument>();
const userMap = new Map<integer, UserDocument>();
for (const u of stats.users) {
userMap.set(u.id, u);
@@ -102,12 +107,11 @@ function LeaderboardsPageContent({
userMap.set(u.id, u);
}
// hack - we aren't returned from this api call for some reason.
userMap.set(reqUser.id, reqUser);
const bestNearbyUser = stats.thisUsersRanking.ranking - stats.above.length - 1;
function LeaderboardRow({ s, i }: { i: integer; s: MONGO_UserGameStats }) {
function LeaderboardRow({ s, i }: { i: integer; s: UserGameStats }) {
return (
<tr
style={{
@@ -125,18 +129,15 @@ function LeaderboardsPageContent({
)}
</td>
<td>
<GentleLink
to={`/u/${userMap.get(s.userID)!.username}/games/${game}/${playtype}`}
>
<GentleLink to={`/u/${userMap.get(s.userID)!.username}/games/${game}`}>
{userMap.get(s.userID)?.username}
</GentleLink>
</td>
<td>
{IsNotNullish(s.ratings[alg])
? FormatGPTProfileRating(game, playtype, alg, s.ratings[alg]!)
? FormatGPTProfileRating(game, alg, s.ratings[alg]!)
: "No Data."}
</td>
{/* temp */}
<td>
{Object.entries(s.classes).length
? Object.entries(s.classes)
@@ -145,11 +146,10 @@ function LeaderboardsPageContent({
([k, v]) =>
v && (
<ClassBadge
classSet={k as Classes[GPTString]}
classSet={k as Classes[V3Game]}
classValue={v}
game={game}
key={`${k}:${v}`}
playtype={playtype}
/>
),
)
@@ -163,7 +163,7 @@ function LeaderboardsPageContent({
<Card
cardBodyClassName="overflow-x-auto d-flex flex-column justify-content-center p-4"
footer={
<LinkButton className="float-end" to={`/games/${game}/${playtype}/leaderboards`}>
<LinkButton className="float-end" to={`/games/${game}/leaderboards`}>
View Global Leaderboards
</LinkButton>
}
@@ -171,12 +171,7 @@ function LeaderboardsPageContent({
>
<MiniTable
className="text-center"
headers={[
"Position",
"User",
FormatGPTProfileRatingName(game, playtype, alg),
"Classes",
]}
headers={["Position", "User", FormatGPTProfileRatingName(game, alg), "Classes"]}
>
<>
{bestNearbyUser >= 1 &&
@@ -20,50 +20,39 @@ import { FormatDate, MillisToSince } from "#util/time";
import { DateTime } from "luxon";
import React, { useMemo, useState } from "react";
import FormSelect from "react-bootstrap/FormSelect";
import {
FormatGameGroup,
type GameGroup,
GetGameGroupConfig,
GetGamePTConfig,
type MONGO_UserGameStats,
type Playtype,
} from "tachi-common";
import { FormatGame, GetGameConfig, type UserGameStats, type V3Game } from "tachi-common";
export default function OverviewPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function OverviewPage({ reqUser, game }: UGPT) {
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Overview`,
["Users", reqUser.username, "Games", FormatGame(game)],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Overview`,
);
return (
<React.Fragment key={`${game}:${playtype}`}>
<UGPTStatShowcase game={game} playtype={playtype} reqUser={reqUser} />
<RankingInfo game={game} playtype={playtype} reqUser={reqUser} />
<RecentActivity game={game} playtype={playtype} reqUser={reqUser} />
<React.Fragment key={game}>
<UGPTStatShowcase game={game} reqUser={reqUser} />
<RankingInfo game={game} reqUser={reqUser} />
<RecentActivity game={game} reqUser={reqUser} />
</React.Fragment>
);
}
function RecentActivity({ reqUser, game, playtype }: UGPT) {
function RecentActivity({ reqUser, game }: UGPT) {
return (
<div className="mt-4">
<Activity
handleNoActivity={null}
url={`/users/${reqUser.id}/games/${game}/${playtype}/activity`}
/>
<Activity handleNoActivity={null} url={`/users/${reqUser.id}/games/${game}/activity`} />
</div>
);
}
type RankingDurations = "3mo" | "year";
type RankingDurations = "3mo" | "all" | "month" | "week" | "year";
function RankingInfo({ reqUser, game, playtype }: UGPT) {
function RankingInfo({ reqUser, game }: UGPT) {
const [duration, setDuration] = useState<RankingDurations>("3mo");
const { data, error } = useApiQuery<UGPTHistory>(
`/users/${reqUser.id}/games/${game}/${playtype}/history?duration=${duration}`,
`/users/${reqUser.id}/games/${game}/history?duration=${duration}`,
);
return (
@@ -75,7 +64,6 @@ function RankingInfo({ reqUser, game, playtype }: UGPT) {
data={data}
duration={duration}
game={game}
playtype={playtype}
setDuration={setDuration}
/>
) : (
@@ -88,7 +76,6 @@ function RankingInfo({ reqUser, game, playtype }: UGPT) {
function UserHistory({
data,
game,
playtype,
duration,
setDuration,
}: {
@@ -96,23 +83,23 @@ function UserHistory({
duration: RankingDurations;
setDuration: SetState<RankingDurations>;
} & GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const [mode, setMode] = useState<"playcount" | "ranking" | "rating">("rating");
const preferredRating = useProfileRatingAlg(game, playtype);
const preferredRating = useProfileRatingAlg(game);
const [rating, setRating] = useState<keyof MONGO_UserGameStats["ratings"]>(preferredRating);
const [rating, setRating] = useState<keyof UserGameStats["ratings"]>(preferredRating);
const propName = useMemo(() => {
if (mode === "rating" && rating) {
return FormatGPTProfileRatingName(game, playtype, rating);
return FormatGPTProfileRatingName(game, rating);
} else if (mode === "ranking") {
return `${FormatGPTProfileRatingName(game, playtype, rating)} Ranking`;
return `${FormatGPTProfileRatingName(game, rating)} Ranking`;
}
return UppercaseFirst(mode);
}, [mode, rating, game, playtype]);
}, [mode, rating, game]);
const currentPropValue = useMemo(() => {
if (mode === "rating" && rating) {
@@ -122,7 +109,7 @@ function UserHistory({
return "N/A";
}
return FormatGPTProfileRating(game, playtype, rating, ratingValue);
return FormatGPTProfileRating(game, rating, ratingValue);
} else if (mode === "ranking") {
return (
<>
@@ -133,7 +120,7 @@ function UserHistory({
}
return data[0].playcount;
}, [mode, rating, data, game, playtype]);
}, [mode, rating, data, game]);
return (
<>
@@ -144,6 +131,7 @@ function UserHistory({
<option value="month">Past Month</option>
<option value="3mo">Past 3 Months</option>
<option value="year">Past Year</option>
<option value="all">All Time</option>
</Select>
</div>
<div className="col-12 col-md-6 align-self-center">
@@ -170,19 +158,17 @@ function UserHistory({
<Divider className="mt-6 mb-2" />
{mode === "ranking" ? (
<>
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
<div className="col-12 offset-md-4 col-md-4 mt-4">
<FormSelect
onChange={(e) =>
setRating(
e.target.value as keyof MONGO_UserGameStats["ratings"],
)
setRating(e.target.value as keyof UserGameStats["ratings"])
}
value={rating}
>
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTProfileRatingName(game, playtype, e)}
{FormatGPTProfileRatingName(game, e)}
</option>
))}
</FormSelect>
@@ -225,26 +211,24 @@ function UserHistory({
/>
) : (
<>
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
<div className="col-12 offset-md-4 col-md-4 mt-4">
<FormSelect
onChange={(e) =>
setRating(
e.target.value as keyof MONGO_UserGameStats["ratings"],
)
setRating(e.target.value as keyof UserGameStats["ratings"])
}
value={rating}
>
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTProfileRatingName(game, playtype, e)}
{FormatGPTProfileRatingName(game, e)}
</option>
))}
</FormSelect>
</div>
)}
<RatingTimeline {...{ game, playtype, data, rating }} />
<RatingTimeline {...{ game, data, rating }} />
</>
)}
</>
@@ -253,14 +237,12 @@ function UserHistory({
function RatingTimeline({
game,
playtype,
data,
rating,
}: {
data: UGPTHistory;
game: GameGroup;
playtype: Playtype;
rating: keyof MONGO_UserGameStats["ratings"];
game: V3Game;
rating: keyof UserGameStats["ratings"];
}) {
const ratingDataset = [
{ id: rating, data: data.map((e) => ({ x: e.timestamp, y: e.ratings[rating] })) },
@@ -270,13 +252,13 @@ function RatingTimeline({
<TimelineChart
axisBottom={{
format: (x) => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
tickValues: 3, // temp
tickValues: 3,
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
format: (y) => (y ? FormatGPTProfileRating(game, playtype, rating, y) : "N/A"),
format: (y) => (y ? FormatGPTProfileRating(game, rating, y) : "N/A"),
}}
data={ratingDataset}
height="30rem"
@@ -285,14 +267,9 @@ function RatingTimeline({
<ChartTooltip>
<div>
{p.point.data.y
? FormatGPTProfileRating(
game,
playtype,
rating,
p.point.data.y as number,
)
? FormatGPTProfileRating(game, rating, p.point.data.y as number)
: "N/A"}{" "}
{FormatGPTProfileRatingName(game, playtype, rating)}
{FormatGPTProfileRatingName(game, rating)}
</div>
<small className="text-body-secondary">
{MillisToSince(+p.point.data.xFormatted)}
@@ -308,13 +285,13 @@ function RankingTimeline({
rating,
}: {
data: UGPTHistory;
rating: keyof MONGO_UserGameStats["ratings"];
rating: keyof UserGameStats["ratings"];
}) {
return (
<TimelineChart
axisBottom={{
format: (x) => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
tickValues: 3, // temp
tickValues: 3,
}}
axisLeft={{
tickSize: 5,
@@ -15,41 +15,45 @@ import React, { useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { Route, Switch } from "react-router-dom";
import {
FormatGameGroup,
type GameGroup,
type ChartDocument,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
type GPTString,
type MONGO_ChartDocument,
type MONGO_PBScoreDocument,
type MONGO_ScoreDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
type PBScoreDocument,
type ScoreDocument,
type ScoreRatingAlgorithms,
type SongDocument,
type UnsuccessfulAPIResponse,
type UserDocument,
type V3Game,
} from "tachi-common";
export default function ScoresPage({
reqUser,
game,
playtype,
}: {
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
} & GamePT) {
const gameConfig = GetGameGroupConfig(game);
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const defaultRating = useScoreRatingAlg(game, playtype);
const defaultRating = useScoreRatingAlg(game);
const [alg, setAlg] = useState(defaultRating);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Scores"],
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Scores",
],
[reqUser],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Scores`,
`${reqUser.username}'s ${FormatGame(game)} Scores`,
);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
return (
<Row xs={{ cols: 1 }}>
@@ -71,39 +75,37 @@ export default function ScoresPage({
</Col>
<Col className="d-flex flex-column gap-4">
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/scores">
<Route exact path="/u/:userID/games/:game/scores">
<>
{Object.keys(gptConfig.scoreRatingAlgs).length > 1 && (
<AlgSelector {...{ alg, setAlg, game, playtype }} />
{Object.keys(gameConfig.scoreRatingAlgs).length > 1 && (
<AlgSelector {...{ alg, setAlg, game }} />
)}
<PBsOverview
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=${alg}`}
{...{ reqUser, game, playtype, alg }}
url={`/users/${reqUser.id}/games/${game}/pbs/best?alg=${alg}`}
{...{ reqUser, game, alg }}
/>
</>
</Route>
<Route path="/u/:userID/games/:game/:playtype/scores/history">
<ScoresOverview {...{ reqUser, game, playtype }} />
<Route path="/u/:userID/games/:game/scores/history">
<ScoresOverview {...{ reqUser, game }} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/scores/all">
<Route path="/u/:userID/games/:game/scores/all">
<PBsOverview
game={game}
indexCol={false}
key="all-pbs"
playtype={playtype}
reqUser={reqUser}
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/all`}
url={`/users/${reqUser.id}/games/${game}/pbs/all`}
/>
</Route>
<Route path="/u/:userID/games/:game/:playtype/scores/most-played">
<Route path="/u/:userID/games/:game/scores/most-played">
<PBsOverview
game={game}
indexCol
key="most-played-pbs"
playtype={playtype}
reqUser={reqUser}
showPlaycount
url={`/users/${reqUser.id}/games/${game}/${playtype}/most-played`}
url={`/users/${reqUser.id}/games/${game}/most-played`}
/>
</Route>
</Switch>
@@ -114,21 +116,20 @@ export default function ScoresPage({
function AlgSelector({
game,
playtype,
alg,
setAlg,
}: {
alg: ScoreRatingAlgorithms[GPTString];
setAlg: SetState<ScoreRatingAlgorithms[GPTString]>;
alg: ScoreRatingAlgorithms[V3Game];
setAlg: SetState<ScoreRatingAlgorithms[V3Game]>;
} & GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
return (
<Form.Group className="d-flex flex-column gap-1">
<div>Best 100 PBs according to</div>
<Form.Select onChange={(e) => setAlg(e.target.value as any)} value={alg}>
{Object.keys(gptConfig.scoreRatingAlgs).map((e) => (
{Object.keys(gameConfig.scoreRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTScoreRatingName(game, playtype, e)}
{FormatGPTScoreRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -136,11 +137,11 @@ function AlgSelector({
);
}
function useFetchPBs(url: string, reqUser: MONGO_UserDocument) {
function useFetchPBs(url: string, reqUser: UserDocument) {
const { data, error } = useApiQuery<{
charts: MONGO_ChartDocument[];
pbs: MONGO_PBScoreDocument[];
songs: MONGO_SongDocument[];
charts: ChartDocument[];
pbs: PBScoreDocument[];
songs: SongDocument[];
}>(url);
return {
@@ -152,15 +153,14 @@ function useFetchPBs(url: string, reqUser: MONGO_UserDocument) {
function PBsOverview({
reqUser,
game,
playtype,
indexCol = true,
showPlaycount = false,
url,
alg,
}: {
alg?: ScoreRatingAlgorithms[GPTString];
alg?: ScoreRatingAlgorithms[V3Game];
indexCol?: boolean;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
showPlaycount?: boolean;
url: string;
} & GamePT) {
@@ -184,27 +184,22 @@ function PBsOverview({
defaultRankingViewMode={preferredRanking}
game={game}
indexCol={indexCol}
playtype={playtype}
showPlaycount={showPlaycount}
/>
</LoadingWrapper>
) : (
<PBsSearch {...{ reqUser, game, playtype, search }} />
<PBsSearch {...{ reqUser, game, search }} />
)}
</div>
</div>
);
}
function FormatData<
D extends MONGO_PBScoreDocument | MONGO_ScoreDocument,
GPT extends GPTString = GPTString,
G extends GameGroup = GameGroup,
>(
function FormatData<D extends PBScoreDocument | ScoreDocument>(
d: D[],
songs: MONGO_SongDocument<G>[],
charts: MONGO_ChartDocument<GPT>[],
reqUser: MONGO_UserDocument,
songs: SongDocument[],
charts: ChartDocument[],
reqUser: UserDocument,
) {
const songMap = new Map();
const chartMap = new Map();
@@ -230,11 +225,11 @@ function FormatData<
return data;
}
function useFetchScores(url: string, reqUser: MONGO_UserDocument) {
function useFetchScores(url: string, reqUser: UserDocument) {
const { data, error } = useApiQuery<{
charts: MONGO_ChartDocument[];
scores: MONGO_ScoreDocument[];
songs: MONGO_SongDocument[];
charts: ChartDocument[];
scores: ScoreDocument[];
songs: SongDocument[];
}>(url);
return {
@@ -246,37 +241,30 @@ function useFetchScores(url: string, reqUser: MONGO_UserDocument) {
function PBsSearch({
reqUser,
game,
playtype,
search,
alg,
}: {
alg?: ScoreRatingAlgorithms[GPTString];
reqUser: MONGO_UserDocument;
alg?: ScoreRatingAlgorithms[V3Game];
reqUser: UserDocument;
search: string;
} & GamePT) {
const { data, error } = useFetchPBs(
`/users/${reqUser.id}/games/${game}/${playtype}/pbs?search=${search}`,
`/users/${reqUser.id}/games/${game}/pbs?search=${search}`,
reqUser,
);
return (
<LoadingWrapper style={{ height: 500 }} {...{ error, dataset: data }}>
<PBTable
alg={alg}
dataset={data!}
game={game}
indexCol={false}
playtype={playtype as "DP" | "SP"}
/>
<PBTable alg={alg} dataset={data!} game={game} indexCol={false} />
</LoadingWrapper>
);
}
function ScoresOverview({ reqUser, game, playtype }: UGPT) {
function ScoresOverview({ reqUser, game }: UGPT) {
const [search, setSearch] = useState("");
const { data, error } = useFetchScores(
`/users/${reqUser.id}/games/${game}/${playtype}/scores/recent`,
`/users/${reqUser.id}/games/${game}/scores/recent`,
reqUser,
);
@@ -292,10 +280,10 @@ function ScoresOverview({ reqUser, game, playtype }: UGPT) {
<div className="col-12 mt-4">
{search === "" ? (
<LoadingWrapper style={{ height: 500 }} {...{ dataset: data, error }}>
<ScoreTable dataset={data!} game={game} playtype={playtype as any} />
<ScoreTable dataset={data!} game={game} />
</LoadingWrapper>
) : (
<ScoresSearch {...{ reqUser, game, playtype, search }} />
<ScoresSearch {...{ reqUser, game, search }} />
)}
</div>
</div>
@@ -305,17 +293,16 @@ function ScoresOverview({ reqUser, game, playtype }: UGPT) {
function ScoresSearch({
reqUser,
game,
playtype,
search,
}: { reqUser: MONGO_UserDocument; search: string } & GamePT) {
}: { reqUser: UserDocument; search: string } & GamePT) {
const { data, error } = useFetchScores(
`/users/${reqUser.id}/games/${game}/${playtype}/scores?search=${search}`,
`/users/${reqUser.id}/games/${game}/scores?search=${search}`,
reqUser,
);
return (
<LoadingWrapper style={{ height: 500 }} {...{ error, dataset: data }}>
<ScoreTable dataset={data!} game={game} playtype={playtype as any} />
<ScoreTable dataset={data!} game={game} />
</LoadingWrapper>
);
}
@@ -15,35 +15,40 @@ import { NumericSOV } from "#util/sorts";
import React, { useState } from "react";
import { useQuery } from "react-query";
import {
FormatGameGroup,
FormatGame,
GameToGameGroup,
GetGameGroupConfig,
type MONGO_SessionDocument,
type MONGO_UserDocument,
type SessionDocument,
type SessionScoreInfo,
type UnsuccessfulAPIResponse,
type UserDocument,
} from "tachi-common";
export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
export default function SessionsPage({ reqUser, game }: UGPT) {
const [sessionSet, setSessionSet] = useState<"best" | "highlighted" | "recent">("best");
const [search, setSearch] = useState("");
const gameConfig = GetGameGroupConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Sessions"],
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Sessions",
],
[reqUser],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Sessions`,
`${reqUser.username}'s ${FormatGame(game)} Sessions`,
);
const baseUrl = `/users/${reqUser.id}/games/${game}/${playtype}/sessions`;
const baseUrl = `/users/${reqUser.id}/games/${game}/sessions`;
const rating = useSessionRatingAlg(game, playtype);
const rating = useSessionRatingAlg(game);
const { data, error } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
`${baseUrl}/${sessionSet}`,
async () => {
const res = await APIFetchV1<
({ __scoreInfo: Array<SessionScoreInfo> } & MONGO_SessionDocument)[]
({ __scoreInfo: Array<SessionScoreInfo> } & SessionDocument)[]
>(`${baseUrl}/${sessionSet}`);
if (!res.success) {
@@ -70,7 +75,7 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
<div className="row">
<div className="col-12">
<SessionCalendar
url={`/users/${reqUser.id}/games/${game}/${playtype}/sessions/calendar`}
url={`/users/${reqUser.id}/games/${game}/sessions/calendar`}
user={reqUser}
/>
<Divider />
@@ -113,12 +118,11 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
dataset={data!}
game={game}
indexCol={sessionSet === "best"}
playtype={playtype}
reqUser={reqUser}
/>
</LoadingWrapper>
) : (
<SearchSessionsTable {...{ game, playtype, reqUser, baseUrl, search }} />
<SearchSessionsTable {...{ game, reqUser, baseUrl, search }} />
)}
</div>
</div>
@@ -128,14 +132,13 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
function SearchSessionsTable({
search,
game,
playtype,
reqUser,
baseUrl,
}: { baseUrl: string; reqUser: MONGO_UserDocument; search: string } & GamePT) {
}: { baseUrl: string; reqUser: UserDocument; search: string } & GamePT) {
const { data, error } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
`${baseUrl}?search=${search}`,
async () => {
const res = await APIFetchV1<MONGO_SessionDocument[]>(`${baseUrl}?search=${search}`);
const res = await APIFetchV1<SessionDocument[]>(`${baseUrl}?search=${search}`);
if (!res.success) {
throw res;
@@ -152,12 +155,7 @@ function SearchSessionsTable({
return (
<LoadingWrapper {...{ error, dataset: data }}>
<GenericSessionTable
dataset={data!}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<GenericSessionTable dataset={data!} game={game} reqUser={reqUser} />
</LoadingWrapper>
);
}
@@ -17,9 +17,9 @@ import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "#util/data";
import React, { useContext, useMemo, useState } from "react";
import { Badge, Button, Col, Row } from "react-bootstrap";
import { Redirect, useParams } from "react-router-dom";
import { GetGameGroupConfig } from "tachi-common";
import { GameToGameGroup, GetGameGroupConfig } from "tachi-common";
export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) {
export default function SpecificSessionPage({ reqUser, game }: UGPT) {
const { sessionID } = useParams<{ sessionID: string }>();
const { data, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
@@ -32,22 +32,20 @@ export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) {
return <Loading />;
}
if (
data.user.id !== reqUser.id ||
game !== data.session.game ||
playtype !== data.session.playtype
) {
const sessionV3Game = data.session.game;
if (data.user.id !== reqUser.id || game !== sessionV3Game) {
return (
<Redirect
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
to={`/u/${data.user.username}/games/${sessionV3Game}/sessions/${sessionID}`}
/>
);
}
return <SessionPage {...{ data, game, playtype, reqUser }} />;
return <SessionPage {...{ data, game, reqUser }} />;
}
function SessionPage({ data, game, playtype }: { data: SessionReturns } & UGPT) {
function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
const { settings } = useContext(UserSettingsContext);
const [sessionData, setSessionData] = useState(data);
@@ -60,12 +58,11 @@ function SessionPage({ data, game, playtype }: { data: SessionReturns } & UGPT)
"Users",
user.username,
"Games",
GetGameGroupConfig(game).name,
playtype,
GetGameGroupConfig(GameToGameGroup(game)).name,
"Sessions",
session.name,
],
[session.name, game, playtype, user],
[session.name, game, user],
`${user.username}: ${session.name}`,
);
@@ -30,31 +30,38 @@ import { Alert, Button, Col, Form, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
BMS_TABLES,
FormatGameGroup,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
GetScoreMetrics,
type MONGO_TableDocument,
type MONGO_UGPTSettingsDocument,
type MONGO_UserDocument,
type ShowcaseStatDetails,
type TableDocument,
type UGPTSettingsDocument,
type UserDocument,
} from "tachi-common";
export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
export default function UGPTSettingsPage({ reqUser, game }: UGPT) {
const query = useQueryString();
const [page, setPage] = useState<"manage" | "preferences" | "showcase">(
query.get("showcase") ? "showcase" : "preferences",
);
const gameConfig = GetGameGroupConfig(game);
const gameConfig = GetGameConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Settings"],
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Settings",
],
[reqUser],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Settings`,
`${reqUser.username}'s ${FormatGame(game)} Settings`,
);
const UGPT = { reqUser, game, playtype };
const UGPT = { reqUser, game };
const { loggedInData } = useContext(UGPTContext);
if (!loggedInData) {
@@ -139,33 +146,32 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
function PreferencesForm({
reqUser,
game,
playtype,
loggedInData,
}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
}: { loggedInData: { settings: UGPTSettingsDocument } & UGPTData } & UGPT) {
const { setLoggedInData } = useContext(UGPTContext);
const settings = loggedInData.settings;
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const formik = useFormik({
initialValues: {
preferredScoreAlg:
settings.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg,
settings.preferences.preferredScoreAlg || gameConfig.defaultScoreRatingAlg,
preferredProfileAlg:
settings.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg,
settings.preferences.preferredProfileAlg || gameConfig.defaultProfileRatingAlg,
preferredSessionAlg:
settings.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
settings.preferences.preferredSessionAlg || gameConfig.defaultSessionRatingAlg,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gameSpecific: settings.preferences.gameSpecific as any,
defaultTable: settings.preferences.defaultTable,
preferredDefaultEnum:
settings.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum,
settings.preferences.preferredDefaultEnum ?? gameConfig.preferredDefaultEnum,
preferredRanking: settings.preferences.preferredRanking ?? "global",
},
onSubmit: async (values) => {
const rj = await APIFetchV1<MONGO_UserDocument>(
`/users/${reqUser.id}/games/${game}/${playtype}/settings`,
const rj = await APIFetchV1<UserDocument>(
`/users/${reqUser.id}/games/${game}/settings`,
{
method: "PATCH",
body: JSON.stringify(values),
@@ -180,7 +186,7 @@ function PreferencesForm({
if (rj.success) {
setLoggedInData({
...loggedInData,
settings: deepmerge(settings as MONGO_UGPTSettingsDocument, {
settings: deepmerge(settings as UGPTSettingsDocument, {
preferences: values,
}),
});
@@ -188,8 +194,8 @@ function PreferencesForm({
},
});
const { data: tables, error } = useApiQuery<MONGO_TableDocument[]>(
`/games/${game}/${playtype}/tables?showInactive=true`,
const { data: tables, error } = useApiQuery<TableDocument[]>(
`/games/${game}/tables?showInactive=true`,
);
if (error) {
@@ -208,7 +214,7 @@ function PreferencesForm({
return (
<Form className="d-flex flex-column gap-4" onSubmit={formik.handleSubmit}>
{Object.keys(gptConfig.scoreRatingAlgs).length > 1 && (
{Object.keys(gameConfig.scoreRatingAlgs).length > 1 && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Score Algorithm</Form.Label>
<Form.Select
@@ -216,9 +222,9 @@ function PreferencesForm({
onChange={formik.handleChange}
value={formik.values.preferredScoreAlg}
>
{Object.keys(gptConfig.scoreRatingAlgs).map((e) => (
{Object.keys(gameConfig.scoreRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTScoreRatingName(game, playtype, e)}
{FormatGPTScoreRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -228,7 +234,7 @@ function PreferencesForm({
</Form.Text>
</Form.Group>
)}
{Object.keys(gptConfig.sessionRatingAlgs).length > 1 && (
{Object.keys(gameConfig.sessionRatingAlgs).length > 1 && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Session Algorithm</Form.Label>
<Form.Select
@@ -236,9 +242,9 @@ function PreferencesForm({
onChange={formik.handleChange}
value={formik.values.preferredSessionAlg}
>
{Object.keys(gptConfig.sessionRatingAlgs).map((e) => (
{Object.keys(gameConfig.sessionRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTSessionRatingName(game, playtype, e)}
{FormatGPTSessionRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -248,7 +254,7 @@ function PreferencesForm({
</Form.Text>
</Form.Group>
)}
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Profile Algorithm</Form.Label>
<Form.Select
@@ -256,9 +262,9 @@ function PreferencesForm({
onChange={formik.handleChange}
value={formik.values.preferredProfileAlg}
>
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTProfileRatingName(game, playtype, e)}
{FormatGPTProfileRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -275,7 +281,7 @@ function PreferencesForm({
onChange={formik.handleChange}
value={formik.values.preferredDefaultEnum}
>
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
@@ -322,7 +328,7 @@ function PreferencesForm({
What folders would you like to see by default?
</Form.Text>
</Form.Group>
{game === "iidx" && (
{(game === "iidx-sp" || game === "iidx-dp") && (
<>
<Form.Group className={formGroupClassNames}>
<Form.Check
@@ -356,7 +362,7 @@ function PreferencesForm({
</Form.Group>
</>
)}
{(game === "sdvx" || game === "usc") && (
{(game === "sdvx" || game === "usc-controller" || game === "usc-keyboard") && (
<Form.Group className={formGroupClassNames}>
<Form.Label>VF6 Target</Form.Label>
<Form.Control
@@ -379,11 +385,11 @@ function PreferencesForm({
</Form.Text>
</Form.Group>
)}
{game === "bms" && (
{(game === "bms-7k" || game === "bms-14k") && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Tables</Form.Label>
{BMS_TABLES.filter((e) => e.playtype === playtype).map((e) => (
{BMS_TABLES.filter((e) => e.game === game).map((e) => (
<Form.Check
checked={
formik.values.gameSpecific.displayTables?.includes(e.prefix) ??
@@ -394,9 +400,9 @@ function PreferencesForm({
onChange={(event) => {
const base: Array<string> =
formik.values.gameSpecific.displayTables ??
BMS_TABLES.filter(
(e) => e.playtype === playtype && !e.notDefault,
).map((e) => e.prefix);
BMS_TABLES.filter((e) => e.game === game && !e.notDefault).map(
(e) => e.prefix,
);
if (event.target.checked) {
formik.setFieldValue("gameSpecific.displayTables", [
@@ -429,9 +435,8 @@ function PreferencesForm({
function ShowcaseForm({
reqUser,
game,
playtype,
loggedInData,
}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
}: { loggedInData: { settings: UGPTSettingsDocument } & UGPTData } & UGPT) {
const { setLoggedInData } = useContext(UGPTContext);
const settings = loggedInData.settings;
@@ -440,11 +445,11 @@ function ShowcaseForm({
const [show, setShow] = useState(false);
const SaveChanges = async () => {
const r = await APIFetchV1<MONGO_UGPTSettingsDocument>(
`/users/${reqUser.id}/games/${game}/${playtype}/showcase`,
const r = await APIFetchV1<UGPTSettingsDocument>(
`/users/${reqUser.id}/games/${game}/showcase`,
{
method: "PUT",
body: JSON.stringify(stats),
body: JSON.stringify({ showcase: stats }),
headers: { "Content-Type": "application/json" },
},
true,
@@ -478,13 +483,12 @@ function ShowcaseForm({
</Button>
</div>
)}
<RenderCurrentStats {...{ reqUser, game, playtype, stats, setStats }} />
<RenderCurrentStats {...{ reqUser, game, stats, setStats }} />
<UGPTStatCreator
game={game}
onCreate={(stat) => {
setStats([...stats, stat]);
}}
playtype={playtype}
reqUser={reqUser}
setShow={setShow}
show={show}
@@ -498,7 +502,6 @@ function RenderCurrentStats({
setStats,
reqUser,
game,
playtype,
}: {
setStats: SetState<ShowcaseStatDetails[]>;
stats: ShowcaseStatDetails[];
@@ -519,7 +522,7 @@ function RenderCurrentStats({
<Row className="w-100 row-gap-4" lg={{ cols: 2 }}>
{stats.map((e, i) => (
<Col className="d-flex flex-column gap-4" key={i}>
<UGPTStatContainer game={game} playtype={playtype} reqUser={reqUser} stat={e} />
<UGPTStatContainer game={game} reqUser={reqUser} stat={e} />
<Button className="w-100" onClick={() => RemoveStatAtIndex(i)} variant="danger">
Delete
</Button>
@@ -529,7 +532,7 @@ function RenderCurrentStats({
);
}
function ManageAccount({ reqUser, game, playtype }: UGPT) {
function ManageAccount({ reqUser, game }: UGPT) {
const [password, setPassword] = useState("");
const [deleting, setDeleting] = useState(false);
@@ -549,13 +552,12 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
<Col className="mt-8" xs={12}>
<h3>Completely Wipe Profile</h3>
If you've <i>really</i> messed up, you can wipe your entire profile for{" "}
{FormatGameGroup(game, playtype)}.
{FormatGame(game)}.
<br />
<Alert className="mt-4" style={{ fontSize: "1.5rem" }} variant="warning">
It is very important to know that this is <b>NOT REVERSIBLE.</b> Wiping your
profile will <b>COMPLETELY DELETE</b> all of your{" "}
{FormatGameGroup(game, playtype)} scores from our server. We will not be able to
retrieve them.
profile will <b>COMPLETELY DELETE</b> all of your {FormatGame(game)} scores from
our server. We will not be able to retrieve them.
</Alert>
<br />
<Form.Group>
@@ -573,16 +575,13 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
onClick={async () => {
if (
confirm(
`You are really about to delete all of your ${FormatGameGroup(
game,
playtype,
)} scores. This is your last chance to turn back.`,
`You are really about to delete all of your ${FormatGame(game)} scores. This is your last chance to turn back.`,
)
) {
setDeleting(true);
const res = await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}`,
`/users/${reqUser.id}/games/${game}`,
{
method: "DELETE",
body: JSON.stringify({ "!password": password }),
@@ -597,14 +596,13 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
setDeleting(false);
if (res.success) {
// bye!
window.location.href = "/";
}
}
}}
variant="outline-danger"
>
Yes, I want to delete my {FormatGameGroup(game, playtype)} account.
Yes, I want to delete my {FormatGame(game)} account.
</Button>
{deleting && (
<>
@@ -21,28 +21,24 @@ import Form from "react-bootstrap/Form";
import Row from "react-bootstrap/Row";
import Stack from "react-bootstrap/Stack";
import { Link } from "react-router-dom";
import { type MONGO_FolderDocument, type MONGO_UserDocument } from "tachi-common";
import { type FolderDocument, type UserDocument } from "tachi-common";
export default function RivalCompareFolderPage({
reqUser,
game,
playtype,
folder,
}: {
folder: MONGO_FolderDocument;
reqUser: MONGO_UserDocument;
folder: FolderDocument;
reqUser: UserDocument;
} & GamePT) {
const { settings } = useLUGPTSettings();
const { user } = useContext(UserContext);
const [selectedUser, setSelectedUser] = useState<MONGO_UserDocument | null>(null);
const [selectedUser, setSelectedUser] = useState<UserDocument | null>(null);
const [show, setShow] = useState(false);
// honestly don't care if this errors or not
const { data, error } = useApiQuery<Array<MONGO_UserDocument>>(
`/users/${settings?.userID}/games/${game}/${playtype}/rivals`,
undefined,
// [settings?.rivals.join(",")]
const { data, error } = useApiQuery<Array<UserDocument>>(
`/users/${settings?.userID}/games/${game}/rivals`,
);
if (!data && !error) {
@@ -51,9 +47,6 @@ export default function RivalCompareFolderPage({
let suggestUsers = data ?? [];
// if the user viewing this page is logged in and *is not*
// the person this page is for
// show them in the suggestions.
if (user && user.id !== reqUser.id && data) {
suggestUsers = [...suggestUsers, user];
}
@@ -65,7 +58,7 @@ export default function RivalCompareFolderPage({
<>
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
{suggestUsers.map((e) => (
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
<UserIcon game={game} key={e.id} user={e}>
<Button
onClick={() => setSelectedUser(e)}
variant={
@@ -91,7 +84,7 @@ export default function RivalCompareFolderPage({
excludeSet={[reqUser.id]}
setShow={setShow}
show={show}
url={`/games/${game}/${playtype}/players`}
url={`/games/${game}/players`}
/>
<Button
className={suggestUsers.length === 0 ? "d-flex mx-auto" : ""}
@@ -107,7 +100,6 @@ export default function RivalCompareFolderPage({
<FolderCompare
folder={folder}
game={game}
playtype={playtype}
reqUser={reqUser}
withUser={selectedUser}
/>
@@ -120,26 +112,24 @@ export default function RivalCompareFolderPage({
function FolderCompare({
reqUser,
game,
playtype,
withUser,
folder,
}: {
folder: MONGO_FolderDocument;
reqUser: MONGO_UserDocument;
withUser: MONGO_UserDocument;
folder: FolderDocument;
reqUser: UserDocument;
withUser: UserDocument;
} & GamePT) {
const { data: baseData, error: baseError } = useApiQuery<UGPTFolderReturns>(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folder.folderID}`,
`/users/${reqUser.id}/games/${game}/folders/${folder.slug}`,
);
const { data: compareData, error: compareError } = useApiQuery<UGPTFolderReturns>(
`/users/${withUser.id}/games/${game}/${playtype}/folders/${folder.folderID}`,
`/users/${withUser.id}/games/${game}/folders/${folder.slug}`,
);
const [shouldIncludeNotPlayed, setShouldIncludeNotPlayed] = useState(false);
const dataset = useMemo(() => {
// i *LOVE* the rules of hooks! they're so convenient!
if (!baseData || !compareData) {
return [];
}
@@ -153,7 +143,7 @@ function FolderCompare({
chart,
base: basePBLookup.get(chart.chartID) ?? null,
compare: comparePBLookup.get(chart.chartID) ?? null,
song: songMap.get(chart.songID)!,
song: songMap.get(chart.song.id)!,
}));
if (!shouldIncludeNotPlayed) {
@@ -177,14 +167,9 @@ function FolderCompare({
return (
<Stack gap={4}>
<Row lg={{ cols: 2 }} xs={{ cols: 1 }}>
<UserCard game={game} playtype={playtype} user={reqUser} />
<UserCard game={game} playtype={playtype} user={withUser} />
<UserCard game={game} user={reqUser} />
<UserCard game={game} user={withUser} />
</Row>
{/* <CompareCard
dataset={dataset}
baseUsername={reqUser.username}
otherUsername={withUser.username}
/> */}
<Form.Check
checked={shouldIncludeNotPlayed}
label="Include charts without plays?"
@@ -196,16 +181,13 @@ function FolderCompare({
compareUser={withUser.username}
dataset={dataset}
game={game}
playtype={playtype}
/>
</Stack>
);
}
function UserCard({ user, game, playtype }: { user: MONGO_UserDocument } & GamePT) {
const { data, error } = useApiQuery<UGPTStatsReturn>(
`/users/${user.username}/games/${game}/${playtype}`,
);
function UserCard({ user, game }: { user: UserDocument } & GamePT) {
const { data, error } = useApiQuery<UGPTStatsReturn>(`/users/${user.username}/games/${game}`);
if (error) {
return <ApiError error={error} />;
@@ -217,7 +199,7 @@ function UserCard({ user, game, playtype }: { user: MONGO_UserDocument } & GameP
<div className="d-flex flex-column">
<Link
className="fw-bold fs-4 text-center text-lg-start"
to={`/u/${user.username}/games/${game}/${playtype}`}
to={`/u/${user.username}/games/${game}`}
>
{user.username}
</Link>
@@ -5,26 +5,25 @@ import Divider from "#components/util/Divider";
import Loading from "#components/util/Loading";
import useApiQuery from "#components/util/query/useApiQuery";
import { TargetsContext } from "#context/TargetsContext";
import { type GoalsOnChartReturn, type GoalsOnFolderReturn } from "#types/api-returns";
import { type GoalsOnFolderReturn } from "#types/api-returns";
import { type UGPT } from "#types/react";
import { CreateGoalSubDataset, CreateUserMap } from "#util/data";
import React, { useContext, useReducer, useState } from "react";
import { Button, Col } from "react-bootstrap";
import { type MONGO_FolderDocument } from "tachi-common";
import { type FolderDocument } from "tachi-common";
export default function FolderQuestsPage({
folder,
game,
playtype,
reqUser,
}: {
folder: MONGO_FolderDocument;
folder: FolderDocument;
} & UGPT) {
const [refresh, forceRefresh] = useReducer((x) => x + 1, 0);
const { reloadTargets } = useContext(TargetsContext);
const { data, error } = useApiQuery<GoalsOnChartReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/on-folder/${folder.folderID}`,
const { data, error } = useApiQuery<GoalsOnFolderReturn>(
`/users/${reqUser.id}/games/${game}/targets/on-folder/${folder.slug}`,
undefined,
[refresh],
);
@@ -40,15 +39,11 @@ export default function FolderQuestsPage({
</Col>
<Divider />
{error && <ApiError error={error} />}
{data ? (
<FolderQuestsInner {...{ reqUser, game, playtype, folder, data }} />
) : (
<Loading />
)}
{data ? <FolderQuestsInner {...{ reqUser, game, folder, data }} /> : <Loading />}
{show && (
<SetNewGoalModal
{...{ game, playtype, reqUser, show, setShow }}
{...{ game, reqUser, show, setShow }}
onNewGoalSet={() => {
forceRefresh();
reloadTargets();
@@ -63,20 +58,13 @@ export default function FolderQuestsPage({
function FolderQuestsInner({
reqUser,
game,
playtype,
folder,
data,
}: {
data: GoalsOnFolderReturn;
folder: MONGO_FolderDocument;
folder: FolderDocument;
} & UGPT) {
const userMap = CreateUserMap([reqUser]);
return (
<GoalSubInfo
dataset={CreateGoalSubDataset(data, userMap)}
game={game}
playtype={playtype}
/>
);
return <GoalSubInfo dataset={CreateGoalSubDataset(data, userMap)} game={game} />;
}
@@ -17,21 +17,20 @@ import { APIFetchV1 } from "#util/api";
import { Reverse, UppercaseFirst } from "#util/misc";
import React, { useContext, useMemo, useState } from "react";
import {
GetGamePTConfig,
GetGPTString,
type FolderDocument,
GetGameConfig,
GetScoreMetricConf,
GetScoreMetrics,
type MONGO_FolderDocument,
} from "tachi-common";
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
export default function FoldersSearch({ reqUser, game }: UGPT) {
const [search, setSearch] = useState("");
const params = useMemo(() => new URLSearchParams({ search }), [search]);
const { data, error } = useApiQuery<UGPTFolderSearch>(
`/users/${reqUser.id}/games/${game}/${playtype}/folders?${params.toString()}`,
`/users/${reqUser.id}/games/${game}/folders?${params.toString()}`,
);
let body = <></>;
@@ -44,7 +43,7 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
const statMap = new Map();
for (const stat of data.stats) {
statMap.set(stat.folderID, stat);
statMap.set(stat.slug, stat);
}
body = (
@@ -55,10 +54,9 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
{data.folders.map((e) => (
<FolderInfoComponent
folder={e}
folderStats={statMap.get(e.folderID)!}
folderStats={statMap.get(e.slug)!}
game={game}
key={e.folderID}
playtype={playtype}
key={e.slug}
reqUser={reqUser}
/>
))}
@@ -81,21 +79,20 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
export function FolderInfoComponent({
reqUser,
game,
playtype,
folderStats,
folder,
}: { folder: MONGO_FolderDocument; folderStats: FolderStatsInfo } & UGPT) {
const gptConfig = GetGamePTConfig(game, playtype);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
}: { folder: FolderDocument; folderStats: FolderStatsInfo } & UGPT) {
const gameConfig = GetGameConfig(game);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const preferredDefaultEnum = useBucket(game, playtype);
const preferredDefaultEnum = useBucket(game);
const [metric, setMetric] = useState<string>(preferredDefaultEnum);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
const dataset = useMemo(() => {
const conf = GetScoreMetricConf(gptConfig, metric) as ConfEnumScoreMetric<string>;
const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric<string>;
return (
<DistributionTable
@@ -119,12 +116,12 @@ export function FolderInfoComponent({
onClick={() => {
if (user?.id === reqUser.id) {
APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folder.folderID}/viewed`,
`/users/${reqUser.id}/games/${game}/folders/${folder.slug}/viewed`,
{ method: "POST" },
);
}
}}
to={`${base}/folders/${folder.folderID}`}
to={`${base}/folders/${folder.slug}`}
variant="outline-info"
>
View
@@ -136,7 +133,7 @@ export function FolderInfoComponent({
<div className="row text-center">
<div className="col-12">
<div className="btn-group">
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
<SelectButton id={e} key={e} setValue={setMetric} value={metric}>
{/* @ts-expect-error this access is legal zzz */}
<Icon type={gptImpl.enumIcons[e]} /> {UppercaseFirst(e)}s
@@ -2,7 +2,6 @@ import { BarChartTooltip } from "#components/charts/ChartTooltip";
import Card from "#components/layout/page/Card";
import TachiTable from "#components/tables/components/TachiTable";
import ApiError from "#components/util/ApiError";
import DebugContent from "#components/util/DebugContent";
import Divider from "#components/util/Divider";
import Icon from "#components/util/Icon";
import LinkButton from "#components/util/LinkButton";
@@ -25,29 +24,24 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
import Form from "react-bootstrap/Form";
import InputGroup from "react-bootstrap/InputGroup";
import {
type GameGroup,
type GamePTConfig,
GetGamePTConfig,
GetGPTString,
type FolderDocument,
type GameConfig,
GetGameConfig,
GetScoreMetricConf,
GetScoreMetrics,
type MONGO_FolderDocument,
type MONGO_TableDocument,
type MONGO_UserDocument,
type Playtype,
type TableDocument,
type UserDocument,
type V3Game,
} from "tachi-common";
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
interface Props {
reqUser: MONGO_UserDocument;
game: GameGroup;
playtype: Playtype;
reqUser: UserDocument;
game: V3Game;
}
export default function FolderTablePage({ reqUser, game, playtype }: Props) {
const { data, error } = useApiQuery<MONGO_TableDocument[]>(
`/games/${game}/${playtype}/tables?showInactive=true`,
);
export default function FolderTablePage({ reqUser, game }: Props) {
const { data, error } = useApiQuery<TableDocument[]>(`/games/${game}/tables?showInactive=true`);
const { settings } = useLUGPTSettings();
@@ -114,24 +108,19 @@ export default function FolderTablePage({ reqUser, game, playtype }: Props) {
</Form.Select>
</InputGroup>
<Divider />
{table && <TableFolderViewer {...{ reqUser, game, playtype, table }} />}
{table && <TableFolderViewer {...{ reqUser, game, table }} />}
</>
);
}
interface UGPTFolderStats {
folder: MONGO_FolderDocument;
folder: FolderDocument;
stats: FolderStatsInfo;
}
function TableFolderViewer({
reqUser,
game,
playtype,
table,
}: { table: MONGO_TableDocument } & Props) {
function TableFolderViewer({ reqUser, game, table }: { table: TableDocument } & Props) {
const { data, error } = useApiQuery<UGPTTableReturns>(
`/users/${reqUser.id}/games/${game}/${playtype}/tables/${table.tableID}`,
`/users/${reqUser.id}/games/${game}/tables/${table.tableID}`,
);
const [dataMap, setDataMap] = useState<Map<string, UGPTFolderStats>>(new Map());
@@ -141,13 +130,13 @@ function TableFolderViewer({
if (data) {
const statMap = new Map();
for (const stat of data.stats) {
statMap.set(stat.folderID, stat);
statMap.set(stat.slug, stat);
}
const newMap = new Map();
for (const folder of data.folders) {
const stats = statMap.get(folder.folderID)!;
newMap.set(folder.folderID, { folder, stats });
const stats = statMap.get(folder.slug)!;
newMap.set(folder.slug, { folder, stats });
}
setDataMap(newMap);
setHasLoadedFolderMap(true);
@@ -166,13 +155,7 @@ function TableFolderViewer({
<>
<TableBarChart dataMap={dataMap} table={table} />
<Divider />
<TableFolderTable
dataMap={dataMap}
game={game}
playtype={playtype}
reqUser={reqUser}
table={table}
/>
<TableFolderTable dataMap={dataMap} game={game} reqUser={reqUser} table={table} />
</>
);
}
@@ -182,10 +165,9 @@ function TableFolderTable({
dataMap,
reqUser,
game,
playtype,
}: {
dataMap: Map<string, UGPTFolderStats>;
table: MONGO_TableDocument;
table: TableDocument;
} & Props) {
const dataset = useMemo(() => {
const arr = [];
@@ -224,12 +206,12 @@ function TableFolderTable({
onClick={() => {
if (user?.id === reqUser.id) {
APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${data.folder.folderID}/viewed`,
`/users/${reqUser.id}/games/${game}/folders/${data.folder.slug}/viewed`,
{ method: "POST" },
);
}
}}
to={`/u/${reqUser.username}/games/${game}/${playtype}/folders/${data.folder.folderID}`}
to={`/u/${reqUser.username}/games/${game}/folders/${data.folder.slug}`}
>
View Breakdown
</LinkButton>
@@ -250,23 +232,23 @@ function TableBarChart({
dataMap,
}: {
dataMap: Map<string, UGPTFolderStats>;
table: MONGO_TableDocument;
table: TableDocument;
}) {
const gptConfig = GetGamePTConfig(table.game, table.playtype);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(table.game, table.playtype)];
const tableGame = table.game;
const gameConfig = GetGameConfig(tableGame);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[tableGame];
const bucket = useBucket(table.game, table.playtype);
const bucket = useBucket(tableGame);
const [enumMetric, setEnumMetric] = useState<string>(bucket);
// @ts-expect-error hack!
const colours = useMemo(() => gptImpl.enumColours[enumMetric], [enumMetric]);
const colours = useMemo(() => (gptImpl.enumColours as any)[enumMetric], [enumMetric]);
const dataset = useMemo(() => {
const arr = [];
for (const folderID of table.folders) {
const data = dataMap.get(folderID)!;
for (const folderSlug of table.folders) {
const data = dataMap.get(folderSlug)!;
if (!data) {
continue;
@@ -301,7 +283,7 @@ function TableBarChart({
<div className="row">
<div className="col-12 d-flex justify-content-center">
<div className="btn-group">
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
<SelectButton
id={e}
key={e}
@@ -327,7 +309,7 @@ function TableBarChart({
<OverviewBarChart
colours={colours}
dataset={dataset}
gptConfig={gptConfig}
gameConfig={gameConfig}
// This hack is necessary because otherwise nivo gets caught in a render loop and dies
key={enumMetric}
mode={enumMetric}
@@ -340,14 +322,14 @@ function TableBarChart({
}
function OverviewBarChart({
gptConfig,
gameConfig,
mode: metric,
dataset,
colours,
}: {
colours: any;
dataset: any;
gptConfig: GamePTConfig;
gameConfig: GameConfig;
mode: string;
}) {
const longestTitle = useMemo(
@@ -360,7 +342,7 @@ function OverviewBarChart({
[dataset],
);
const conf = GetScoreMetricConf(gptConfig, metric) as ConfEnumScoreMetric<string>;
const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric<string>;
return (
<ResponsiveBar
@@ -1,5 +1,4 @@
import useSetSubheader from "#components/layout/header/useSetSubheader";
import Divider from "#components/util/Divider";
import Icon from "#components/util/Icon";
import SelectLinkButton from "#components/util/SelectLinkButton";
import useUGPTBase from "#components/util/useUGPTBase";
@@ -8,32 +7,36 @@ import { UserContext } from "#context/UserContext";
import { type UGPT } from "#types/react";
import React, { useContext } from "react";
import { Route, Switch } from "react-router-dom";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
import FolderSelectPage from "./FolderSelectPage";
import FolderTablePage from "./FolderTablePage";
import RecentFoldersPage from "./RecentFoldersPage";
import SpecificFolderPage from "./SpecificFolderPage";
export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function FoldersMainPage({ reqUser, game }: UGPT) {
const { user } = useContext(UserContext);
const { ugs } = useContext(AllLUGPTStatsContext);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Folders"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Folders`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Folders",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Folders`,
);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
return (
<div className="row">
<div className="col-12 text-center">
<div className="btn-group d-flex justify-content-center mb-8">
{user && ugs?.find((x) => x.game === game && x.playtype === playtype) && (
{user && ugs?.find((x) => x.game === game) && (
<SelectLinkButton className="text-wrap" to={`${base}/folders/recent`}>
<Icon type="clock" />{" "}
{user.id === reqUser.id
@@ -52,17 +55,17 @@ export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) {
</div>
<div className="col-12">
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/folders">
<FolderTablePage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/folders">
<FolderTablePage {...{ reqUser, game }} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/folders/search">
<FolderSelectPage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/folders/search">
<FolderSelectPage {...{ reqUser, game }} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/folders/recent">
<RecentFoldersPage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/folders/recent">
<RecentFoldersPage {...{ reqUser, game }} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/folders/:folderID">
<SpecificFolderPage {...{ reqUser, game, playtype }} />
<Route path="/u/:userID/games/:game/folders/:folderSlug">
<SpecificFolderPage {...{ reqUser, game }} />
</Route>
</Switch>
</div>
@@ -8,29 +8,29 @@ import React, { useContext } from "react";
import { Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type MONGO_FolderDocument,
type MONGO_RecentlyViewedFolderDocument,
type MONGO_UserDocument,
type FolderDocument,
type RecentlyViewedFolderDocument,
type UserDocument,
} from "tachi-common";
import { FolderInfoComponent } from "./FolderSelectPage";
export default function RecentFoldersPage({ reqUser, game, playtype }: UGPT) {
export default function RecentFoldersPage({ reqUser, game }: UGPT) {
const { user } = useContext(UserContext);
if (!user) {
return <>Hey, you're not logged in. How did you get here!</>;
}
return <Inner game={game} playtype={playtype} reqUser={reqUser} user={user} />;
return <Inner game={game} reqUser={reqUser} user={user} />;
}
function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } & UGPT) {
function Inner({ reqUser, game, user }: { user: UserDocument } & UGPT) {
const { data, error } = useApiQuery<{
folders: MONGO_FolderDocument[];
folders: FolderDocument[];
stats: FolderStatsInfo[];
views: MONGO_RecentlyViewedFolderDocument[];
}>(`/users/${user.id}/games/${game}/${playtype}/folders/recent`);
views: RecentlyViewedFolderDocument[];
}>(`/users/${user.id}/games/${game}/folders/recent`);
if (error) {
return <ApiError error={error} />;
@@ -44,9 +44,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
return (
<div className="text-center">
Looks like you've not recently interacted with any folders.{" "}
<Link to={`/u/${user.username}/games/${game}/${playtype}/folders`}>
Go do that!
</Link>
<Link to={`/u/${user.username}/games/${game}/folders`}>Go do that!</Link>
</div>
);
}
@@ -56,9 +54,8 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
for (const recent of data.views) {
dataset.push({
view: recent,
// Is it really O(n^2) if the input is capped at 4?
folder: data.folders.find((x) => x.folderID === recent.folderID),
stats: data.stats.find((x) => x.folderID === recent.folderID),
folder: data.folders.find((x) => x.slug === recent.slug),
stats: data.stats.find((x) => x.slug === recent.slug),
});
}
@@ -66,9 +63,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
<Row>
{dataset.map((e) => {
if (e.folder === undefined || e.stats === undefined) {
return (
<>Failed to load folder {e.view.folderID}. This is a bug. Report this!</>
);
return <>Failed to load folder {e.view.slug}. This is a bug. Report this!</>;
}
return (
@@ -76,8 +71,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
folder={e.folder}
folderStats={e.stats}
game={game}
key={e.folder.folderID}
playtype={playtype}
key={e.folder.slug}
reqUser={reqUser}
/>
);
@@ -32,19 +32,16 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { Link, Route, Switch, useParams } from "react-router-dom";
import {
type ChartDocument,
COLOUR_SET,
FormatDifficultyShort,
type GameGroup,
GetGamePTConfig,
GetGPTString,
GetGameConfig,
GetScoreEnumConfs,
type GPTString,
type integer,
type MONGO_ChartDocument,
type MONGO_ScoreDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
type Playtype,
type ScoreDocument,
type SongDocument,
type UserDocument,
type V3Game,
} from "tachi-common";
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
@@ -52,16 +49,15 @@ import FolderComparePage from "./FolderComparePage";
import FolderQuestsPage from "./FolderQuestsPage";
interface Props {
reqUser: MONGO_UserDocument;
game: GameGroup;
playtype: Playtype;
reqUser: UserDocument;
game: V3Game;
}
export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
const { folderID } = useParams<{ folderID: string }>();
export default function SpecificFolderPage({ reqUser, game }: Props) {
const { folderSlug } = useParams<{ folderSlug: string }>();
const { data, error } = useApiQuery<UGPTFolderReturns>(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folderID}`,
`/users/${reqUser.id}/games/${game}/folders/${folderSlug}`,
);
const folderDataset = useMemo(() => {
@@ -79,7 +75,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
...chart,
__related: {
pb: pbMap.get(chart.chartID) ?? null,
song: songMap.get(chart.songID)!,
song: songMap.get(chart.song.id)!,
user: reqUser,
},
});
@@ -100,13 +96,12 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
folderDataset={folderDataset}
folderTitle={data.folder.title}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
);
}, [folderDataset]);
const base = `${useUGPTBase({ reqUser, game, playtype })}/folders/${folderID}`;
const base = `${useUGPTBase({ reqUser, game })}/folders/${folderSlug}`;
if (error) {
return <ApiError error={error} />;
@@ -116,7 +111,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
return <Loading />;
}
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString];
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
return (
<div className="row">
@@ -131,8 +126,10 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
</SelectLinkButton>
{gptImpl.ratingSystems.length !== 0 &&
// temp: tierlist view sucks for BMS and PMS
game !== "bms" &&
game !== "pms" && (
game !== "bms-7k" &&
game !== "bms-14k" &&
game !== "pms-controller" &&
game !== "pms-keyboard" && (
<SelectLinkButton className="text-wrap" to={`${base}/tierlist`}>
<Icon type="sort-alpha-up" /> Tierlist View
</SelectLinkButton>
@@ -154,40 +151,24 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
<div className="col-12">
<Switch>
<Route exact path={base}>
<FolderTable dataset={folderDataset} game={game} playtype={playtype} />
<FolderTable dataset={folderDataset} game={game} />
</Route>
<Route exact path={`${base}/tierlist`}>
<TierlistBreakdown
data={data}
folderDataset={folderDataset}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
</Route>
<Route exact path={`${base}/timeline`}>
<TimelineView
folderID={folderID}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<TimelineView folderSlug={folderSlug} game={game} reqUser={reqUser} />
</Route>
<Route exact path={`${base}/compare`}>
<FolderComparePage
folder={data.folder}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<FolderComparePage folder={data.folder} game={game} reqUser={reqUser} />
</Route>
<Route exact path={`${base}/targets`}>
<FolderQuestsPage
folder={data.folder}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<FolderQuestsPage folder={data.folder} game={game} reqUser={reqUser} />
</Route>
</Switch>
</div>
@@ -195,11 +176,11 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
);
}
function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string } & Props) {
const gptConfig = GetGamePTConfig(game, playtype);
const enumConfs = GetScoreEnumConfs(gptConfig);
function TimelineView({ game, reqUser, folderSlug }: { folderSlug: string } & Props) {
const gameConfig = GetGameConfig(game);
const enumConfs = GetScoreEnumConfs(gameConfig);
const [selectedEnum, setSelectedEnum] = useState<string>(gptConfig.preferredDefaultEnum);
const [selectedEnum, setSelectedEnum] = useState<string>(gameConfig.preferredDefaultEnum);
const [enumConf, setEnumConf] = useState<ConfEnumScoreMetric<string>>(enumConfs[selectedEnum]!);
const [value, setValue] = useState<string>(enumConf.minimumRelevantValue);
@@ -237,9 +218,7 @@ function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string
</div>
</Card>
<hr />
<TimelineMain
{...{ reqUser, game, playtype, folderID, enumMetric: selectedEnum, value }}
/>
<TimelineMain {...{ reqUser, game, folderSlug, enumMetric: selectedEnum, value }} />
</>
);
}
@@ -247,23 +226,22 @@ function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string
function TimelineMain({
reqUser,
game,
playtype,
folderID,
folderSlug,
enumMetric: enumMetric,
value,
}: {
enumMetric: string;
folderID: string;
folderSlug: string;
value: string;
} & Props) {
const { data, error } = useApiQuery<{
charts: MONGO_ChartDocument[];
scores: MONGO_ScoreDocument[];
songs: MONGO_SongDocument[];
charts: ChartDocument[];
scores: ScoreDocument[];
songs: SongDocument[];
}>(
`/users/${
reqUser.id
}/games/${game}/${playtype}/folders/${folderID}/timeline?criteriaValue=${encodeURIComponent(
}/games/${game}/folders/${folderSlug}/timeline?criteriaValue=${encodeURIComponent(
value,
)}&criteriaType=${encodeURIComponent(enumMetric)}`,
);
@@ -369,10 +347,10 @@ function TimelineElement({
index: integer;
scoreData: {
__related: {
chart: MONGO_ChartDocument;
song: MONGO_SongDocument;
chart: ChartDocument;
song: SongDocument;
};
} & MONGO_ScoreDocument;
} & ScoreDocument;
}) {
return (
<div className="timeline-item">
@@ -423,24 +401,20 @@ type InfoProps = {
folderDataset: FolderDataset;
} & Props;
function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString];
function TierlistBreakdown({ game, folderDataset, reqUser }: InfoProps) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const [tierlist, setTierlist] = useState<string>(gptImpl.ratingSystems[0].name);
const [useFancyColour, setUseFancyColour] = useState(false);
const [forceGridView, setForceGridView] = useState(false);
const playerStats = useMemo(
() => FolderDatasetAchievedStatus(folderDataset, game, playtype, tierlist),
() => FolderDatasetAchievedStatus(folderDataset, game, tierlist),
[tierlist],
);
// @ts-expect-error Typescript doesn't like our unioning here for some reason
// and i can't be bothered to figure it out
const tierlistImpl: GPTRatingSystem<GPTString> = gptImpl.ratingSystems.find(
// @ts-expect-error Typescript doesn't like our unioning here for some reason
// and i can't be bothered to figure it out
(rs: GPTRatingSystem<GPTString>) => rs.name === tierlist,
const tierlistImpl = (gptImpl.ratingSystems as GPTRatingSystem<V3Game>[]).find(
(rs) => rs.name === tierlist,
);
if (!tierlistImpl) {
@@ -501,7 +475,6 @@ function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps
forceGridView={forceGridView}
game={game}
playerStats={playerStats}
playtype={playtype}
reqUser={reqUser}
tierlistImpl={tierlistImpl}
useFancyColour={useFancyColour}
@@ -514,7 +487,6 @@ function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps
function TierlistInfoLadder({
playerStats,
game,
playtype,
reqUser,
folderDataset,
tierlistImpl,
@@ -523,11 +495,10 @@ function TierlistInfoLadder({
}: {
folderDataset: FolderDataset;
forceGridView: boolean;
game: GameGroup;
game: V3Game;
playerStats: Record<string, { score: string | null; status: AchievedStatuses }>;
playtype: Playtype;
reqUser: MONGO_UserDocument;
tierlistImpl: GPTRatingSystem<GPTString>;
reqUser: UserDocument;
tierlistImpl: GPTRatingSystem<V3Game>;
useFancyColour: boolean;
}) {
const buckets: TierlistInfo[][] = useMemo(() => {
@@ -609,7 +580,6 @@ function TierlistInfoLadder({
{...{
bucket,
game,
playtype,
reqUser,
useFancyColour,
tierlistImpl,
@@ -625,7 +595,6 @@ function TierlistInfoLadder({
function TierlistBucket({
bucket,
game,
playtype,
reqUser,
useFancyColour,
forceGridView,
@@ -633,10 +602,9 @@ function TierlistBucket({
}: {
bucket: TierlistInfo[];
forceGridView: boolean;
game: GameGroup;
playtype: Playtype;
reqUser: MONGO_UserDocument;
tierlistImpl: GPTRatingSystem<GPTString>;
game: V3Game;
reqUser: UserDocument;
tierlistImpl: GPTRatingSystem<V3Game>;
useFancyColour: boolean;
}) {
const {
@@ -653,7 +621,6 @@ function TierlistBucket({
game={game}
i={i}
key={`${tierlistInfo.chart.chartID}-${tierlistInfo.text}`}
playtype={playtype}
reqUser={reqUser}
tierlistImpl={tierlistImpl}
tierlistInfo={tierlistInfo}
@@ -673,7 +640,6 @@ function TierlistBucket({
game={game}
i={i}
key={`${tierlistInfo.chart.chartID}-${tierlistInfo.text}`}
playtype={playtype}
reqUser={reqUser}
tierlistImpl={tierlistImpl}
tierlistInfo={tierlistInfo}
@@ -687,7 +653,6 @@ function TierlistBucket({
function TierlistInfoBucketValues({
tierlistInfo,
game,
playtype,
reqUser,
useFancyColour,
forceGridView,
@@ -695,11 +660,10 @@ function TierlistInfoBucketValues({
}: {
bucket: TierlistInfo[];
forceGridView: boolean;
game: GameGroup;
game: V3Game;
i: integer;
playtype: Playtype;
reqUser: MONGO_UserDocument;
tierlistImpl: GPTRatingSystem<GPTString>;
reqUser: UserDocument;
tierlistImpl: GPTRatingSystem<V3Game>;
tierlistInfo: TierlistInfo;
useFancyColour: boolean;
}) {
@@ -716,7 +680,7 @@ function TierlistInfoBucketValues({
let colourCss: string | undefined;
if (useFancyColour) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
// @ts-expect-error lol
colourCss = gptImpl.enumColours[tierlistImpl.enumName][tierlistInfo.score];
@@ -736,7 +700,7 @@ function TierlistInfoBucketValues({
<tr>
<DifficultyCell alwaysShort chart={tierlistInfo.chart} game={game} noTierlist />
<td className="text-start">
<Link className="text-decoration-none" to={CreateChartLink(data, game)}>
<Link className="text-decoration-none" to={CreateChartLink(data)}>
{tierlistInfo.chart.__related.song.title}
</Link>{" "}
<br />
@@ -771,10 +735,10 @@ function TierlistInfoBucketValues({
className={`${colourClass} bg-opacity-50 rounded p-2`}
style={{ backgroundColor: colourCss ? ChangeOpacity(colourCss, 0.5) : undefined }}
>
<Link className="text-decoration-none" to={CreateChartLink(data, game)}>
<Link className="text-decoration-none" to={CreateChartLink(data)}>
{data.__related.song.title}
</Link>{" "}
{FormatDifficultyShort(data, game)}
{FormatDifficultyShort(data)}
<Divider className="my-2" />
{tierlistInfo.value} ({tierlistInfo.text ?? "No Info"})
{tierlistInfo.idvDiff && (
@@ -851,17 +815,11 @@ enum AchievedStatuses {
SCORE_BASED,
}
function FolderDatasetAchievedStatus(
folderDataset: FolderDataset,
game: GameGroup,
playtype: Playtype,
tierlist: string,
) {
function FolderDatasetAchievedStatus(folderDataset: FolderDataset, game: V3Game, tierlist: string) {
const tierlistInfo: Record<string, { score: string | null; status: AchievedStatuses }> = {};
const fn = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString].ratingSystems.find(
// @ts-expect-error i'm sick of this language and i'm sick of type hacks
(e: GPTRatingSystem<GPTString>) => e.name === tierlist,
const fn = (GPT_CLIENT_IMPLEMENTATIONS[game].ratingSystems as GPTRatingSystem<V3Game>[]).find(
(e) => e.name === tierlist,
)?.achievementFn;
for (const data of folderDataset) {
@@ -871,8 +829,7 @@ function FolderDatasetAchievedStatus(
if (!data.__related.pb) {
achieved = AchievedStatuses.NOT_PLAYED;
} else if (fn) {
// @ts-expect-error i'm sick of this language and i'm sick of type hacks
const v = fn(data.__related.pb);
const v = fn(data.__related.pb as any);
achieved = v[1] ? AchievedStatuses.ACHIEVED : AchievedStatuses.FAILED;
score = typeof v[0] === "number" ? v[0].toString() : v[0];
@@ -4,7 +4,7 @@ import { type UGPT } from "#types/react";
import React from "react";
import { Link } from "react-router-dom";
export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
export default function RivalsActivityPage({ reqUser, game }: UGPT) {
const { settings } = useLUGPTSettings();
if (!settings) {
@@ -15,12 +15,10 @@ export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
return (
<div className="text-center">
You have no rivals set.{" "}
<Link to={`/u/${reqUser.id}/games/${game}/${playtype}/rivals/manage`}>
Go set some!
</Link>
<Link to={`/u/${reqUser.id}/games/${game}/rivals/manage`}>Go set some!</Link>
</div>
);
}
return <Activity url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity`} />;
return <Activity url={`/users/${reqUser.id}/games/${game}/rivals/activity`} />;
}
@@ -7,36 +7,32 @@ import useLUGPTSettings from "#components/util/useLUGPTSettings";
import useUGPTBase from "#components/util/useUGPTBase";
import React from "react";
import { Col, Row } from "react-bootstrap";
import { Redirect, Route, Switch } from "react-router-dom";
import { Route, Switch } from "react-router-dom";
import {
FormatGameGroup,
type GameGroup,
FormatGame,
GameToGameGroup,
GetGameGroupConfig,
type MONGO_UserDocument,
type Playtype,
type UserDocument,
type V3Game,
} from "tachi-common";
import RivalsActivityPage from "./RivalsActivityPage";
import RivalsManagePage from "./RivalsManagePage";
export default function RivalsMainPage({
reqUser,
game,
playtype,
}: {
game: GameGroup;
playtype: Playtype;
reqUser: MONGO_UserDocument;
}) {
const gameConfig = GetGameGroupConfig(game);
export default function RivalsMainPage({ reqUser, game }: { game: V3Game; reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Rivals`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Rivals",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Rivals`,
);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
const { settings } = useLUGPTSettings();
@@ -48,11 +44,6 @@ export default function RivalsMainPage({
<Row>
<Col className="text-center" xs={12}>
<div className="btn-group d-flex justify-content-center">
{/* this ui sucks and i don't like it. come up with something better? */}
{/* <SelectLinkButton to={`${base}/rivals/pb-leaderboard`}>
<Icon type="sort-amount-up" />
Rival's Bests
</SelectLinkButton> */}
<SelectLinkButton to={`${base}/rivals`}>
<Icon type="list" /> Rival Activity
</SelectLinkButton>
@@ -64,20 +55,19 @@ export default function RivalsMainPage({
</Col>
<Col xs={12}>
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/rivals">
<RivalsActivityPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/rivals">
<RivalsActivityPage game={game} reqUser={reqUser} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/rivals/manage">
<RivalsManagePage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/rivals/manage">
<RivalsManagePage game={game} reqUser={reqUser} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/rivals/pb-leaderboard">
<Route exact path="/u/:userID/games/:game/rivals/pb-leaderboard">
<ScoreLeaderboard
game={game}
playtype={playtype}
refreshDeps={[`rivals-pb-leaderboard-${settings.rivals.join(",")}`]}
url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/pb-leaderboard`}
url={`/users/${reqUser.id}/games/${game}/rivals/pb-leaderboard`}
/>
</Route>
</Switch>
@@ -16,35 +16,38 @@ import React, { useContext, useState } from "react";
import { Alert, Button, Col } from "react-bootstrap";
import { Prompt } from "react-router-dom";
import {
FormatGameGroup,
type GameGroup,
FormatGame,
GameToGameGroup,
GetGameGroupConfig,
type integer,
type MONGO_UserDocument,
type Playtype,
type UserDocument,
type V3Game,
} from "tachi-common";
export default function RivalsManagePage({
reqUser,
game,
playtype,
}: {
game: GameGroup;
playtype: Playtype;
reqUser: MONGO_UserDocument;
game: V3Game;
reqUser: UserDocument;
}) {
const gameConfig = GetGameGroupConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals", "Manage"],
[reqUser, game, playtype],
`Managing ${reqUser.username}'s ${FormatGameGroup(game, playtype)} Rivals`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Rivals",
"Manage",
],
[reqUser, game],
`Managing ${reqUser.username}'s ${FormatGame(game)} Rivals`,
);
const { settings } = useLUGPTSettings();
const { data, error } = useApiQuery<MONGO_UserDocument[]>(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
const { data, error } = useApiQuery<UserDocument[]>(
`/users/${reqUser.id}/games/${game}/rivals`,
{},
[`fetch-rivals-${settings?.rivals.join(",")}`],
);
@@ -53,9 +56,7 @@ export default function RivalsManagePage({
data: challengers,
isLoading: cIsLoading,
error: cError,
} = useApiQuery<MONGO_UserDocument[]>(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals/challengers`,
);
} = useApiQuery<UserDocument[]>(`/users/${reqUser.id}/games/${game}/rivals/challengers`);
if (error) {
<ApiError error={error} />;
@@ -78,7 +79,6 @@ export default function RivalsManagePage({
challengers={challengers}
game={game}
initialRivals={data}
playtype={playtype}
reqUser={reqUser}
/>
);
@@ -87,15 +87,13 @@ export default function RivalsManagePage({
function RivalsOverviewPage({
reqUser,
game,
playtype,
initialRivals,
challengers,
}: {
challengers: Array<MONGO_UserDocument>;
game: GameGroup;
initialRivals: Array<MONGO_UserDocument>;
playtype: Playtype;
reqUser: MONGO_UserDocument;
challengers: Array<UserDocument>;
game: V3Game;
initialRivals: Array<UserDocument>;
reqUser: UserDocument;
}) {
const { user } = useContext(UserContext);
@@ -123,7 +121,6 @@ function RivalsOverviewPage({
return (
<>
{/* kind of a stupid way to check whether the array has changed or not, but who cares. */}
{isRequestingUser && currentRivals.toString() !== rivals.toString() && (
<Alert className="vstack" variant="warning">
<Prompt
@@ -136,7 +133,7 @@ function RivalsOverviewPage({
<Button
onClick={async () => {
const res = await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
`/users/${reqUser.id}/games/${game}/rivals`,
{
method: "PUT",
body: JSON.stringify({
@@ -168,7 +165,7 @@ function RivalsOverviewPage({
<Card header={`${isRequestingUser ? "Your" : `${reqUser.username}'s`} Rivals`}>
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
{rivals.map((e) => (
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
<UserIcon game={game} key={e.id} user={e}>
<Button
onClick={() => setRivals(rivals.filter((u) => u.id !== e.id))}
variant="outline-danger"
@@ -209,7 +206,6 @@ function RivalsOverviewPage({
} else {
setRivals([...rivals, user]);
// if we're now at max rivals, exit.
if (rivals.length + 1 >= MAX_RIVALS) {
setShow(false);
}
@@ -219,7 +215,7 @@ function RivalsOverviewPage({
excludeSet={rivals.map((e) => e.id)}
setShow={setShow}
show={show}
url={`/games/${game}/${playtype}/players`}
url={`/games/${game}/players`}
/>
</>
)}
@@ -246,7 +242,7 @@ function RivalsOverviewPage({
>
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
{challengers.map((e) => (
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
<UserIcon game={game} key={e.id} user={e}>
{isRequestingUser &&
!rivals.map((e) => e.id).includes(e.id) &&
(rivals.length < MAX_RIVALS ? (
@@ -7,21 +7,25 @@ import { type UGPT } from "#types/react";
import React from "react";
import { Col, Row } from "react-bootstrap";
import { Route, Switch } from "react-router-dom";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
import UGPTGoalsPage from "./UGPTGoalsPage";
import UGPTQuestsPage from "./UGPTQuestsPage";
export default function TargetsPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function TargetsPage({ reqUser, game }: UGPT) {
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Goals & Quests"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Goals & Quests`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Goals & Quests",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Goals & Quests`,
);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
return (
<Row>
@@ -38,11 +42,11 @@ export default function TargetsPage({ reqUser, game, playtype }: UGPT) {
</Col>
<Col xs={12}>
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/targets/goals">
<UGPTGoalsPage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/targets/goals">
<UGPTGoalsPage {...{ reqUser, game }} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/targets">
<UGPTQuestsPage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/targets">
<UGPTQuestsPage {...{ reqUser, game }} />
</Route>
</Switch>
</Col>
@@ -14,16 +14,16 @@ import { CreateGoalSubDataset } from "#util/data";
import React, { useContext, useReducer, useState } from "react";
import { Button, Col } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FormatGameGroup } from "tachi-common";
import { FormatGame } from "tachi-common";
export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
const [show, setShow] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const { reloadTargets } = useContext(TargetsContext);
const [refresh, refetchGoals] = useReducer((x) => x + 1, 0);
const { data, error } = useApiQuery<AllUGPTGoalsReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals`,
`/users/${reqUser.id}/games/${game}/targets/goals`,
undefined,
[refresh.toString()],
);
@@ -61,13 +61,10 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
</Button>
<div>
Looking for goal recommendations?{" "}
<Link to={`/games/${game}/${playtype}/quests`}>
Check out {FormatGameGroup(game, playtype)}'s Quests
</Link>
.
<Link to={`/games/${game}/quests`}>Check out {FormatGame(game)}'s Quests</Link>.
</div>
<Divider />
<GoalSubInfo dataset={dataset} game={game} playtype={playtype} />
<GoalSubInfo dataset={dataset} game={game} />
</Col>
{show && (
<AddNewGoalForQuestModal
@@ -75,7 +72,7 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
noNote
onCreate={async (rawGoal) => {
await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/add-goal`,
`/users/${reqUser.id}/games/${game}/targets/goals/add-goal`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -91,7 +88,6 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
refetchGoals();
reloadTargets();
}}
playtype={playtype}
setShow={setShow}
show={show}
/>
@@ -101,7 +97,7 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
dataset={dataset}
onDelete={async (goalID) => {
await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/${goalID}`,
`/users/${reqUser.id}/games/${game}/targets/goals/${goalID}`,
{
method: "DELETE",
},
@@ -12,19 +12,19 @@ import React, { useMemo, useState } from "react";
import { Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type MONGO_GoalDocument,
type MONGO_QuestDocument,
type MONGO_QuestSubscriptionDocument,
type GoalDocument,
type QuestDocument,
type QuestSubscriptionDocument,
} from "tachi-common";
export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
export default function UGPTQuestsPage({ reqUser, game }: UGPT) {
const [show, setShow] = useState<"achieved" | "all" | "unachieved">("all");
const { data, error } = useApiQuery<{
goals: Array<MONGO_GoalDocument>;
quests: Array<MONGO_QuestDocument>;
questSubs: Array<MONGO_QuestSubscriptionDocument>;
}>(`/users/${reqUser.id}/games/${game}/${playtype}/targets/quests`);
goals: Array<GoalDocument>;
quests: Array<QuestDocument>;
questSubs: Array<QuestSubscriptionDocument>;
}>(`/users/${reqUser.id}/games/${game}/targets/quests`);
const questsToShow = useMemo(() => {
if (!data || error) {
@@ -33,27 +33,22 @@ export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
const questSubMap = CreateQuestSubMap(data.questSubs);
// slice and sort based on progress
let base = data.quests.slice(0).sort(
NumericSOV((quest) => {
const sub = questSubMap.get(quest.questID);
if (!sub) {
return -Infinity; // shouldn't happen
return -Infinity;
}
// sink achieved things below goals in progress
if (sub.achieved) {
return -100;
}
// since this is always ostensibly positive, the magic numbers -99 and
// -100 should be fine.
return sub.progress / GetGoalIDsFromQuest(quest).length;
}, true),
);
// filter on "show" setting
switch (show) {
case "all":
break;
@@ -98,7 +93,7 @@ export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
<div className="text-center">
Looks like you have no quests set.
<br />
<Link to={`/games/${game}/${playtype}/quests`}>Go set some!</Link>
<Link to={`/games/${game}/quests`}>Go set some!</Link>
</div>
</Col>
)}
@@ -8,34 +8,39 @@ import { type UGPT } from "#types/react";
import React, { useContext } from "react";
import { Col, Row } from "react-bootstrap";
import { Link, Route, Switch } from "react-router-dom";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function UGPTUtilsPage({ reqUser, game }: UGPT) {
const { user } = useContext(UserContext);
const isViewingOwnProfile = user?.id === reqUser.id;
const utils = GetGPTUtils(game, playtype);
const pageName = GetGPTUtilsName(game, playtype, isViewingOwnProfile);
const utils = GetGPTUtils(game);
const pageName = GetGPTUtilsName(game, isViewingOwnProfile);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, pageName ?? "Utils"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} ${pageName ?? "Utils"}`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
pageName ?? "Utils",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} ${pageName ?? "Utils"}`,
);
return (
<Row>
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/utils">
<Route exact path="/u/:userID/games/:game/utils">
{utils.map((util) => (
<Col className="my-4" key={util.urlPath} lg={6} xs={12}>
<Card
footer={
<div className="d-flex w-100 justify-content-end">
<LinkButton
to={`/u/${reqUser.username}/games/${game}/${playtype}/utils/${util.urlPath}`}
to={`/u/${reqUser.username}/games/${game}/utils/${util.urlPath}`}
>
View
</LinkButton>
@@ -53,14 +58,14 @@ export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
<Route
exact
key={tool.urlPath}
path={`/u/:userID/games/:game/:playtype/utils/${tool.urlPath}`}
path={`/u/:userID/games/:game/utils/${tool.urlPath}`}
>
<Col className="mt-4" xs={12}>
<Card
footer={
<Link
className="text-body-secondary text-hover-white"
to={`/u/${reqUser.username}/games/${game}/${playtype}/utils`}
to={`/u/${reqUser.username}/games/${game}/utils`}
>
&lt; Back to all tools...
</Link>
@@ -71,7 +76,7 @@ export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
</Card>
<Divider />
</Col>
<Col xs={12}>{tool.component({ reqUser, game, playtype })}</Col>
<Col xs={12}>{tool.component({ reqUser, game })}</Col>
</Route>
))}
</Switch>
@@ -17,7 +17,7 @@ const LOCAL_QUEST_KEY = "LOCAL_QUESTS";
const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = {
json: [
{
game: p.isIn(TachiConfig.GAMES),
game: p.isIn(TachiConfig.GAME_GROUPS),
playtype: (self, parent) => {
const gameConfig = GetGameGroupConfig(parent.game as any);
+112 -153
View File
@@ -3,7 +3,6 @@ import GPTChartsPage from "#app/pages/dashboard/games/_game/_playtype/GPTChartsP
import GPTDevInfo from "#app/pages/dashboard/games/_game/_playtype/GPTDevInfo";
import GPTLeaderboardsPage from "#app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage";
import GPTMainPage from "#app/pages/dashboard/games/_game/_playtype/GPTMainPage";
import PlaytypeSelect from "#app/pages/dashboard/games/_game/PlaytypeSelect";
import { ErrorPage } from "#app/pages/ErrorPage";
import ChartInfoFormat from "#components/game/charts/ChartInfoFormat";
import { GPTBottomNav } from "#components/game/GPTHeader";
@@ -28,7 +27,7 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
import { type SongsReturn } from "#types/api-returns";
import { type GamePT, type SetState } from "#types/react";
import { ToCDNURL } from "#util/api";
import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts";
import { IsSupportedGame } from "#util/asserts";
import { ChangeOpacity } from "#util/color-opacity";
import { CreateChartLink } from "#util/data";
import { SelectRightChart } from "#util/misc";
@@ -37,109 +36,97 @@ import React, { useContext, useEffect, useState } from "react";
import { Col, Row } from "react-bootstrap";
import { Redirect, Route, Switch, useParams } from "react-router-dom";
import {
type ChartDocument,
COLOUR_SET,
FormatDifficulty,
type GameGroup,
GetGameGroupConfig,
GetGamePTConfig,
GetGPTString,
type MONGO_ChartDocument,
type MONGO_SongDocument,
type GamesForGroup,
GameToGameGroup,
GetGameConfig,
type SongDocument,
type V3Game,
} from "tachi-common";
import { type FixedDifficulties } from "tachi-common/types/game-config-utils";
export default function GameRoutes() {
const { game } = useParams<{ game: string }>();
const { game: gameParam } = useParams<{ game: string }>();
const { setBackground } = useContext(BackgroundContext);
useEffect(() => {
setBackground(ToCDNURL(`/game-banners/${game}`));
}, [game, setBackground]);
if (!IsSupportedGame(game)) {
return <ErrorPage customMessage={`The game ${game} is not supported.`} statusCode={404} />;
}
const gameConfig = GetGameGroupConfig(game);
setBackground(ToCDNURL(`/game-banners/${gameParam}`));
}, [gameParam, setBackground]);
// Support legacy URLs like /games/iidx/SP → redirect to /games/iidx-sp
return (
<Switch>
<Route exact path="/games/:game">
{gameConfig.playtypes.length === 1 ? (
<Redirect to={`/games/${game}/${gameConfig.playtypes[0]}`} />
) : (
<PlaytypeSelect
base={`/games/${game}`}
game={game}
subheaderCrumbs={["Games", gameConfig.name]}
subheaderTitle={`${gameConfig.name} Playtype Select`}
/>
)}
</Route>
<Route path="/games/:game/:playtype">
<UGPTContextProvider>
<TargetsContextProvider>
<GamePlaytypeRoutes game={game} />
</TargetsContextProvider>
</UGPTContextProvider>
</Route>
<Route path="*">
<ErrorPage statusCode={404} />
<Route path="/games/:game">
<V3GameRoutes />
</Route>
</Switch>
);
}
function GamePlaytypeRoutes({ game }: { game: GameGroup }) {
const { playtype } = useParams<{ playtype: string }>();
function V3GameRoutes() {
const { game: gameParam } = useParams<{ game: string }>();
const { setBackground } = useContext(BackgroundContext);
if (!IsSupportedPlaytype(game, playtype)) {
useEffect(() => {
setBackground(ToCDNURL(`/game-banners/${gameParam}`));
}, [gameParam, setBackground]);
if (!IsSupportedGame(gameParam)) {
return (
<ErrorPage
customMessage={`The playtype ${playtype} is not supported.`}
statusCode={400}
/>
<ErrorPage customMessage={`The game ${gameParam} is not supported.`} statusCode={404} />
);
}
const game = gameParam;
return (
<UGPTContextProvider>
<TargetsContextProvider>
<GameV3Routes game={game} />
</TargetsContextProvider>
</UGPTContextProvider>
);
}
function GameV3Routes({ game }: { game: V3Game }) {
return (
<>
<div className="card">
<GPTBottomNav baseUrl={`/games/${game}/${playtype}`} />
<GPTBottomNav baseUrl={`/games/${game}`} />
</div>
<Divider />
<Switch>
<Route exact path="/games/:game/:playtype">
<GPTMainPage game={game} playtype={playtype} />
<Route exact path="/games/:game">
<GPTMainPage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/songs">
<Redirect to={`/games/${game}/${playtype}/charts`} />
<Route exact path="/games/:game/songs">
<Redirect to={`/games/${game}/charts`} />
</Route>
<Route exact path="/games/:game/:playtype/charts">
<GPTChartsPage game={game} playtype={playtype} />
<Route exact path="/games/:game/charts">
<GPTChartsPage game={game} />
</Route>
<Route path="/games/:game/:playtype/charts/:chartID">
<ChartPageRoutes game={game} playtype={playtype} />
<Route path="/games/:game/charts/:chartID">
<ChartPageRoutes game={game} />
</Route>
<Route path="/games/:game/:playtype/songs/:songID">
<SongChartRedirectRoutes game={game} playtype={playtype} />
<Route path="/games/:game/songs/:songID">
<SongChartRedirectRoutes game={game} />
</Route>
<Route path="/games/:game/:playtype/(quests|questlines|goals)">
<GPTQuestRoutes game={game} playtype={playtype} />
<Route path="/games/:game/(quests|questlines|goals)">
<GPTQuestRoutes game={game} />
</Route>
<Route exact path="/games/:game/:playtype/leaderboards">
<GPTLeaderboardsPage game={game} playtype={playtype} />
<Route exact path="/games/:game/leaderboards">
<GPTLeaderboardsPage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/dev-info">
<GPTDevInfo game={game} playtype={playtype} />
<Route exact path="/games/:game/dev-info">
<GPTDevInfo game={game} />
</Route>
<Route path="*">
@@ -150,46 +137,46 @@ function GamePlaytypeRoutes({ game }: { game: GameGroup }) {
);
}
function GPTQuestRoutes({ game, playtype }: GamePT) {
function GPTQuestRoutes({ game }: GamePT) {
return (
<>
<Switch>
<Route exact path="/games/:game/:playtype/quests">
<QuestsPage game={game} playtype={playtype} />
<Route exact path="/games/:game/quests">
<QuestsPage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/questlines">
<Redirect to={`/games/${game}/${playtype}/quests`} />
<Route exact path="/games/:game/questlines">
<Redirect to={`/games/${game}/quests`} />
</Route>
<Route exact path="/games/:game/:playtype/questlines/:questlineID">
<QuestlinePage game={game} playtype={playtype} />
<Route exact path="/games/:game/questlines/:questlineID">
<QuestlinePage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/quests/:questID">
<QuestPage game={game} playtype={playtype} />
<Route exact path="/games/:game/quests/:questID">
<QuestPage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/goals">
<Redirect to={`/games/${game}/${playtype}/quests`} />
<Route exact path="/games/:game/goals">
<Redirect to={`/games/${game}/quests`} />
</Route>
</Switch>
</>
);
}
function ChartPageRoutes({ game, playtype }: GamePT) {
function ChartPageRoutes({ game }: GamePT) {
const { chartID } = useParams<{ chartID: string }>();
const { data: singleData, error: chartErr } = useApiQuery<{
chart: MONGO_ChartDocument;
song: MONGO_SongDocument;
}>(`/games/${game}/${playtype}/charts/${chartID}`);
chart: ChartDocument;
song: SongDocument;
}>(`/games/${game}/charts/${chartID}`);
const songLegacyId = singleData?.song.id;
const { data: songsData, error: songsErr } = useApiQuery<SongsReturn>(
songLegacyId !== undefined ? `/games/${game}/${playtype}/songs/${songLegacyId}` : "",
songLegacyId !== undefined ? `/games/${game}/songs/${songLegacyId}` : "",
undefined,
undefined,
songLegacyId === undefined,
@@ -197,7 +184,7 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
const { settings } = useContext(UserSettingsContext);
const [activeChart, setActiveChart] = useState<MONGO_ChartDocument | null>(null);
const [activeChart, setActiveChart] = useState<ChartDocument | null>(null);
useEffect(() => {
const c = songsData?.charts.find((x) => x.chartID === chartID);
@@ -216,10 +203,7 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
if (songsData.charts.length === 0) {
return (
<ErrorPage
customMessage={"This song has no charts for this playtype."}
statusCode={404}
/>
<ErrorPage customMessage={"This song has no charts for this game."} statusCode={404} />
);
}
@@ -227,18 +211,12 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
<>
<SongInfoHeader
game={game}
playtype={playtype}
{...songsData}
activeChart={activeChart}
setActiveChart={setActiveChart}
/>
<Divider />
<GPTChartPage
chart={activeChart}
game={game}
playtype={playtype}
song={songsData.song}
/>
<GPTChartPage chart={activeChart} game={game} song={songsData.song} />
{settings?.preferences.developerMode && (
<>
<Divider />
@@ -251,10 +229,10 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
);
}
function SongChartRedirectRoutes({ game, playtype }: GamePT) {
function SongChartRedirectRoutes({ game }: GamePT) {
const { songID } = useParams<{ songID: string }>();
const { data, error } = useApiQuery<SongsReturn>(`/games/${game}/${playtype}/songs/${songID}`);
const { data, error } = useApiQuery<SongsReturn>(`/games/${game}/songs/${songID}`);
if (error) {
return <ErrorPage customMessage={error.description} statusCode={error.statusCode} />;
@@ -266,14 +244,11 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
if (data.charts.length === 0) {
return (
<ErrorPage
customMessage={"This song has no charts for this playtype."}
statusCode={404}
/>
<ErrorPage customMessage={"This song has no charts for this game."} statusCode={404} />
);
}
if (data.charts.every((c) => c.playtype !== playtype)) {
if (data.charts.every((c) => c.game !== game)) {
const c = data.charts[0];
if (!c.chartID) {
@@ -285,17 +260,17 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
);
}
return <Redirect to={`/games/${game}/${c.playtype}/charts/${c.chartID}`} />;
return <Redirect to={`/games/${c.game}/charts/${c.chartID}`} />;
}
return (
<Switch>
<Route exact path="/games/:game/:playtype/songs/:songID">
<SongSongIdOnlyRedirect charts={data.charts} game={game} playtype={playtype} />
<Route exact path="/games/:game/songs/:songID">
<SongSongIdOnlyRedirect charts={data.charts} game={game} />
</Route>
<Route path="/games/:game/:playtype/songs/:songID/:difficulty">
<SongDifficultyRedirect data={data} game={game} playtype={playtype} />
<Route path="/games/:game/songs/:songID/:difficulty">
<SongDifficultyRedirect data={data} game={game} />
</Route>
<Route path="*">
@@ -305,11 +280,7 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
);
}
function SongSongIdOnlyRedirect({
charts,
game,
playtype,
}: { charts: MONGO_ChartDocument[] } & GamePT) {
function SongSongIdOnlyRedirect({ charts, game }: { charts: ChartDocument[] } & GamePT) {
const hardest = charts.slice(0).sort(NumericSOV((x) => x.levelNum, true))[0];
if (!hardest.chartID) {
@@ -321,52 +292,53 @@ function SongSongIdOnlyRedirect({
);
}
return <Redirect to={`/games/${game}/${playtype}/charts/${hardest.chartID}`} />;
return <Redirect to={`/games/${game}/charts/${hardest.chartID}`} />;
}
function SongDifficultyRedirect({ data, game, playtype }: { data: SongsReturn } & GamePT) {
function SongDifficultyRedirect({ data, game }: { data: SongsReturn } & GamePT) {
const { difficulty: d } = useParams<{ difficulty: string }>();
const difficulty = decodeURIComponent(d);
const gptConfig = GetGamePTConfig(game, playtype);
const chart = SelectRightChart(gptConfig, difficulty, data.charts);
const gameConfig = GetGameConfig(game);
const chart = SelectRightChart(gameConfig, difficulty, data.charts);
if (!chart?.chartID) {
return <ErrorPage customMessage={"Could not resolve this chart."} statusCode={404} />;
}
return <Redirect to={`/games/${game}/${chart.playtype}/charts/${chart.chartID}`} />;
return <Redirect to={`/games/${chart.game}/charts/${chart.chartID}`} />;
}
function SongInfoHeader({
game,
playtype,
song,
charts,
activeChart,
setActiveChart,
}: {
activeChart: MONGO_ChartDocument | null;
setActiveChart: SetState<MONGO_ChartDocument | null>;
activeChart: ChartDocument | null;
setActiveChart: SetState<ChartDocument | null>;
} & GamePT &
SongsReturn) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const sortedCharts = charts.slice(0).sort(
gptConfig.difficulties.type === "DYNAMIC"
gameConfig.difficulties.type === "DYNAMIC"
? StrSOV((x) => x.difficulty)
: NumericSOV((x) =>
// can TS *really* not infer this?
(gptConfig.difficulties as FixedDifficulties<string>).order.indexOf(
(gameConfig.difficulties as FixedDifficulties<string>).order.indexOf(
x.difficulty,
),
),
);
const gameGroup = GameToGameGroup(game);
return (
<Card header="Song Info">
<Row className="align-items-center justify-content-evenly">
{game !== "bms" && game !== "pms" && (
{gameGroup !== "bms" && gameGroup !== "pms" && (
<Col className="text-center" lg={3} xs={12}>
{/* empty padding :) */}
</Col>
@@ -374,7 +346,7 @@ function SongInfoHeader({
<Col className="text-center" lg={4} xs={12}>
<SongChartInfoFormat {...{ game, song, chart: activeChart }} />
</Col>
{game !== "bms" && game !== "pms" && (
{gameGroup !== "bms" && gameGroup !== "pms" && (
<Col className="text-center" lg={3} xs={12}>
<h5>Charts</h5>
<hr />
@@ -382,13 +354,12 @@ function SongInfoHeader({
className="btn-group-vertical d-flex justify-content-center"
role="group"
>
{game === "iidx" ? (
{gameGroup === "iidx" ? (
<IIDXDifficultyList
{...{
activeChart,
charts: sortedCharts,
game,
playtype,
setActiveChart,
song,
}}
@@ -399,7 +370,6 @@ function SongInfoHeader({
activeChart,
charts: sortedCharts,
game,
playtype,
setActiveChart,
song,
}}
@@ -411,12 +381,7 @@ function SongInfoHeader({
{activeChart && (
<Col xs={12}>
<hr />
<ChartInfoFormat
chart={activeChart}
game={game}
playtype={playtype}
song={song}
/>
<ChartInfoFormat chart={activeChart} game={game} song={song} />
</Col>
)}
</Row>
@@ -425,9 +390,9 @@ function SongInfoHeader({
}
type Props = {
activeChart: MONGO_ChartDocument | null;
setActiveChart: SetState<MONGO_ChartDocument | null>;
} & { song: MONGO_SongDocument } & GamePT;
activeChart: ChartDocument | null;
setActiveChart: SetState<ChartDocument | null>;
} & { song: SongDocument } & GamePT;
const ITG_COLOUR_LOOKUP = {
Beginner: COLOUR_SET.paleBlue,
@@ -441,13 +406,13 @@ const ITG_COLOUR_LOOKUP = {
function DifficultyButton({
chart,
game,
playtype,
setActiveChart,
activeChart,
}: { chart: MONGO_ChartDocument } & Props) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
}: { chart: ChartDocument } & Props) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const diffTag = chart.difficulty;
const gameGroup = GameToGameGroup(game);
return (
<LinkButton
@@ -462,7 +427,7 @@ function DifficultyButton({
gptImpl.difficultyColours[diffTag],
activeChart?.chartID === chart.chartID ? 0.4 : 0.2,
)
: game === "itg"
: gameGroup === "itg"
? ChangeOpacity(
// @ts-expect-error hack!
ITG_COLOUR_LOOKUP[chart.data.difficultyTag],
@@ -470,19 +435,19 @@ function DifficultyButton({
)
: undefined,
}}
to={CreateChartLink(chart, game)}
to={CreateChartLink(chart)}
variant="secondary"
>
<div
className={activeChart?.chartID === chart.chartID ? "fw-bolder" : ""}
style={{
color:
game === "ongeki" && diffTag === "LUNATIC"
gameGroup === "ongeki" && diffTag === "LUNATIC"
? "light-dark(rgba(140, 30, 40, 1), rgba(255, 180, 180, 1))"
: undefined,
}}
>
{FormatDifficulty(chart, game)}
{FormatDifficulty(chart)}
{chart.isPrimary ? (
""
) : (
@@ -502,9 +467,8 @@ function DifficultyList({
activeChart,
setActiveChart,
game,
playtype,
}: {
charts: MONGO_ChartDocument[];
charts: ChartDocument[];
} & Props) {
return (
<>
@@ -514,7 +478,6 @@ function DifficultyList({
chart={e}
game={game}
key={e.chartID}
playtype={playtype}
setActiveChart={setActiveChart}
song={song}
/>
@@ -533,16 +496,15 @@ function IIDXDifficultyList({
activeChart,
setActiveChart,
game,
playtype,
}: {
charts: MONGO_ChartDocument[];
charts: ChartDocument[];
} & Props) {
const { settings } = useLUGPTSettings<"iidx:DP" | "iidx:SP">();
const { settings } = useLUGPTSettings<GamesForGroup["iidx"]>();
const [set, setSet] = useState<"All Scratch" | "Kichiku" | "Kiraku" | null>(null);
if (
!(activeChart as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">)?.data["2dxtraSet"] &&
!(activeChart as ChartDocument<GamesForGroup["iidx"]>)?.data["2dxtraSet"] &&
!settings?.preferences.gameSpecific.display2DXTra
) {
return (
@@ -550,14 +512,12 @@ function IIDXDifficultyList({
{...{
charts: charts.filter(
// @ts-expect-error hack
(e: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">) =>
e.data["2dxtraSet"] === null,
(e: ChartDocument<GamesForGroup["iidx"]>) => e.data["2dxtraSet"] === null,
),
song,
activeChart,
setActiveChart,
game,
playtype,
}}
/>
);
@@ -583,14 +543,13 @@ function IIDXDifficultyList({
{...{
charts: charts.filter(
// @ts-expect-error hack
(e: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">) =>
(e: ChartDocument<GamesForGroup["iidx"]>) =>
set ? e.difficulty.startsWith(set) : e.data["2dxtraSet"] === null,
),
song,
activeChart,
setActiveChart,
game,
playtype,
}}
/>
</>
+47 -94
View File
@@ -1,4 +1,3 @@
import PlaytypeSelect from "#app/pages/dashboard/games/_game/PlaytypeSelect";
import FoldersMainPage from "#app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage";
import LeaderboardsPage from "#app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage";
import OverviewPage from "#app/pages/dashboard/users/games/_game/_playtype/OverviewPage";
@@ -27,18 +26,11 @@ import { UserContext } from "#context/UserContext";
import { UserSettingsContext } from "#context/UserSettingsContext";
import { type UGPTStatsReturn } from "#types/api-returns";
import { APIFetchV1, type APIFetchV1Return, ToAPIURL } from "#util/api";
import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts";
import { IsSupportedGame } from "#util/asserts";
import React, { useContext, useEffect } from "react";
import { useQuery } from "react-query";
import { Redirect, Route, Switch, useHistory, useParams } from "react-router-dom";
import {
FormatGameGroup,
type GameGroup,
GetGameGroupConfig,
type MONGO_UserDocument,
type MONGO_UserGameStats,
type Playtype,
} from "tachi-common";
import { FormatGame, type UserDocument, type UserGameStats, type V3Game } from "tachi-common";
import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage";
import UserPage from "../pages/dashboard/users/UserPage";
@@ -48,7 +40,7 @@ export default function UserRoutes() {
const { userID } = useParams<{ userID: string }>();
const history = useHistory();
const { data: reqUser, error } = useApiQuery<MONGO_UserDocument>(`/users/${params.userID}`);
const { data: reqUser, error } = useApiQuery<UserDocument>(`/users/${params.userID}`);
const { setBackground } = useContext(BackgroundContext);
useEffect(() => {
@@ -105,7 +97,7 @@ export default function UserRoutes() {
);
}
function UserProfileRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
function UserProfileRoutes({ reqUser }: { reqUser: UserDocument }) {
const { settings } = useContext(UserSettingsContext);
return (
@@ -150,81 +142,43 @@ function UserProfileRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function UserGameRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
const { game } = useParams<{ game: string }>();
if (!IsSupportedGame(game)) {
return <ErrorPage customMessage={`The game ${game} is not supported.`} statusCode={400} />;
}
const gameConfig = GetGameGroupConfig(game);
function UserGameRoutes({ reqUser }: { reqUser: UserDocument }) {
return (
<Switch>
<Route exact path="/u/:userID/games/:game">
{gameConfig.playtypes.length === 1 ? (
<Redirect
to={`/u/${reqUser.username}/games/${game}/${gameConfig.playtypes[0]}`}
/>
) : (
<PlaytypeSelect
base={`/u/${reqUser.username}/games/${game}`}
game={game}
subheaderCrumbs={["Users", reqUser.username, "Games", gameConfig.name]}
subheaderTitle={`${reqUser.username} ${gameConfig.name} Playtype Select`}
/>
)}
</Route>
<Route path="/u/:userID/games/:game/:playtype">
<UGPTContextProvider>
<TargetsContextProvider>
<UserGamePlaytypeRoutes game={game} reqUser={reqUser} />
</TargetsContextProvider>
</UGPTContextProvider>
<Route path="/u/:userID/games/:game">
<V3UserGameRoutes reqUser={reqUser} />
</Route>
</Switch>
);
}
function UserGamePlaytypeRoutes({
reqUser,
game,
}: {
game: GameGroup;
reqUser: MONGO_UserDocument;
}) {
const { playtype } = useParams<{ playtype: string }>();
function V3UserGameRoutes({ reqUser }: { reqUser: UserDocument }) {
const { game: gameParam } = useParams<{ game: string }>();
if (!IsSupportedPlaytype(game, playtype)) {
if (!IsSupportedGame(gameParam)) {
return (
<ErrorPage
customMessage={`The playtype ${playtype} is not supported.`}
statusCode={400}
/>
<ErrorPage customMessage={`The game ${gameParam} is not supported.`} statusCode={400} />
);
}
return <Inner game={game} playtype={playtype} reqUser={reqUser} />;
const game = gameParam;
return (
<UGPTContextProvider>
<TargetsContextProvider>
<Inner game={game} reqUser={reqUser} />
</TargetsContextProvider>
</UGPTContextProvider>
);
}
function Inner({
reqUser,
game,
playtype,
}: {
game: GameGroup;
playtype: Playtype;
reqUser: MONGO_UserDocument;
}) {
function Inner({ reqUser, game }: { game: V3Game; reqUser: UserDocument }) {
const { user } = useContext(UserContext);
const { data, error } = useQuery<UGPTStatsReturn, APIFetchV1Return<MONGO_UserGameStats>>(
[reqUser.id, game, playtype],
const { data, error } = useQuery<UGPTStatsReturn, APIFetchV1Return<UserGameStats>>(
[reqUser.id, game],
async () => {
const res = await APIFetchV1<UGPTStatsReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}`,
);
const res = await APIFetchV1<UGPTStatsReturn>(`/users/${reqUser.id}/games/${game}`);
if (!res.success) {
console.error(res);
@@ -255,47 +209,46 @@ function Inner({
<LayoutHeaderContainer
footer={
<UGPTBottomNav
baseUrl={`/u/${reqUser.username}/games/${game}/${playtype}`}
baseUrl={`/u/${reqUser.username}/games/${game}`}
game={game}
isRequestedUser={reqUser.id === user?.id}
playtype={playtype}
/>
}
header={`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Profile`}
header={`${reqUser.username}'s ${FormatGame(game)} Profile`}
>
<UGPTHeaderBody game={game} playtype={playtype} reqUser={reqUser} stats={stats} />
<UGPTHeaderBody game={game} reqUser={reqUser} stats={stats} />
</LayoutHeaderContainer>
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype">
<OverviewPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game">
<OverviewPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/scores">
<ScoresPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/scores">
<ScoresPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/folders">
<FoldersMainPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/folders">
<FoldersMainPage game={game} reqUser={reqUser} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/sessions">
<SessionsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/sessions">
<SessionsPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/sessions/:sessionID">
<SpecificSessionPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/sessions/:sessionID">
<SpecificSessionPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/rivals">
<RivalsMainPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/rivals">
<RivalsMainPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/targets">
<TargetsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/targets">
<TargetsPage game={game} reqUser={reqUser} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/leaderboard">
<LeaderboardsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/leaderboard">
<LeaderboardsPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/utils">
<UGPTUtilsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/utils">
<UGPTUtilsPage game={game} reqUser={reqUser} />
</Route>
<RequireAuthAsUserParam>
<Route exact path="/u/:userID/games/:game/:playtype/settings">
<UGPTSettingsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/settings">
<UGPTSettingsPage game={game} reqUser={reqUser} />
</Route>
</RequireAuthAsUserParam>
<Route path="*">
@@ -38,10 +38,10 @@ import { Button, Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
FormatChart,
FormatGameGroup,
GetGamePTConfig,
FormatGame,
GetGameConfig,
GetScoreEnumConfs,
type MONGO_UserDocument,
type UserDocument,
} from "tachi-common";
// Records activity for a group of users on a GPT. Also used for single users.
@@ -57,7 +57,7 @@ export default function Activity({
url: string;
}) {
const [clumped, setClumped] = useState<ClumpedActivity>([]);
const [users, setUsers] = useState<Array<MONGO_UserDocument>>([]);
const [users, setUsers] = useState<Array<UserDocument>>([]);
const [shouldShowGame, setShouldShowGame] = useState(false);
const [exhausted, setExhausted] = useState(false);
@@ -132,7 +132,7 @@ function ActivityInner({
exhausted: boolean;
fetchMoreFrom: (start: number) => void;
shouldShowGame: boolean;
users: Array<MONGO_UserDocument>;
users: Array<UserDocument>;
}) {
const userMap = CreateUserMap(users);
@@ -250,11 +250,11 @@ function ScoresActivity({
}: {
data: ClumpedActivityScores;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
const { game, playtype } = data.scores[0];
const game = data.scores[0].game;
const prettyGame = shouldShowGame ? `${FormatGameGroup(game, playtype)} ` : "";
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
const [show, setShow] = useState(false);
@@ -264,12 +264,7 @@ function ScoresActivity({
if (data.scores.length === 1) {
const score0 = data.scores[0];
subMessage = `a ${prettyGame}score on ${FormatChart(
score0.game,
score0.__related.song,
score0.__related.chart,
true,
)}`;
subMessage = `a ${prettyGame}score on ${FormatChart(score0.__related.chart)}`;
if (score0.comment) {
mutedText = `"${score0.comment}"`;
@@ -278,9 +273,7 @@ function ScoresActivity({
subMessage = `${data.scores.length} ${prettyGame}scores`;
mutedText = TruncateString(
data.scores
.map((e) => FormatChart(e.game, e.__related.song, e.__related.chart, true))
.join(", "),
data.scores.map((e) => FormatChart(e.__related.chart)).join(", "),
100,
);
}
@@ -301,7 +294,7 @@ function ScoresActivity({
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
<div className="timeline-content-title">
<span className="me-2">
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
</span>
<Icon
style={{
@@ -310,8 +303,7 @@ function ScoresActivity({
type={`chevron-${show ? "down" : "right"}`}
/>
<span className="ms-2" style={{ fontSize: "1.15rem" }}>
<UGPTLink game={game} playtype={playtype} reqUser={user} /> highlighted{" "}
{subMessage}!
<UGPTLink game={game} reqUser={user} /> highlighted {subMessage}!
</span>
{mutedText && (
<>
@@ -333,12 +325,7 @@ function ScoresActivity({
{show && (
<>
<Divider />
<ScoreTable
dataset={dataset}
game={game}
noTopDisplayStr
playtype={playtype}
/>
<ScoreTable dataset={dataset} game={game} noTopDisplayStr />
</>
)}
</div>
@@ -353,11 +340,11 @@ function GoalActivity({
}: {
data: ClumpedActivityGoalAchievement;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
const { game, playtype } = data.goals[0];
const game = data.goals[0].game;
const prettyGame = shouldShowGame ? `${FormatGameGroup(game, playtype)} ` : "";
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
const [show, setShow] = useState(false);
@@ -368,7 +355,7 @@ function GoalActivity({
const goal0 = data.goals[0];
subMessage = `${goal0.__related.goal.name}${
shouldShowGame ? ` in ${FormatGameGroup(game, playtype)}` : ""
shouldShowGame ? ` in ${FormatGame(game)}` : ""
}!`;
} else {
subMessage = `${data.goals.length} ${prettyGame}goals`;
@@ -383,7 +370,7 @@ function GoalActivity({
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
<div className="timeline-content-title">
<span className="me-2">
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
</span>
<Icon
style={{
@@ -392,8 +379,7 @@ function GoalActivity({
type={`chevron-${show ? "down" : "right"}`}
/>
<span className="ms-2" style={{ fontSize: "1.15rem" }}>
<UGPTLink game={game} playtype={playtype} reqUser={user} /> achieved{" "}
{subMessage}!
<UGPTLink game={game} reqUser={user} /> achieved {subMessage}!
</span>
{mutedText && (
<>
@@ -438,11 +424,11 @@ function QuestActivity({
}: {
data: ClumpedActivityQuestAchievement;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
const { game, playtype } = data.quest;
const game = data.quest.game;
const prettyGame = shouldShowGame ? FormatGameGroup(game, playtype) : "";
const prettyGame = shouldShowGame ? FormatGame(game) : "";
return (
<div className="timeline-item timeline-hover my-4">
@@ -452,13 +438,12 @@ function QuestActivity({
<div className="timeline-content-title">
<span style={{ fontSize: "1.15rem" }}>
<span className="me-2">
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
</span>
<UGPTLink game={game} playtype={playtype} reqUser={user} /> completed
the{" "}
<UGPTLink game={game} reqUser={user} /> completed the{" "}
<Link
className="text-decoration-none"
to={`/games/${game}/${playtype}/quests/${data.quest.questID}`}
to={`/games/${game}/quests/${data.quest.questID}`}
>
{data.quest.name}
</Link>{" "}
@@ -486,17 +471,16 @@ function SessionActivity({
}: {
data: ClumpedActivitySession;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
const [show, setShow] = useState(false);
const { user: loggedInUser } = useContext(UserContext);
const prettyGame = shouldShowGame ? `${FormatGameGroup(data.game, data.playtype)} ` : "";
const game = data.game;
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR;
const { game, playtype } = data;
return (
<div className="timeline-item timeline-hover">
<div className={`timeline-badge bg-${data.highlight ? "warning" : "secondary"}`}></div>
@@ -504,7 +488,7 @@ function SessionActivity({
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
<div className="timeline-content-title">
<span className="me-2">
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
</span>
<Icon
style={{
@@ -520,7 +504,7 @@ function SessionActivity({
}}
>
{/* worst string formatting ever */}
<UGPTLink game={data.game} playtype={data.playtype} reqUser={user} />{" "}
<UGPTLink game={game} reqUser={user} />{" "}
{isProbablyActive
? user.id === loggedInUser?.id
? "are having"
@@ -564,7 +548,8 @@ function SessionShower({ sessionID }: { sessionID: string }) {
const scoreMap = CreateScoreIDMap(data.scores);
const gptConfig = GetGamePTConfig(data.session.game, data.session.playtype);
const sessionGame = data.session.game;
const gameConfig = GetGameConfig(sessionGame);
const raises = data.scoreInfo.filter((e) => {
const score = scoreMap.get(e.scoreID);
@@ -574,7 +559,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
return false;
}
const enumMetrics = GetScoreEnumConfs(gptConfig);
const enumMetrics = GetScoreEnumConfs(gameConfig);
// for all enum metrics, check if this score beats the minimum relevant enum
// and is a raise.
@@ -601,7 +586,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
<div className="mb-4">This session had no raises.</div>
<div>
<LinkButton
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
to={`/u/${data.user.username}/games/${sessionGame}/sessions/${sessionID}`}
variant="outline-primary"
>
View Full Session
@@ -620,7 +605,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
</Col>
<div className="d-flex w-100 justify-content-center">
<LinkButton
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
to={`/u/${data.user.username}/games/${sessionGame}/sessions/${sessionID}`}
variant="outline-primary"
>
View Full Session
@@ -637,7 +622,7 @@ function ClassAchievementActivity({
}: {
data: ClumpedActivityClassAchievement;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
return (
<div className="timeline-item timeline-hover">
@@ -645,35 +630,39 @@ function ClassAchievementActivity({
<div className="timeline-content">
<div className="timeline-content-inner">
<div className="timeline-content-title">
<span className="me-2">
<ProfilePicture
size="sm"
toGPT={{ game: data.game, playtype: data.playtype }}
user={user}
/>
</span>
<UGPTLink game={data.game} playtype={data.playtype} reqUser={user} />{" "}
achieved{" "}
<ClassBadge
classSet={data.classSet}
classValue={data.classValue}
game={data.game}
playtype={data.playtype}
/>
{shouldShowGame && ` in ${FormatGameGroup(data.game, data.playtype)}`}!
{data.classOldValue !== null && (
<>
{" "}
(Raised from{" "}
<ClassBadge
classSet={data.classSet}
classValue={data.classOldValue}
game={data.game}
playtype={data.playtype}
/>
)
</>
)}
{(() => {
const classGame = data.game;
return (
<>
<span className="me-2">
<ProfilePicture
size="sm"
toGPT={{ game: classGame }}
user={user}
/>
</span>
<UGPTLink game={classGame} reqUser={user} /> achieved{" "}
<ClassBadge
classSet={data.classSet}
classValue={data.classValue}
game={classGame}
/>
{shouldShowGame && ` in ${FormatGame(classGame)}`}!
{data.classOldValue !== null && (
<>
{" "}
(Raised from{" "}
<ClassBadge
classSet={data.classSet}
classValue={data.classOldValue}
game={classGame}
/>
)
</>
)}
</>
);
})()}
</div>
<div className="timeline-content-timestamp">
@@ -689,15 +678,11 @@ function ClassAchievementActivity({
);
}
function UGPTLink({ reqUser, game, playtype }: UGPT) {
// currently
function UGPTLink({ reqUser, game }: UGPT) {
const { user } = useContext(UserContext);
return (
<Link
className="text-decoration-none fw-bold"
to={`/u/${reqUser.username}/games/${game}/${playtype}`}
>
<Link className="text-decoration-none fw-bold" to={`/u/${reqUser.username}/games/${game}`}>
{user?.id === reqUser.id ? "You" : reqUser.username}
{reqUser?.isSupporter ? (
<>
@@ -73,11 +73,7 @@ const getScoreYAxisNotch = (game: GameGroup) => (s: number) => {
};
const strokeColor = (
type:
| "BELLS"
| Difficulties["chunithm:Single"]
| Difficulties["maimaidx:Single"]
| Difficulties["ongeki:Single"],
type: "BELLS" | Difficulties["chunithm"] | Difficulties["maimaidx"] | Difficulties["ongeki"],
) => {
const isLight = getTheme() === "light";
@@ -170,10 +166,7 @@ export default function GekichumaiScoreChart({
duration,
}: {
data: Serie[];
difficulty:
| Difficulties["chunithm:Single"]
| Difficulties["maimaidx:Single"]
| Difficulties["ongeki:Single"];
difficulty: Difficulties["chunithm"] | Difficulties["maimaidx"] | Difficulties["ongeki"];
duration: number;
game: "chunithm" | "maimaidx" | "ongeki";
height?: number | string;
@@ -188,18 +181,18 @@ export default function GekichumaiScoreChart({
if (type === "Score") {
if (game === "chunithm") {
color =
GPT_CLIENT_IMPLEMENTATIONS["chunithm:Single"].difficultyColours[
difficulty as Difficulties["chunithm:Single"]
GPT_CLIENT_IMPLEMENTATIONS.chunithm.difficultyColours[
difficulty as Difficulties["chunithm"]
];
} else if (game === "ongeki") {
color =
GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].difficultyColours[
difficulty as Difficulties["ongeki:Single"]
GPT_CLIENT_IMPLEMENTATIONS.ongeki.difficultyColours[
difficulty as Difficulties["ongeki"]
];
} else if (game === "maimaidx") {
color =
GPT_CLIENT_IMPLEMENTATIONS["maimaidx:Single"].difficultyColours[
difficulty as Difficulties["maimaidx:Single"]
GPT_CLIENT_IMPLEMENTATIONS.maimaidx.difficultyColours[
difficulty as Difficulties["maimaidx"]
];
}
} else if (type === "Bells") {
@@ -1,11 +1,11 @@
import Activity from "#components/activity/Activity";
import { AllLUGPTStatsContext } from "#context/AllLUGPTStatsContext";
import React, { useContext } from "react";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
import { DashboardLoggedInNoScores } from "./DashboardLoggedInNoScores";
export default function DashboardActivity({ user }: { user: MONGO_UserDocument }) {
export default function DashboardActivity({ user }: { user: UserDocument }) {
const { ugs } = useContext(AllLUGPTStatsContext);
if (ugs?.length === 0) {
@@ -2,9 +2,9 @@ import Divider from "#components/util/Divider";
import LinkButton from "#components/util/LinkButton";
import { TachiConfig } from "#lib/config";
import React from "react";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export function DashboardLoggedInNoScores({ user }: { user: MONGO_UserDocument }) {
export function DashboardLoggedInNoScores({ user }: { user: UserDocument }) {
return (
<div>
<span className="display-4">
@@ -4,11 +4,10 @@ import { type GamePT } from "#types/react";
import { UppercaseFirst } from "#util/misc";
import React from "react";
import { Badge } from "react-bootstrap";
import { type Classes, GetGamePTConfig, GetGPTString, type GPTString } from "tachi-common";
import { type Classes, GetGameConfig, type V3Game } from "tachi-common";
export default function ClassBadge<GPT extends GPTString = GPTString>({
export default function ClassBadge<GPT extends V3Game = V3Game>({
game,
playtype,
classSet,
classValue,
showSetOnHover = true,
@@ -18,12 +17,10 @@ export default function ClassBadge<GPT extends GPTString = GPTString>({
showSetOnHover?: boolean;
} & GamePT) {
const classStyle =
// @ts-expect-error hepl i'm trapped in a type factory
GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)].classColours[classSet][classValue];
// @ts-expect-error complex indexed types
GPT_CLIENT_IMPLEMENTATIONS[game].classColours[classSet][classValue];
const data = GetGamePTConfig(game, playtype).classes[classSet].values.find(
(e) => e.id === classValue,
);
const data = GetGameConfig(game).classes[classSet].values.find((e) => e.id === classValue);
if (!data) {
return (
@@ -64,7 +61,9 @@ export default function ClassBadge<GPT extends GPTString = GPTString>({
} else if (data.hoverText) {
return <QuickTooltip tooltipContent={data.hoverText}>{badgeComponent}</QuickTooltip>;
} else if (showSetOnHover) {
<QuickTooltip tooltipContent={UppercaseFirst(classSet)}>{badgeComponent}</QuickTooltip>;
return (
<QuickTooltip tooltipContent={UppercaseFirst(classSet)}>{badgeComponent}</QuickTooltip>
);
}
return badgeComponent;

Some files were not shown because too many files have changed in this diff Show More