From edda03e91637104ac16f1daacb9b0a1663cc34e2 Mon Sep 17 00:00:00 2001 From: zk Date: Mon, 6 Apr 2026 01:25:30 +0100 Subject: [PATCH] sunday 5th apr (rip mark hollis) (#52) * lots of work * feat: move rg-stats in, ensure consistency in db queries * the clocks dont work * almost * ok jesus been and gone now back to usual --- .cursor/rules/coverage-tools.mdc | 11 + .cursor/skills/coverage-tools/SKILL.md | 55 + .github/workflows/server.yml | 4 +- .scripts/make-datadumps.sh | 2 +- .scripts/restore-mongo.sh | 2 +- .scripts/ts_machete.js | 28 + .security-audits/2026-04-05.md | 161 ++ Justfile | 2 + Justfile-apps | 2 +- Justfile-backup | 40 + Justfile-observability | 11 + Justfile-test | 6 +- bun.lock | 564 +---- db/migrations/20260405120000_dirty_queues.sql | 58 + dev/bootstrap.sh | 19 - dev/functions.fish | 2 +- dev/observability/alloy-config.alloy | 20 + .../dashboards/nodejs-application.json | 897 ++++++++ .../grafana/dashboards/tachi-api.json | 1821 +++++++++++++++++ .../provisioning/dashboards/dashboards.yml | 12 + .../provisioning/datasources/datasources.yml | 57 + dev/observability/prometheus.yml | 5 + dev/observability/setup-datasources.sh | 157 ++ dev/postgres-init.sql | 12 + docker-compose-dev.yml | 61 +- docs/Justfile | 2 +- docs/docs/codebase/setup/config.md | 19 +- docs/docs/contributing/components/core.md | 2 +- .../contributing/components/documentation.md | 2 +- docs/docs/contributing/setup.md | 14 +- package.json | 317 ++- patches/winston-loki@6.1.0.patch | 15 - typescript/bot/package.json | 10 +- typescript/bot/src/actions/sync.test.ts | 17 +- .../bot/src/anon-actions/letmein.test.ts | 9 +- .../bot/src/anon-actions/register.test.ts | 13 +- .../bot/src/test-utils/select-constants.ts | 20 + typescript/client/.env | 5 +- typescript/client/example/.env | 5 +- typescript/client/package.json | 10 +- typescript/client/src/app/pages/LoginPage.tsx | 47 +- typescript/client/src/lib/config.ts | 8 +- typescript/client/src/util/api.ts | 10 +- typescript/client/vite.config.ts | 71 + typescript/common/package.json | 3 +- typescript/common/src/config/config.test.ts | 98 +- .../common/src/constants/bms-tables.test.ts | 12 +- typescript/common/src/index.test.ts | 14 +- typescript/common/vitest.config.ts | 12 +- typescript/coverage-tools/package.json | 23 + typescript/coverage-tools/src/index.ts | 8 + typescript/coverage-tools/src/manifest.ts | 38 + .../coverage-tools/src/report-coverage.ts | 233 +++ .../coverage-tools/src/summary-from-final.ts | 76 + typescript/coverage-tools/tsconfig.json | 12 + typescript/db-migration-engine/package.json | 1 - typescript/db/package.json | 3 +- typescript/db/src/generated/index.ts | 4 + typescript/db/src/generated/public/Chart.ts | 2 + typescript/db/src/generated/public/PbDirty.ts | 25 + .../db/src/generated/public/PublicSchema.ts | 6 + .../db/src/generated/public/ScoreRederive.ts | 20 + .../src/generated/public/enqueue_pb_dirty.ts | 5 + .../public/enqueue_score_rederive.ts | 5 + typescript/docs-autogen-scripts/package.json | 1 - typescript/rg-stats/eslint.config.mjs | 22 + typescript/rg-stats/package.json | 62 + .../chunithm-rating.test.ts.snap | 7 + .../__snapshots__/ddr-flare.test.ts.snap | 9 + .../__snapshots__/gitadora-skill.test.ts.snap | 11 + .../itg-highest-unbroken.test.ts.snap | 11 + .../__snapshots__/jubility.test.ts.snap | 13 + .../__snapshots__/maimai-rate.test.ts.snap | 9 + .../__snapshots__/maimaidx-rate.test.ts.snap | 17 + .../__snapshots__/ongeki-rating.test.ts.snap | 27 + .../popn-classpoints.test.ts.snap | 13 + .../__snapshots__/poyashi-bpi.test.ts.snap | 51 + .../__snapshots__/volforce.test.ts.snap | 11 + .../__snapshots__/wacca-rate.test.ts.snap | 21 + .../src/algorithms/chunithm-rating.test.ts | 46 + .../src/algorithms/chunithm-rating.ts | 38 + .../src/algorithms/curator-skill.test.ts | 71 + .../rg-stats/src/algorithms/curator-skill.ts | 35 + .../rg-stats/src/algorithms/ddr-flare.test.ts | 239 +++ .../rg-stats/src/algorithms/ddr-flare.ts | 28 + .../src/algorithms/gitadora-skill.test.ts | 64 + .../rg-stats/src/algorithms/gitadora-skill.ts | 41 + .../algorithms/itg-highest-unbroken.test.ts | 99 + .../src/algorithms/itg-highest-unbroken.ts | 131 ++ .../rg-stats/src/algorithms/jubility.test.ts | 320 +++ .../rg-stats/src/algorithms/jubility.ts | 50 + .../src/algorithms/maimai-rate.test.ts | 82 + .../rg-stats/src/algorithms/maimai-rate.ts | 130 ++ .../src/algorithms/maimaidx-rate.test.ts | 97 + .../rg-stats/src/algorithms/maimaidx-rate.ts | 98 + .../src/algorithms/ongeki-rating.test.ts | 169 ++ .../rg-stats/src/algorithms/ongeki-rating.ts | 147 ++ .../src/algorithms/popn-classpoints.test.ts | 100 + .../src/algorithms/popn-classpoints.ts | 70 + .../rg-stats/src/algorithms/potential.test.ts | 38 + .../rg-stats/src/algorithms/potential.ts | 27 + .../src/algorithms/poyashi-bpi.test.ts | 275 +++ .../rg-stats/src/algorithms/poyashi-bpi.ts | 223 ++ .../rg-stats/src/algorithms/volforce.test.ts | 820 ++++++++ .../rg-stats/src/algorithms/volforce.ts | 315 +++ .../src/algorithms/wacca-rate.test.ts | 202 ++ .../rg-stats/src/algorithms/wacca-rate.ts | 219 ++ typescript/rg-stats/src/index.ts | 14 + typescript/rg-stats/src/test-utils/approx.ts | 14 + .../rg-stats/src/test-utils/test-case.ts | 1 + .../rg-stats/src/test-utils/throw-snapshot.ts | 14 + typescript/rg-stats/src/util/math.ts | 28 + typescript/rg-stats/src/util/misc.ts | 18 + typescript/rg-stats/src/util/options.ts | 14 + typescript/rg-stats/src/util/throw-if.test.ts | 82 + typescript/rg-stats/src/util/throw-if.ts | 59 + typescript/rg-stats/src/util/types.ts | 4 + .../rg-stats/test-data/ongeki-rating.json | 956 +++++++++ typescript/rg-stats/tsconfig.build.json | 12 + typescript/rg-stats/tsconfig.json | 9 + typescript/rg-stats/vitest.config.ts | 37 + typescript/seeds-scripts/package.json | 5 - typescript/server/.env | 7 +- typescript/server/.env.test | 2 +- typescript/server/.gitignore | 3 - typescript/server/package.json | 25 +- .../src/actions/create-api-client.test.ts | 60 + .../server/src/actions/create-api-client.ts | 9 + .../src/actions/patch-ugpt-settings.test.ts | 13 +- .../server/src/actions/patch-ugpt-settings.ts | 7 +- .../src/actions/remove-goal-subscription.ts | 12 +- .../src/actions/test-utils/api-tokens.ts | 5 +- .../src/actions/test-utils/notifications.ts | 6 +- .../src/actions/update-api-client.test.ts | 44 + .../server/src/actions/update-api-client.ts | 9 + .../server/src/actions/update-bpi-data.ts | 19 +- .../server/src/actions/update-dp-tiers.ts | 20 +- .../server/src/actions/update-sp12-data.ts | 18 +- .../src/actions/update-user-settings.test.ts | 10 +- .../src/anon-actions/oauth-token-exchange.ts | 9 +- .../src/anon-actions/reset-password.test.ts | 47 +- .../server/src/anon-actions/reset-password.ts | 16 +- .../src/game-implementations/games/arcaea.ts | 1 + .../src/game-implementations/games/bms-pms.ts | 1 + .../game-implementations/games/chunithm.ts | 1 + .../src/game-implementations/games/ddr.ts | 1 + .../game-implementations/games/gitadora.ts | 1 + .../src/game-implementations/games/iidx.ts | 18 + .../src/game-implementations/games/itg.ts | 1 + .../src/game-implementations/games/jubeat.ts | 1 + .../src/game-implementations/games/maimai.ts | 1 + .../game-implementations/games/maimaidx.ts | 1 + .../src/game-implementations/games/museca.ts | 1 + .../src/game-implementations/games/ongeki.ts | 1 + .../src/game-implementations/games/popn.ts | 1 + .../src/game-implementations/games/sdvx.ts | 1 + .../src/game-implementations/games/usc.ts | 1 + .../src/game-implementations/games/wacca.ts | 1 + .../server/src/game-implementations/types.ts | 9 + .../utils/derivation-checksum.ts | 83 + .../server/src/lib/admin/admin-queries.ts | 24 +- .../server/src/lib/db-formats/action.ts | 12 + .../server/src/lib/db-formats/admin-jobs.ts | 33 + typescript/server/src/lib/db-formats/goal.ts | 38 + .../src/lib/db-formats/import-document.ts | 50 +- .../server/src/lib/db-formats/import-lock.ts | 5 + .../server/src/lib/db-formats/notification.ts | 17 +- .../server/src/lib/db-formats/orphan-score.ts | 12 + typescript/server/src/lib/db-formats/pb.ts | 20 + .../lib/db-formats/priv-account-credential.ts | 5 + typescript/server/src/lib/db-formats/quest.ts | 34 + .../server/src/lib/db-formats/questline.ts | 11 +- typescript/server/src/lib/db-formats/song.ts | 27 + .../src/lib/db-formats/target-documents.ts | 17 +- .../src/lib/db-formats/ugpt-settings.ts | 26 +- typescript/server/src/lib/handlers/sigterm.ts | 17 +- .../server/src/lib/jobs/drain-dirty-queues.ts | 121 ++ .../lib/jobs/inline-job-runner/job-runner.ts | 39 + .../lib/notifications/notifications.test.ts | 5 +- .../lib/score-import/framework/goals/goals.ts | 14 +- .../framework/import-locks/lock.ts | 9 +- .../score-import/framework/orphans/orphans.ts | 7 +- .../framework/pb/create-pb-doc.ts | 9 +- .../framework/pb/rederive-scores.ts | 101 + .../score-import/framework/quests/quests.ts | 39 +- .../score-importing/score-import-main.ts | 6 + .../framework/sessions/sessions.ts | 16 +- .../src/lib/score-mutation/delete-scores.ts | 7 +- typescript/server/src/lib/setup/config.ts | 6 +- .../server/src/lib/targets/goal-utils.ts | 4 +- typescript/server/src/lib/targets/goals.ts | 85 +- typescript/server/src/lib/targets/quests.ts | 89 +- typescript/server/src/main.ts | 35 +- .../server/src/scripts/migrate-to-postgres.ts | 123 +- .../poc-folder-where-sql-raw.test.ts | 99 + .../poc-seeds-shell-injection.test.ts | 36 + .../src/server/middleware/request-logger.ts | 27 +- typescript/server/src/server/prometheus.ts | 86 + .../v1/games/_game/_playtype/charts/router.ts | 4 +- .../_game/_playtype/targets/goals/router.ts | 22 +- .../_game/_playtype/targets/quests/router.ts | 26 +- .../server/router/api/v1/localdev/router.ts | 26 + .../_game/_playtype/targets/goals/router.ts | 43 +- .../_game/_playtype/targets/quests/router.ts | 55 +- .../games/_game/_playtype/targets/router.ts | 76 +- .../v1/users/_userID/notifications/router.ts | 8 +- typescript/server/src/server/router/router.ts | 2 +- typescript/server/src/server/server.ts | 20 +- typescript/server/src/services/pg/seeds.ts | 26 +- .../src/utils/calculations/recalc-scores.ts | 25 +- typescript/server/src/utils/db.ts | 89 +- .../server/src/utils/queries/questlines.ts | 6 +- typescript/server/src/utils/queries/songs.ts | 41 +- .../server/src/utils/queries/summary.ts | 21 +- typescript/server/src/utils/user.ts | 5 +- .../server/src/utils/validate-webhook-uri.ts | 47 + typescript/sieglinde/package.json | 3 +- 217 files changed, 11757 insertions(+), 1496 deletions(-) create mode 100644 .cursor/rules/coverage-tools.mdc create mode 100644 .cursor/skills/coverage-tools/SKILL.md create mode 100644 .security-audits/2026-04-05.md create mode 100644 Justfile-backup create mode 100644 Justfile-observability create mode 100644 db/migrations/20260405120000_dirty_queues.sql create mode 100644 dev/observability/alloy-config.alloy create mode 100644 dev/observability/grafana/dashboards/nodejs-application.json create mode 100644 dev/observability/grafana/dashboards/tachi-api.json create mode 100644 dev/observability/grafana/provisioning/dashboards/dashboards.yml create mode 100644 dev/observability/grafana/provisioning/datasources/datasources.yml create mode 100644 dev/observability/prometheus.yml create mode 100755 dev/observability/setup-datasources.sh delete mode 100644 patches/winston-loki@6.1.0.patch create mode 100644 typescript/bot/src/test-utils/select-constants.ts create mode 100644 typescript/coverage-tools/package.json create mode 100644 typescript/coverage-tools/src/index.ts create mode 100644 typescript/coverage-tools/src/manifest.ts create mode 100644 typescript/coverage-tools/src/report-coverage.ts create mode 100644 typescript/coverage-tools/src/summary-from-final.ts create mode 100644 typescript/coverage-tools/tsconfig.json create mode 100644 typescript/db/src/generated/public/PbDirty.ts create mode 100644 typescript/db/src/generated/public/ScoreRederive.ts create mode 100644 typescript/db/src/generated/public/enqueue_pb_dirty.ts create mode 100644 typescript/db/src/generated/public/enqueue_score_rederive.ts create mode 100644 typescript/rg-stats/eslint.config.mjs create mode 100644 typescript/rg-stats/package.json create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/chunithm-rating.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/ddr-flare.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/gitadora-skill.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/itg-highest-unbroken.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/jubility.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/maimai-rate.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/maimaidx-rate.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/ongeki-rating.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/popn-classpoints.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/poyashi-bpi.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/volforce.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/__snapshots__/wacca-rate.test.ts.snap create mode 100644 typescript/rg-stats/src/algorithms/chunithm-rating.test.ts create mode 100644 typescript/rg-stats/src/algorithms/chunithm-rating.ts create mode 100644 typescript/rg-stats/src/algorithms/curator-skill.test.ts create mode 100644 typescript/rg-stats/src/algorithms/curator-skill.ts create mode 100644 typescript/rg-stats/src/algorithms/ddr-flare.test.ts create mode 100644 typescript/rg-stats/src/algorithms/ddr-flare.ts create mode 100644 typescript/rg-stats/src/algorithms/gitadora-skill.test.ts create mode 100644 typescript/rg-stats/src/algorithms/gitadora-skill.ts create mode 100644 typescript/rg-stats/src/algorithms/itg-highest-unbroken.test.ts create mode 100644 typescript/rg-stats/src/algorithms/itg-highest-unbroken.ts create mode 100644 typescript/rg-stats/src/algorithms/jubility.test.ts create mode 100644 typescript/rg-stats/src/algorithms/jubility.ts create mode 100644 typescript/rg-stats/src/algorithms/maimai-rate.test.ts create mode 100644 typescript/rg-stats/src/algorithms/maimai-rate.ts create mode 100644 typescript/rg-stats/src/algorithms/maimaidx-rate.test.ts create mode 100644 typescript/rg-stats/src/algorithms/maimaidx-rate.ts create mode 100644 typescript/rg-stats/src/algorithms/ongeki-rating.test.ts create mode 100644 typescript/rg-stats/src/algorithms/ongeki-rating.ts create mode 100644 typescript/rg-stats/src/algorithms/popn-classpoints.test.ts create mode 100644 typescript/rg-stats/src/algorithms/popn-classpoints.ts create mode 100644 typescript/rg-stats/src/algorithms/potential.test.ts create mode 100644 typescript/rg-stats/src/algorithms/potential.ts create mode 100644 typescript/rg-stats/src/algorithms/poyashi-bpi.test.ts create mode 100644 typescript/rg-stats/src/algorithms/poyashi-bpi.ts create mode 100644 typescript/rg-stats/src/algorithms/volforce.test.ts create mode 100644 typescript/rg-stats/src/algorithms/volforce.ts create mode 100644 typescript/rg-stats/src/algorithms/wacca-rate.test.ts create mode 100644 typescript/rg-stats/src/algorithms/wacca-rate.ts create mode 100644 typescript/rg-stats/src/index.ts create mode 100644 typescript/rg-stats/src/test-utils/approx.ts create mode 100644 typescript/rg-stats/src/test-utils/test-case.ts create mode 100644 typescript/rg-stats/src/test-utils/throw-snapshot.ts create mode 100644 typescript/rg-stats/src/util/math.ts create mode 100644 typescript/rg-stats/src/util/misc.ts create mode 100644 typescript/rg-stats/src/util/options.ts create mode 100644 typescript/rg-stats/src/util/throw-if.test.ts create mode 100644 typescript/rg-stats/src/util/throw-if.ts create mode 100644 typescript/rg-stats/src/util/types.ts create mode 100644 typescript/rg-stats/test-data/ongeki-rating.json create mode 100644 typescript/rg-stats/tsconfig.build.json create mode 100644 typescript/rg-stats/tsconfig.json create mode 100644 typescript/rg-stats/vitest.config.ts create mode 100644 typescript/server/src/game-implementations/utils/derivation-checksum.ts create mode 100644 typescript/server/src/lib/db-formats/action.ts create mode 100644 typescript/server/src/lib/db-formats/admin-jobs.ts create mode 100644 typescript/server/src/lib/db-formats/goal.ts create mode 100644 typescript/server/src/lib/db-formats/import-lock.ts create mode 100644 typescript/server/src/lib/db-formats/orphan-score.ts create mode 100644 typescript/server/src/lib/db-formats/priv-account-credential.ts create mode 100644 typescript/server/src/lib/db-formats/quest.ts create mode 100644 typescript/server/src/lib/jobs/drain-dirty-queues.ts create mode 100644 typescript/server/src/lib/score-import/framework/pb/rederive-scores.ts create mode 100644 typescript/server/src/security-audit/poc-folder-where-sql-raw.test.ts create mode 100644 typescript/server/src/security-audit/poc-seeds-shell-injection.test.ts create mode 100644 typescript/server/src/server/prometheus.ts create mode 100644 typescript/server/src/utils/validate-webhook-uri.ts diff --git a/.cursor/rules/coverage-tools.mdc b/.cursor/rules/coverage-tools.mdc new file mode 100644 index 000000000..1a474b182 --- /dev/null +++ b/.cursor/rules/coverage-tools.mdc @@ -0,0 +1,11 @@ +--- +description: Pointer to coverage aggregation tooling (see skill) +globs: "{Justfile-test,typescript/**/vitest.config.ts,typescript/coverage-tools/**/*.ts}" +alwaysApply: false +--- + +# Coverage tooling + +Full commands, manifest registration, and API: read [.cursor/skills/coverage-tools/SKILL.md](.cursor/skills/coverage-tools/SKILL.md). + +Quick: after `just test-typescript`, run `just coverage-report` (add `--by-dir`, `--json`, or `--min-lines N --strict` as needed). diff --git a/.cursor/skills/coverage-tools/SKILL.md b/.cursor/skills/coverage-tools/SKILL.md new file mode 100644 index 000000000..850741e99 --- /dev/null +++ b/.cursor/skills/coverage-tools/SKILL.md @@ -0,0 +1,55 @@ +--- +name: coverage-tools +description: >- + Aggregates Vitest v8/Istanbul coverage across Tachi workspaces via + tachi-coverage-tools (manifest, CLI, optional programmatic API). Use when + measuring test coverage, reviewing coverage after changes, adding CI gates, + registering a new Vitest package, or when the user mentions coverage reports, + coverage-final.json, or just coverage-report. +--- + +# Coverage tools (tachi-coverage-tools) + +## When to use + +- After running tests with coverage, to see totals and cold areas. +- When adding a new workspace that runs Vitest with `--coverage`. +- To gate CI on minimum line coverage (`--min-lines` + `--strict`). + +## Prerequisite + +Coverage JSON is produced by Vitest v8 with `--coverage` (e.g. `just test-typescript`). Output is under each package’s `coverage/` directory (gitignored). If reports are missing, run tests with coverage first. + +## Commands (repo root) + +| Command | Purpose | +|--------|---------| +| `just coverage-report` | Table: lines, statements, functions, branches per registered package | +| `just coverage-report --by-dir` | Same plus top-level `src/*` buckets (server/bot) | +| `just coverage-report --json` | Machine-readable `CoverageReport` on stdout | +| `just coverage-report --packages server` | Filter by manifest `id` (comma-separated) | +| `just coverage-report --min-lines 80 --strict` | Exit non-zero if files missing or line % below floor | + +Implementation: workspace package `typescript/coverage-tools` (`bun run --filter tachi-coverage-tools report`). + +## Registering a new Vitest package + +Edit `typescript/coverage-tools/src/manifest.ts`: add a `CoverageSource` with: + +- `id` — short name for `--packages` +- `packageRoot` — e.g. `typescript/my-pkg` +- `coverageFinal` — path to `coverage/coverage-final.json` relative to repo root (Vitest default unless `coverage.reportsDirectory` overrides) + +## Programmatic use + +```typescript +import { buildReport, COVERAGE_SOURCES } from "tachi-coverage-tools"; +``` + +`buildReport({ repoRoot, packageIds, byDir })` returns `CoverageReport` (see `typescript/coverage-tools/src/report-coverage.ts`). + +## Related files + +- `typescript/coverage-tools/src/manifest.ts` — source registry +- `typescript/coverage-tools/src/report-coverage.ts` — CLI flags and `buildReport` +- `Justfile-test` — `coverage-report` recipe diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index a90c01890..3d843f644 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -57,8 +57,8 @@ jobs: env: NODE_ENV: "test" PORT: 8080 - MONGO_URL: "127.0.0.1" - REDIS_URL: "127.0.0.1" + MONGO_URL: "localhost" + REDIS_URL: "localhost" docker-push: runs-on: ubuntu-latest diff --git a/.scripts/make-datadumps.sh b/.scripts/make-datadumps.sh index f5d8bc7a7..03f2153a0 100755 --- a/.scripts/make-datadumps.sh +++ b/.scripts/make-datadumps.sh @@ -20,7 +20,7 @@ for kind in "kamai" "boku"; do mongodump --archive --port=$remote_port --db=$kind | mongorestore --archive --nsFrom="$kind.*" --nsTo="anon-$kind.*" - ts-node src/scripts/anonymise-db 127.0.0.1:27017/anon-$kind + ts-node src/scripts/anonymise-db localhost:27017/anon-$kind TCHIS_CONF_LOCATION=$kind.dataset.conf.json5 bun run set-indexes diff --git a/.scripts/restore-mongo.sh b/.scripts/restore-mongo.sh index 44f109102..7d8463a5f 100755 --- a/.scripts/restore-mongo.sh +++ b/.scripts/restore-mongo.sh @@ -46,7 +46,7 @@ docker run --rm \ -v "${DUMP_DIR}:/dump:ro" \ "mongo:$MONGO_VERSION" \ mongorestore \ - --host "127.0.0.1:${MONGO_PORT}" \ + --host "localhost:${MONGO_PORT}" \ "${RESTORE_ARGS[@]}" echo "Done." diff --git a/.scripts/ts_machete.js b/.scripts/ts_machete.js index 8ee667762..15e1383c6 100755 --- a/.scripts/ts_machete.js +++ b/.scripts/ts_machete.js @@ -7,6 +7,10 @@ * Usage: * bun .scripts/ts_machete.js # report unused deps * bun .scripts/ts_machete.js --fix # also remove them from package.json + * + * Root package.json may define `tsMachete.excludeGlobal` (dep names) and + * `tsMachete.exclude` (map of workspace package `name` → dep names) for + * dependencies that are used via scripts, tooling, SCSS, ESLint config, etc. */ import path from "node:path"; @@ -29,6 +33,25 @@ process.chdir(cwd); const rootPkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")); +const tsMacheteConfig = rootPkg.tsMachete ?? {}; +const excludeGlobal = new Set(tsMacheteConfig.excludeGlobal ?? []); +const excludeByPackageName = tsMacheteConfig.exclude ?? {}; + +/** + * @param {string | undefined} packageName workspace package.json `name` + * @param {string} depName + */ +function isTsMacheteExcluded(packageName, depName) { + if (excludeGlobal.has(depName)) { + return true; + } + if (!packageName) { + return false; + } + const per = excludeByPackageName[packageName]; + return Array.isArray(per) && per.includes(depName); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -139,6 +162,8 @@ function getSourceFiles(pkgDir) { // 3. CommonJS require: require("pkg") const IMPORT_PATTERNS = [ /\bfrom\s+["']([^"'\s]+)["']/gm, + // Side-effect imports: import "pkg" / import 'pkg' (no `from` clause) + /\bimport\s+["']([^"'\s]+)["']/gm, /\bimport\(["']([^"'\s]+)["']\)/gm, /\brequire\(["']([^"'\s]+)["']\)/gm, ]; @@ -228,6 +253,9 @@ for (const workspacePattern of rootPkg.workspaces) { } for (const depName of Object.keys(pkg[section])) { + if (isTsMacheteExcluded(pkg.name, depName)) { + continue; + } if (!isDepUsed(depName, usedPackages)) { (unusedBySection[section] ??= []).push(depName); } diff --git a/.security-audits/2026-04-05.md b/.security-audits/2026-04-05.md new file mode 100644 index 000000000..be9486985 --- /dev/null +++ b/.security-audits/2026-04-05.md @@ -0,0 +1,161 @@ +# Security audit — 2026-04-05 + +Internal review of the Tachi monorepo. Focuses on production-facing attack +surface: user-facing API endpoints, auth flows, trust boundaries, and +dependency advisories. + +Regression tests for fixed vulns live in the corresponding action test files. +Informational POCs remain in `typescript/server/src/security-audit/`. + +--- + +## VULN-1 Password reset code never invalidated after use + +**Severity:** High +**Endpoint:** `POST /api/v1/auth/reset-password` +**File:** `typescript/server/src/anon-actions/reset-password.ts` +**Status:** FIXED + +### Description + +`ANON_ACTION_ResetPassword` read the token from `priv_password_reset_token` +with `executeTakeFirstOrThrow` but **never deleted it**. The only constraint +was the 24-hour expiry check (`created_on` vs `GetTimeXHoursAgo(24)`). + +### Fix + +Replaced the `SELECT` with `DELETE ... RETURNING` so the token is atomically +consumed on first use. A second attempt with the same code returns 404. + +### Verification + +`anon-actions/reset-password.test.ts` — "deletes the token from +priv_password_reset_token after use" and "rejects a second use of the +same reset code". + +--- + +## VULN-2 OAuth2 client_secret compared with `!==` (timing side-channel) + +**Severity:** Medium +**Endpoint:** `POST /api/v1/oauth/token` +**File:** `typescript/server/src/anon-actions/oauth-token-exchange.ts` +**Status:** FIXED + +### Description + +The secret comparison used `!==` which short-circuits on the first differing +byte, enabling statistical timing analysis to recover the 42-char secret. + +### Fix + +Replaced with `crypto.timingSafeEqual` (with a length guard for +different-length inputs). + +### Verification + +`server/router/api/v1/oauth/router.test.ts` — "returns 403 for invalid +client secret" covers correct rejection behavior. + +--- + +## VULN-3 Webhook SSRF — user-controlled `webhookUri` with no host restriction + +**Severity:** Medium +**Endpoints:** `POST /api/v1/clients/create`, `PATCH /api/v1/clients/:clientID` +**Files:** `typescript/server/src/actions/create-api-client.ts`, +`typescript/server/src/actions/update-api-client.ts` +**Status:** FIXED + +### Description + +Any authenticated user could create or update an API client with an arbitrary +`webhookUri` targeting internal infrastructure (AWS metadata, localhost, RFC-1918). + +### Fix + +Added `validateWebhookUri()` in `typescript/server/src/utils/validate-webhook-uri.ts`. +Enforces HTTPS-only and rejects loopback, localhost, RFC-1918, link-local, and +`0.0.0.0` addresses. Applied in both `create-api-client.ts` and +`update-api-client.ts`. + +### Verification + +`actions/create-api-client.test.ts` — rejects http, localhost, private IP, +and link-local webhook URIs. `actions/update-api-client.test.ts` — same +checks on the update path, plus confirms null is still accepted. + +--- + +## INFO-1 User enumeration via login & registration + +**Severity:** Low (mitigated by captcha + rate limiting) +**Endpoints:** `POST /api/v1/auth/login`, `POST /api/v1/auth/register` + +### Description + +- **Login:** Returns 404 "This user does not exist" for unknown usernames, vs + 403 "Invalid password" for valid usernames. Allows username enumeration. +- **Register:** Returns 409 "This email is already in use". Allows email + enumeration. + +### Mitigations already in place + +- `AggressiveRateLimitMiddleware` (15 req / 10 min) on both endpoints. +- CAPTCHA validation in production/staging. +- The `forgot-password` and `resend-verify-email` endpoints correctly + return a static response before doing any work, preventing timing leaks. + +### Remediation (optional) + +Return a generic "Invalid username or password" for both login failure +cases. For registration, silently accept duplicate emails and send a +"you already have an account" notification to the existing email instead +of revealing existence to the caller. + +--- + +## Positive findings + +| Area | Notes | +|------|-------| +| **SQL injection** | Search strings use bound Kysely `sql` template parameters. Hostile payloads tested in `songs.test.ts`. | +| **JSONB `sql.lit` keys** | Algorithm/metric keys are whitelisted via `CheckStrProfileAlg` / game config before reaching `sql.lit`. | +| **Password handling** | bcrypt (12 rounds), `crypto.randomBytes` for tokens. | +| **Session config** | `sameSite: "strict"` + `secure: true` in production. Prevents CSRF on cookie-bearing endpoints. | +| **Forgot-password timing** | Response sent *before* DB lookup — cannot enumerate emails via timing. | +| **OAuth2 code** | Single-use (deleted in a transaction during token exchange). | +| **API token lookup** | Done via DB `WHERE` clause (B-tree index), not application-level byte comparison — no timing leak. | +| **IR routes** | Require API token + specific permissions before handlers execute. | +| **Query parser** | `app.set("query parser", "simple")` — disables `qs` nested object parsing, eliminating prototype-pollution vectors through query strings. | +| **Body size limit** | `express.json({ limit: "4mb" })` caps request bodies. | + +--- + +## Dependency advisories + +Snapshot from `bun audit` (2026-04-05). Re-run after bumps. + +| Package | Severity | Production-relevant? | +|---------|----------|---------------------| +| **multer** ≥1.4.4-lts.1 <2.0.2 | High (DoS) | Yes — file upload routes | +| **kysely** ≥0.26.0 ≤0.28.11 | High (SQLi) | MySQL-focused; lower risk on Postgres but worth patching | +| **fast-xml-parser** ≥4.0.0-beta.3 <4.5.5 | High (entity expansion) | Yes — SSS score import, sieglinde | +| **node-fetch** <2.6.7 | High (header leak) | Yes — server-side fetches to KAI/etc | +| **path-to-regexp** <0.1.12 | High (ReDoS) | Yes — via Express router | +| **lodash** ≤4.17.23 | High (template injection) | Low risk — `_.template` not used with user input | +| **vite** ≤4.5.5 | Moderate (SSRF/fs bypass) | No — dev server only | +| **qs** ≥6.7.0 ≤6.14.1 | Low–Moderate (DoS) | Mitigated by `query parser: "simple"` | + +--- + +## Test index + +| Location | Vuln | Notes | +|----------|------|-------| +| `anon-actions/reset-password.test.ts` | VULN-1 | Token invalidation + reuse rejection | +| `server/router/api/v1/oauth/router.test.ts` | VULN-2 | Secret rejection (behavioral) | +| `actions/create-api-client.test.ts` | VULN-3 | webhookUri validation on create | +| `actions/update-api-client.test.ts` | VULN-3 | webhookUri validation on update | +| `security-audit/poc-seeds-shell-injection.test.ts` | Informational | Local-dev only | +| `security-audit/poc-folder-where-sql-raw.test.ts` | Informational | Trusted DB data only | diff --git a/Justfile b/Justfile index 84da88f43..a765fbd09 100644 --- a/Justfile +++ b/Justfile @@ -1,8 +1,10 @@ import "Justfile-gen" import "Justfile-apps" import "Justfile-db" +import "Justfile-backup" import "Justfile-misc" import "Justfile-migrate" +import "Justfile-observability" import "Justfile-test" import "Justfile-bench" import "Justfile-dataset" diff --git a/Justfile-apps b/Justfile-apps index fcee9dcf3..45adba16e 100644 --- a/Justfile-apps +++ b/Justfile-apps @@ -20,4 +20,4 @@ server: bun --watch src/main.ts client: - cd typescript/client && vite \ No newline at end of file + cd typescript/client && bun vite \ No newline at end of file diff --git a/Justfile-backup b/Justfile-backup new file mode 100644 index 000000000..33f0aaa13 --- /dev/null +++ b/Justfile-backup @@ -0,0 +1,40 @@ +# Postgres backup/restore via Docker (host only). Requires `docker` on PATH — not available +# inside the devcontainer, so these recipes fail fast there. + +POSTGRES_CONTAINER := "tachi-postgres" +PG_USER := "tachi" + +[private] +assert-docker-host: + #!/usr/bin/env bash + set -euo pipefail + if ! command -v docker >/dev/null 2>&1; then + echo "error: docker not found in PATH; run this on the host (not inside the devcontainer)." >&2 + exit 1 + fi + +# Dump POSTGRES_DB to ~/Documents/tachi-postgres-backup-.sql.gz +# Usage: just postgres-backup +# just postgres-backup tachi +postgres-backup POSTGRES_DB='tachi_dev': assert-docker-host + #!/usr/bin/env bash + set -euo pipefail + out="${HOME}/Documents/tachi-postgres-backup-$(date +%Y%m%d-%H%M%S).sql.gz" + mkdir -p "${HOME}/Documents" + echo "Writing $out" + docker exec -t "{{POSTGRES_CONTAINER}}" pg_dump -U "{{PG_USER}}" -d "{{POSTGRES_DB}}" --no-owner --no-acl | gzip -c > "$out" + ls -lh "$out" + gzip -t "$out" + +# Restore a gzipped pg_dump into POSTGRES_DB (overwrites existing objects in that DB). +# Usage: just postgres-restore ~/Documents/tachi-postgres-backup-....sql.gz +# just postgres-restore ~/backup.sql.gz tachi +postgres-restore backup_file POSTGRES_DB='tachi_dev': assert-docker-host + #!/usr/bin/env bash + set -euo pipefail + if ! test -f "{{backup_file}}"; then + echo "error: backup file not found: {{backup_file}}" >&2 + exit 1 + fi + echo "Restoring {{backup_file}} into database {{POSTGRES_DB}}..." + gunzip -c "{{backup_file}}" | docker exec -i "{{POSTGRES_CONTAINER}}" psql -U "{{PG_USER}}" -d "{{POSTGRES_DB}}" diff --git a/Justfile-observability b/Justfile-observability new file mode 100644 index 000000000..5d919d6ae --- /dev/null +++ b/Justfile-observability @@ -0,0 +1,11 @@ +# Local observability stack (see docker-compose-dev.yml: tachi-grafana, tachi-alloy). + +grafana: + python3 -c "import webbrowser; webbrowser.open('http://localhost:3005')" + +alloy: + python3 -c "import webbrowser; webbrowser.open('http://localhost:12345')" + +# Reload all Grafana provisioning (datasources + dashboards) and verify health. +grafana-reset: + ./dev/observability/setup-datasources.sh diff --git a/Justfile-test b/Justfile-test index 3871f2be0..5c0a7171a 100644 --- a/Justfile-test +++ b/Justfile-test @@ -70,10 +70,14 @@ test-typescript FILTER="*": bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml +# Summarize Vitest v8 coverage across workspaces (see typescript/coverage-tools). +coverage-report *ARGS: + bun run --filter tachi-coverage-tools report -- {{ARGS}} + # Run all parity suites, or a single one if a name is given. test-parity suite="": - TACHI_SERVER=https://127.0.0.1:8080 bun vitest run {{ if suite != "" { "tests/" + suite + ".test.ts" } else { "" } }} + TACHI_SERVER=https://localhost:8080 bun vitest run {{ if suite != "" { "tests/" + suite + ".test.ts" } else { "" } }} # ==> Orchestration of parallel db tests diff --git a/bun.lock b/bun.lock index d62cc636b..dc7877449 100644 --- a/bun.lock +++ b/bun.lock @@ -19,15 +19,8 @@ "kanel": "^3.16.1", "kanel-kysely": "^0.8.0", "ts-node": "10.9.1", - "tsc-alias": "1.7.1", "typescript": "5.9.3", }, - "peerDependencies": { - "@types/bson": "1.0.11", - "@types/mongodb": "3.6.20", - "@types/node": "20.19.37", - "mongodb": "3.6.3", - }, }, "typescript/bliss": { "name": "bliss", @@ -49,34 +42,26 @@ "dependencies": { "@discordjs/builders": "catalog:", "@discordjs/rest": "catalog:", - "@types/bson": "catalog:", - "@types/humanize-duration": "catalog:", - "@valuabletouch/winston-seq": "catalog:", "bliss": "workspace:*", "deepmerge": "catalog:", "discord-api-types": "catalog:", "discord.js": "catalog:", "dotenv": "catalog:", "express": "catalog:", - "humanize-duration": "catalog:", - "json5": "catalog:", "kysely": "catalog:", "lodash": "catalog:", "luxon": "catalog:", "node-fetch": "catalog:", "pg": "catalog:", "prudence": "catalog:", - "seq-logging": "catalog:", "tachi-common": "workspace:*", "tachi-db": "workspace:*", "ts-node": "catalog:", "tsconfig-paths": "catalog:", "typescript": "catalog:", - "winston": "catalog:", }, "devDependencies": { "@types/express": "catalog:", - "@types/json5": "catalog:", "@types/lodash": "catalog:", "@types/luxon": "catalog:", "@types/node": "catalog:", @@ -97,29 +82,21 @@ "@fullcalendar/daygrid": "catalog:", "@fullcalendar/react": "catalog:", "@nivo/bar": "catalog:", - "@nivo/colors": "catalog:", "@nivo/core": "catalog:", "@nivo/line": "catalog:", - "@nivo/tooltip": "catalog:", "@types/lodash": "catalog:", "@types/natural-compare": "catalog:", "@types/react-google-recaptcha": "catalog:", - "csstype": "catalog:", - "d3-scale": "catalog:", "deepmerge": "catalog:", "dotenv": "catalog:", "fast-json-stable-hash": "catalog:", "formik": "catalog:", - "fs-extra": "catalog:", - "hash.js": "catalog:", "humanize-duration": "catalog:", - "json-2-csv": "catalog:", "lodash": "catalog:", "luxon": "catalog:", "nanoid": "catalog:", "natural-compare": "catalog:", "pretty-bytes": "catalog:", - "prop-types": "catalog:", "prudence": "catalog:", "react": "catalog:", "react-async": "catalog:", @@ -158,25 +135,36 @@ "name": "tachi-common", "version": "2.4.0-dev", "dependencies": { - "@types/express-serve-static-core": "catalog:", "@types/mongodb": "catalog:", - "deepmerge": "catalog:", "prudence": "catalog:", "zod": "catalog:", }, "devDependencies": { "@typescript/native-preview": "catalog:", + "@vitest/coverage-v8": "catalog:", "eslint-config-tachi": "workspace:*", "typescript": "catalog:", "vitest": "catalog:", }, }, + "typescript/coverage-tools": { + "name": "tachi-coverage-tools", + "version": "0.1.0", + "dependencies": { + "istanbul-lib-coverage": "catalog:", + }, + "devDependencies": { + "@types/istanbul-lib-coverage": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "typescript/db": { "name": "tachi-db", "version": "0.1.0-see-project-root", "dependencies": { "kysely": "catalog:", - "pg": "catalog:", }, "devDependencies": { "@types/express": "catalog:", @@ -212,7 +200,6 @@ "name": "tachi-db-migration-engine", "version": "1.0.0", "dependencies": { - "kysely": "catalog:", "pg": "catalog:", }, "devDependencies": { @@ -228,7 +215,6 @@ "name": "tachi-autogen-docs", "version": "1.0.0", "dependencies": { - "commander": "catalog:", "tachi-common": "workspace:*", }, "devDependencies": { @@ -283,6 +269,17 @@ "typescript": "catalog:", }, }, + "typescript/rg-stats": { + "name": "rg-stats", + "version": "1.2.1", + "devDependencies": { + "@typescript/native-preview": "catalog:", + "@vitest/coverage-v8": "catalog:", + "eslint-config-tachi": "workspace:*", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "typescript/seeds-scripts": { "name": "tachi-seeds-scripts", "version": "0.1.0-see-project-root", @@ -296,17 +293,12 @@ "cheerio": "catalog:", "commander": "catalog:", "csv-parse": "catalog:", - "deepmerge": "catalog:", "encoding-japanese": "catalog:", "fast-json-stable-hash": "catalog:", "fast-xml-parser": "catalog:", - "glob": "catalog:", - "html-entities": "catalog:", "iconv-lite": "catalog:", "lodash.get": "catalog:", - "monk": "catalog:", "node-fetch": "catalog:", - "prudence": "catalog:", "tachi-common": "workspace:*", "zod": "catalog:", }, @@ -321,17 +313,13 @@ "version": "2.5.1", "dependencies": { "@aws-sdk/client-s3": "catalog:", - "@aws-sdk/signature-v4-crt": "catalog:", "@types/bcryptjs": "catalog:", - "@types/bson": "catalog:", "@types/bunyan": "catalog:", "@types/connect-redis": "catalog:", "@types/express": "catalog:", "@types/express-rate-limit": "catalog:", "@types/express-serve-static-core": "catalog:", "@types/express-session": "catalog:", - "@types/glob": "catalog:", - "@types/mkdirp": "catalog:", "@types/mongodb": "catalog:", "@types/multer": "catalog:", "@types/node": "catalog:", @@ -339,10 +327,6 @@ "@types/nodemailer": "catalog:", "@types/rate-limit-redis": "catalog:", "@types/redis": "catalog:", - "@types/rimraf": "catalog:", - "@types/safe-json-stringify": "catalog:", - "@types/semver": "catalog:", - "@valuabletouch/winston-seq": "catalog:", "bcryptjs": "catalog:", "bliss": "workspace:*", "bms-table-loader": "catalog:", @@ -362,10 +346,8 @@ "fast-xml-parser": "catalog:", "helmet": "catalog:", "html-entities": "catalog:", - "json5": "catalog:", "kysely": "catalog:", "lodash": "catalog:", - "mkdirp": "catalog:", "mongodb": "catalog:", "monk": "catalog:", "multer": "catalog:", @@ -374,39 +356,26 @@ "node-html-parser": "catalog:", "nodemailer": "catalog:", "pg": "catalog:", + "prom-client": "catalog:", "prudence": "catalog:", "rate-limit-redis": "catalog:", "redis": "catalog:", "rg-stats": "catalog:", - "rimraf": "catalog:", - "safe-json-stringify": "catalog:", - "semver": "catalog:", "tachi-common": "workspace:*", "tachi-db": "workspace:*", "tachi-db-migration-engine": "workspace:*", "ts-node": "catalog:", "typescript": "catalog:", - "uuidv7": "catalog:", - "winston": "catalog:", - "winston-loki": "catalog:", - "winston-transport": "catalog:", "zod": "catalog:", }, "devDependencies": { - "@grpc/grpc-js": "catalog:", - "@types/google-protobuf": "catalog:", "@types/pg": "catalog:", "@types/supertest": "catalog:", "@typescript/native-preview": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-config-tachi": "workspace:*", - "glob": "catalog:", - "google-protobuf": "catalog:", - "grpc-tools": "catalog:", "node-mocks-http": "catalog:", - "nodemon": "catalog:", "supertest": "catalog:", - "ts-protoc-gen": "catalog:", "tsconfig-paths": "catalog:", "vitest": "catalog:", }, @@ -421,7 +390,6 @@ "fast-xml-parser": "catalog:", "node-fetch": "catalog:", "tachi-common": "workspace:*", - "winston": "catalog:", }, "devDependencies": { "@typescript/native-preview": "catalog:", @@ -446,8 +414,6 @@ }, "catalog": { "@aws-sdk/client-s3": "3.49.0", - "@aws-sdk/signature-v4-crt": "3.49.0", - "@babel/core": "7.1.0", "@discordjs/builders": "0.5.0", "@discordjs/rest": "0.1.0-canary.0", "@eslint/js": "^9.20.0", @@ -455,16 +421,12 @@ "@fullcalendar/core": "^6.0.0", "@fullcalendar/daygrid": "^6.0.0", "@fullcalendar/react": "^6.0.0", - "@grpc/grpc-js": "^1.10.6", "@nivo/bar": "0.79.1", - "@nivo/colors": "0.79.1", "@nivo/core": "0.79.0", "@nivo/line": "0.79.1", - "@nivo/tooltip": "0.79.0", "@octokit/app": "15.1.0", "@octokit/webhooks-types": "7.5.1", "@types/bcryptjs": "^2.4.2", - "@types/bson": "1.0.11", "@types/bunyan": "^1.8.8", "@types/connect-redis": "0.0.18", "@types/eslint__js": "8.42.3", @@ -472,16 +434,12 @@ "@types/express-rate-limit": "5.1.3", "@types/express-serve-static-core": "4.17.29", "@types/express-session": "1.17.4", - "@types/glob": "^7.2.0", - "@types/google-protobuf": "^3.15.12", "@types/history": "4.7.11", "@types/humanize-duration": "^3.27.1", - "@types/jest": "^26.0.23", - "@types/json5": "2.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", "@types/lodash": "4.14.172", "@types/lodash.get": "^4.4.7", "@types/luxon": "^2.3.1", - "@types/mkdirp": "1.0.2", "@types/mongodb": "3.6.20", "@types/multer": "1.4.7", "@types/natural-compare": "^1.4.1", @@ -496,17 +454,13 @@ "@types/react-router-bootstrap": "0.26.1", "@types/react-router-dom": "5.1.7", "@types/redis": "2.8.32", - "@types/rimraf": "3.0.2", - "@types/safe-json-stringify": "1.1.2", - "@types/semver": "^7.3.9", "@types/supertest": "2.0.11", + "@types/tap": "^15.0.5", "@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/parser": "^4.27.0", "@typescript-eslint/utils": "^8.24.1", "@typescript/native-preview": "7.0.0-dev.20260309.1", - "@valuabletouch/winston-seq": "1.3.0", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-istanbul": "~3.2.0", "@vitest/coverage-v8": "~3.2.0", "bcryptjs": "2.4.3", "better-sqlite3": "9.4.5", @@ -517,24 +471,17 @@ "bunyan": "1.8.15", "chalk": "4", "cheerio": "^1.0.0-rc.12", - "chokidar": "^3.3.0", "commander": "^8.2.0", "connect-redis": "6.1.1", - "coveralls": "^3.0.11", - "csstype": "3.1.1", "csv-parse": "^5.0.3", - "d3-scale": "3.3.0", "deepmerge": "^4.2.2", "discord-api-types": "0.22.0", "discord.js": "13.6.0", "dotenv": "16.0.0", "encoding-japanese": "^1.0.30", - "eslint": "^7.29.0", "eslint-config-prettier": "^9.1.0", - "eslint-config-standard": "^16.0.3", "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jest": "^24.3.6", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-perfectionist": "^3.9.1", "eslint-plugin-react": "^7.37.4", @@ -547,36 +494,17 @@ "express-session": "1.17.2", "fast-json-stable-hash": "^1.0.3", "fast-xml-parser": "^4.2.5", - "findit": "^2.0.0", - "flow-remove-types": "^2.112.0", - "foreground-child": "^2.0.0", "formik": "2.2.9", - "fs-exists-cached": "^1.0.0", - "fs-extra": "7.0.1", - "glob": "^7.1.6", "globals": "^15.15.0", - "google-protobuf": "^3.21.2", - "grpc-tools": "^1.12.4", - "hash.js": "1.1.7", "helmet": "5.0.2", "html-entities": "^2.3.2", "humanize-duration": "^3.27.1", "iconv-lite": "^0.6.3", - "import-jsx": "^4.0.0", - "ink": "^2.7.1", - "isexe": "^2.0.0", - "istanbul-lib-processinfo": "^2.0.2", - "jackspeak": "^1.4.0", - "jest": "^27.0.4", - "json-2-csv": "3.14.4", - "json5": "2.2.0", + "istanbul-lib-coverage": "^3.2.2", "kysely": "^0.27.5", - "libtap": "^1.1.1", "lodash": "^4.17.23", "lodash.get": "^4.4.2", "luxon": "^2.3.1", - "minipass": "^3.1.1", - "mkdirp": "^1.0.4", "mongodb": "3.6.3", "monk": "^7.3.4", "multer": "2.0.1", @@ -586,19 +514,14 @@ "node-fetch": "2.6.7", "node-html-parser": "^5.3.3", "node-mocks-http": "1.11.0", - "node-preload": "^0.2.1", "node-sass": "9.0.0", "nodemailer": "7.0.11", - "nodemon": "^2.0.20", - "nyc": "^15.1.0", - "opener": "^1.5.1", "pg": "^8.13.3", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "prettier": "3.2.5", "pretty-bytes": "5.6.0", - "process-on-spawn": "^1.0.0", - "prop-types": "15.6.0", + "prom-client": "15.1.3", "prudence": "0.10.0", "rate-limit-redis": "2.1.0", "react": "17.0.2", @@ -613,37 +536,18 @@ "react-router-dom": "5.1.2", "react-select": "^5.6.1", "redis": "3.1.2", - "rg-stats": "1.2.1", - "rimraf": "^3.0.0", - "safe-json-stringify": "1.2.0", + "rg-stats": "workspace:*", "sass": "^1.77.0", - "semver": "^7.3.7", - "seq-logging": "1.1.1", - "signal-exit": "^3.0.0", - "source-map-support": "^0.5.16", - "standard": "^16.0.3", "supertest": "6.2.2", "sync-fetch": "^0.3.1", - "tap-mocha-reporter": "^5.0.0", - "tap-parser": "^10.0.1", - "tap-yaml": "^1.0.0", - "tcompare": "^5.0.6", - "treport": "^2.0.2", - "ts-jest": "^27.0.3", + "tap": "^15.1.6", "ts-node": "^8.5.2", - "ts-protoc-gen": "^0.15.0", "tsconfig-paths": "3.12.0", - "tsd": "^0.17.0", "typescript": "^4.3.4", "typescript-eslint": "^8.24.1", - "uuidv7": "^1.1.0", "vite": "^8.0.0", "vite-plugin-html": "^3.2.0", "vitest": "~3.2.0", - "which": "^2.0.2", - "winston": "3.5.1", - "winston-loki": "^6.1.0", - "winston-transport": "4.5.0", "zod": "^4.3.6", }, "packages": { @@ -873,8 +777,6 @@ "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.11.0", "", { "dependencies": { "@bufbuild/protobuf": "2.11.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ=="], - "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], - "@connectrpc/connect": ["@connectrpc/connect@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ=="], "@connectrpc/connect-node": ["@connectrpc/connect-node@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.1" } }, "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg=="], @@ -883,8 +785,6 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], - "@discordjs/builders": ["@discordjs/builders@0.5.0", "", { "dependencies": { "@sindresorhus/is": "^4.0.1", "discord-api-types": "^0.22.0", "ow": "^0.27.0", "ts-mixer": "^6.0.0", "tslib": "^2.3.0" } }, "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw=="], "@discordjs/collection": ["@discordjs/collection@0.1.6", "", {}, "sha512-utRNxnd9kSS2qhyivo9lMlt5qgAUasH2gb7BEOn6p0efFh24gjGomHzWKMAPn2hEReOPQZCJaRKoURwRotKucQ=="], @@ -1005,10 +905,6 @@ "@fullcalendar/react": ["@fullcalendar/react@6.1.20", "", { "peerDependencies": { "@fullcalendar/core": "~6.1.20", "react": "^16.7.0 || ^17 || ^18 || ^19", "react-dom": "^16.7.0 || ^17 || ^18 || ^19" } }, "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], - - "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], - "@httptoolkit/websocket-stream": ["@httptoolkit/websocket-stream@6.0.1", "", { "dependencies": { "@types/ws": "*", "duplexify": "^3.5.1", "inherits": "^2.0.1", "isomorphic-ws": "^4.0.1", "readable-stream": "^2.3.3", "safe-buffer": "^5.1.2", "ws": "*", "xtend": "^4.0.0" } }, "sha512-A0NOZI+Glp3Xgcz6Na7i7o09+/+xm2m0UCU8gdtM2nIv6/cjLmhMZMqehSpTlgbx9omtLmV8LVqOskPEyWnmZQ=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -1023,8 +919,6 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -1037,12 +931,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], - "@kristiandupont/recase": ["@kristiandupont/recase@1.4.1", "", { "dependencies": { "ramda": "^0.30.0" } }, "sha512-e5t4YqhnRGbS9sU4N52cQgTn37qKwTsxDDcIuIkgPvX0UmnL+7eoOR6oFXeCib5zYuw03vYKpR55NBq+W43j1A=="], - "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.3", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -1055,42 +945,6 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], - "@napi-rs/snappy-android-arm-eabi": ["@napi-rs/snappy-android-arm-eabi@7.3.3", "", { "os": "android", "cpu": "arm" }, "sha512-d4vUFFzNBvazGfB/KU8MnEax6itTIgRWXodPdZDnWKHy9HwVBndpCiedQDcSNHcZNYV36rx034rpn7SAuTL2NA=="], - - "@napi-rs/snappy-android-arm64": ["@napi-rs/snappy-android-arm64@7.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Uh+w18dhzjVl85MGhRnojb7OLlX2ErvMsYIunO/7l3Frvc2zQvfqsWsFJanu2dwqlE2YDooeNP84S+ywgN9sxg=="], - - "@napi-rs/snappy-darwin-arm64": ["@napi-rs/snappy-darwin-arm64@7.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AmJn+6yOu/0V0YNHLKmRUNYkn93iv/1wtPayC7O1OHtfY6YqHQ31/MVeeRBiEYtQW9TwVZxXrDirxSB1PxRdtw=="], - - "@napi-rs/snappy-darwin-x64": ["@napi-rs/snappy-darwin-x64@7.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-biLTXBmPjPmO7HIpv+5BaV9Gy/4+QJSUNJW8Pjx1UlWAVnocPy7um+zbvAWStZssTI5sfn/jOClrAegD4w09UA=="], - - "@napi-rs/snappy-freebsd-x64": ["@napi-rs/snappy-freebsd-x64@7.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-E3R3ewm8Mrjm0yL2TC3VgnphDsQaCPixNJqBbGiz3NTshVDhlPlOgPKF0NGYqKiKaDGdD9PKtUgOR4vagUtn7g=="], - - "@napi-rs/snappy-linux-arm-gnueabihf": ["@napi-rs/snappy-linux-arm-gnueabihf@7.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-ZuNgtmk9j0KyT7TfLyEnvZJxOhbkyNR761nk04F0Q4NTHMICP28wQj0xgEsnCHUsEeA9OXrRL4R7waiLn+rOQA=="], - - "@napi-rs/snappy-linux-arm64-gnu": ["@napi-rs/snappy-linux-arm64-gnu@7.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-KIzwtq0dAzshzpqZWjg0Q9lUx93iZN7wCCUzCdLYIQ+mvJZKM10VCdn0RcuQze1R3UJTPwpPLXQIVskNMBYyPA=="], - - "@napi-rs/snappy-linux-arm64-musl": ["@napi-rs/snappy-linux-arm64-musl@7.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-AAED4cQS74xPvktsyVmz5sy8vSxG/+3d7Rq2FDBZzj3Fv6v5vux6uZnECPCAqpALCdTtJ61unqpOyqO7hZCt1Q=="], - - "@napi-rs/snappy-linux-ppc64-gnu": ["@napi-rs/snappy-linux-ppc64-gnu@7.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-pofO5eSLg8ZTBwVae4WHHwJxJGZI8NEb4r5Mppvq12J/1/Hq1HecClXmfY3A7bdT2fsS2Td+Q7CI9VdBOj2sbA=="], - - "@napi-rs/snappy-linux-riscv64-gnu": ["@napi-rs/snappy-linux-riscv64-gnu@7.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-OiHYdeuwj0TVBXADUmmQDQ4lL1TB+8EwmXnFgOutoDVXHaUl0CJFyXLa6tYUXe+gRY8hs1v7eb0vyE97LKY06Q=="], - - "@napi-rs/snappy-linux-s390x-gnu": ["@napi-rs/snappy-linux-s390x-gnu@7.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-66QdmuV9CTq/S/xifZXlMy3PsZTviAgkqqpZ+7vPCmLtuP+nqhaeupShOFf/sIDsS0gZePazPosPTeTBbhkLHg=="], - - "@napi-rs/snappy-linux-x64-gnu": ["@napi-rs/snappy-linux-x64-gnu@7.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-g6KURjOxrgb8yXDEZMuIcHkUr/7TKlDwSiydEQtMtP3n4iI4sNjkcE/WNKlR3+t9bZh1pFGAq7NFRBtouQGHpQ=="], - - "@napi-rs/snappy-linux-x64-musl": ["@napi-rs/snappy-linux-x64-musl@7.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-6UvOyczHknpaKjrlKKSlX3rwpOrfJwiMG6qA0NRKJFgbcCAEUxmN9A8JvW4inP46DKdQ0bekdOxwRtAhFiTDfg=="], - - "@napi-rs/snappy-openharmony-arm64": ["@napi-rs/snappy-openharmony-arm64@7.3.3", "", { "os": "none", "cpu": "arm64" }, "sha512-I5mak/5rTprobf7wMCk0vFhClmWOL/QiIJM4XontysnadmP/R9hAcmuFmoMV2GaxC9MblqLA7Z++gy8ou5hJVw=="], - - "@napi-rs/snappy-wasm32-wasi": ["@napi-rs/snappy-wasm32-wasi@7.3.3", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-+EroeygVYo9RksOchjF206frhMkfD2PaIun3yH4Zp5j/Y0oIEgs/+VhAYx/f+zHRylQYUIdLzDRclcoepvlR8Q=="], - - "@napi-rs/snappy-win32-arm64-msvc": ["@napi-rs/snappy-win32-arm64-msvc@7.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-rxqfntBsCfzgOha/OlG8ld2hs6YSMGhpMUbFjeQLyVDbooY041fRXv3S7yk52DfO6H4QQhLT5+p7cW0mYdhyiQ=="], - - "@napi-rs/snappy-win32-ia32-msvc": ["@napi-rs/snappy-win32-ia32-msvc@7.3.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-joRV16DsRtqjGt0CdSpxGCkO0UlHGeTZ/GqvdscoALpRKbikR2Top4C61dxEchmOd3lSYsXutuwWWGg3Nr++WA=="], - - "@napi-rs/snappy-win32-x64-msvc": ["@napi-rs/snappy-win32-x64-msvc@7.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-cEnQwcsdJyOU7HSZODWsHpKuQoSYM4jaqw/hn9pOXYbRN1+02WxYppD3fdMuKN6TOA6YG5KA5PHRNeVilNX86Q=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@nivo/annotations": ["@nivo/annotations@0.79.1", "", { "dependencies": { "@nivo/colors": "0.79.1", "@react-spring/web": "9.3.1", "lodash": "^4.17.21" }, "peerDependencies": { "@nivo/core": "0.79.0", "react": ">= 16.14.0 < 18.0.0" } }, "sha512-lYso9Luu0maSDtIufwvyVt2+Wue7R9Fh3CIjuRDmNR72UjAgAVEcCar27Fy865UXGsj2hRJZ7KY/1s6kT3gu/w=="], @@ -1211,26 +1065,6 @@ "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], - - "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], - - "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], - - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], - - "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], - - "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], - - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], - - "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], - - "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], - - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], - "@react-aria/ssr": ["@react-aria/ssr@3.9.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ=="], "@react-spring/animated": ["@react-spring/animated@9.3.2", "", { "dependencies": { "@react-spring/shared": "~9.3.0", "@react-spring/types": "~9.3.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0" } }, "sha512-pBvKydRHbTzuyaeHtxGIOvnskZxGo/S5/YK1rtYm88b9NQZuZa95Rgd3O0muFL+99nvBMBL8cvQGD0UJmsqQsg=="], @@ -1343,8 +1177,6 @@ "@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], - "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], - "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], @@ -1393,10 +1225,6 @@ "@types/express-session": ["@types/express-session@1.17.4", "", { "dependencies": { "@types/express": "*" } }, "sha512-7cNlSI8+oOBUHTfPXMwDxF/Lchx5aJ3ho7+p9jJZYVg9dVDJFh3qdMXmJtRsysnvS+C6x46k9DRYmrmCkE+MVg=="], - "@types/glob": ["@types/glob@7.2.0", "", { "dependencies": { "@types/minimatch": "*", "@types/node": "*" } }, "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA=="], - - "@types/google-protobuf": ["@types/google-protobuf@3.15.12", "", {}, "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ=="], - "@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], "@types/history": ["@types/history@4.7.11", "", {}, "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="], @@ -1407,9 +1235,11 @@ "@types/ioredis": ["@types/ioredis@5.0.0", "", { "dependencies": { "ioredis": "*" } }, "sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/json5": ["@types/json5@2.2.0", "", { "dependencies": { "json5": "*" } }, "sha512-NrVug5woqbvNZ0WX+Gv4R+L4TGddtmFek2u8RtccAgFZWtS9QXF2xCXY22/M4nzkaKF0q9Fc6M/5rxLDhfwc/A=="], + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], "@types/lodash": ["@types/lodash@4.14.172", "", {}, "sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw=="], @@ -1423,10 +1253,6 @@ "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], - "@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="], - - "@types/mkdirp": ["@types/mkdirp@1.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ=="], - "@types/mongodb": ["@types/mongodb@3.6.20", "", { "dependencies": { "@types/bson": "*", "@types/node": "*" } }, "sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -1469,22 +1295,14 @@ "@types/redis": ["@types/redis@2.8.32", "", { "dependencies": { "@types/node": "*" } }, "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w=="], - "@types/rimraf": ["@types/rimraf@3.0.2", "", { "dependencies": { "@types/glob": "*", "@types/node": "*" } }, "sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ=="], - - "@types/safe-json-stringify": ["@types/safe-json-stringify@1.1.2", "", {}, "sha512-Hj/LZMBXFH3Qj9sNmNu6syBpkZqBaM00HgP7naf9CnZhB3kdxmrenWUD2cY6vvTWjuBM3NPOLknPKDUOQuTWXA=="], - "@types/scheduler": ["@types/scheduler@0.26.0", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="], - "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], - "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], "@types/supertest": ["@types/supertest@2.0.11", "", { "dependencies": { "@types/superagent": "*" } }, "sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q=="], - "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], - "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "@types/warning": ["@types/warning@3.0.3", "", {}, "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q=="], @@ -1569,8 +1387,6 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], - "@valuabletouch/winston-seq": ["@valuabletouch/winston-seq@1.3.0", "", { "dependencies": { "seq-logging": "^1.1.1", "winston-transport": "^4.4.0" } }, "sha512-j6oHAXTTdhwUap0VJGHIwiKhbw9IffOWYMrMI1Y2uXStNCNSnwIHYHHfjDw46hrynbVN838DRR+voGtYEvzF1w=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], @@ -1589,8 +1405,6 @@ "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], - "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -1601,16 +1415,12 @@ "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], @@ -1649,8 +1459,6 @@ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], - "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -1683,8 +1491,6 @@ "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "binary-parser": ["binary-parser@2.3.0", "", {}, "sha512-TXOlPrhvi+kZUv3vA1qZ+MBdqZdihF8gWw7j/MRINiJqXH652GtLSqFTHNPjG8kHbiMQSCATG9oat9ao6aA8dA=="], "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], @@ -1713,8 +1519,6 @@ "bson": ["bson@1.1.6", "", {}, "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg=="], - "btoa": ["btoa@1.2.1", "", { "bin": { "btoa": "bin/btoa.js" } }, "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -1751,7 +1555,7 @@ "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], - "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], @@ -1761,20 +1565,14 @@ "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], - "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], - "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], - "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -1809,8 +1607,6 @@ "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], - "core-js": ["core-js@1.2.7", "", {}, "sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA=="], - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], @@ -1849,7 +1645,7 @@ "d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="], + "d3-time": ["d3-time@1.1.0", "", {}, "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="], "d3-time-format": ["d3-time-format@3.0.0", "", { "dependencies": { "d3-time": "1 - 2" } }, "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag=="], @@ -1869,8 +1665,6 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "deeks": ["deeks@2.5.1", "", {}, "sha512-fqrBeUz7f1UqaXDRzVB5RG2EfPk15HJRrb2pMZj8mLlSTtz4tRPsK5leFOskoHFPuyZ6+7aRM9j657fvXLkJ7Q=="], - "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], @@ -1911,8 +1705,6 @@ "discord.js": ["discord.js@13.6.0", "", { "dependencies": { "@discordjs/builders": "^0.11.0", "@discordjs/collection": "^0.4.0", "@sapphire/async-queue": "^1.1.9", "@types/node-fetch": "^2.5.12", "@types/ws": "^8.2.2", "discord-api-types": "^0.26.0", "form-data": "^4.0.0", "node-fetch": "^2.6.1", "ws": "^8.4.0" } }, "sha512-tXNR8zgsEPxPBvGk3AQjJ9ljIIC6/LOPjzKwpwz8Y1Q2X66Vi3ZqFgRHYwnHKC0jC0F+l4LzxlhmOJsBZDNg9g=="], - "doc-path": ["doc-path@3.0.2", "", {}, "sha512-VRlA2OKSjTbHWj6wmSanxJ338fE/YN8pqmZ0FIWK5JWkIJMFRc4KmD35JtOrnjvVG0WrzOtDDNHx1lN1tkb+lA=="], - "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], @@ -1947,8 +1739,6 @@ "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], @@ -2115,12 +1905,8 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fbjs": ["fbjs@0.8.18", "", { "dependencies": { "core-js": "^1.0.0", "isomorphic-fetch": "^2.1.1", "loose-envify": "^1.0.0", "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", "ua-parser-js": "^0.7.30" } }, "sha512-EQaWFK+fEPSoibjNy8IxUtaFOMXcWsY0JaVrQoZR9zC8N2Ygf9iDITPWjUTVIax95b6I742JFLqASHfsag/vKA=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], @@ -2139,8 +1925,6 @@ "flatted": ["flatted@3.4.0", "", {}, "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw=="], - "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -2159,7 +1943,7 @@ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], @@ -2175,8 +1959,6 @@ "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], @@ -2205,14 +1987,10 @@ "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], - "google-protobuf": ["google-protobuf@3.21.4", "", {}, "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ=="], - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "grpc-tools": ["grpc-tools@1.13.1", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0" }, "bin": { "grpc_tools_node_protoc": "bin/protoc.js", "grpc_tools_node_protoc_plugin": "bin/protoc_plugin.js" } }, "sha512-0sttMUxThNIkCTJq5qI0xXMz5zWqV2u3yG1kR3Sj9OokGIoyRBFjoInK9NyW7x5fH7knj48Roh1gq5xbl0VoDQ=="], - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -2225,8 +2003,6 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], @@ -2249,8 +2025,6 @@ "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "humanize-duration": ["humanize-duration@3.33.2", "", {}, "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -2259,8 +2033,6 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="], - "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -2295,8 +2067,6 @@ "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], - "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], "is-buffer": ["is-buffer@2.0.5", "", {}, "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="], @@ -2341,8 +2111,6 @@ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], @@ -2355,12 +2123,10 @@ "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "isomorphic-fetch": ["isomorphic-fetch@2.2.1", "", { "dependencies": { "node-fetch": "^1.0.1", "whatwg-fetch": ">=0.10.0" } }, "sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA=="], - "isomorphic-ws": ["isomorphic-ws@4.0.1", "", { "peerDependencies": { "ws": "*" } }, "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -2389,8 +2155,6 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-2-csv": ["json-2-csv@3.14.4", "", { "dependencies": { "deeks": "2.5.1", "doc-path": "3.0.2" } }, "sha512-6Dg4ZJb5g1Lu6gVM2jJGpu1WUo0/b3xS1b35+dXAWmYzlu2AqTXSFrUzA3l5/aYptjhoiGxCN7CZbxBwrUmhig=="], - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], @@ -2399,9 +2163,9 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - "json5": ["json5@2.2.0", "", { "dependencies": { "minimist": "^1.2.5" }, "bin": { "json5": "lib/cli.js" } }, "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA=="], + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], @@ -2417,8 +2181,6 @@ "knex-pglite": ["knex-pglite@0.12.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.x", "knex": "3.x" } }, "sha512-7aMZfDVt2f6MQEdT1ASP/rS8T2bQCIieFGxMry9AO1zEHduI1fwqsOa0D63z3qFzHHLcEGNfSCqOfoUFbXoxtg=="], - "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], - "kysely": ["kysely@0.27.6", "", {}, "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ=="], "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], @@ -2461,8 +2223,6 @@ "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], - "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], - "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="], @@ -2475,17 +2235,13 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], - - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], "luxon": ["luxon@2.5.2", "", {}, "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA=="], @@ -2579,17 +2335,13 @@ "mini-create-react-context": ["mini-create-react-context@0.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.1", "tiny-warning": "^1.0.3" }, "peerDependencies": { "prop-types": "^15.0.0", "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-TtF6hZE59SGmS4U8529qB+jJFeW6asTLDIpPgvPLSCsooAwJS7QprHIFTqv9/Qh3NdLwQxFYgiHX5lqb6jqzPA=="], - "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - - "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], @@ -2629,8 +2381,6 @@ "mv": ["mv@2.1.1", "", { "dependencies": { "mkdirp": "~0.5.1", "ncp": "~2.0.0", "rimraf": "~2.4.0" } }, "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg=="], - "mylas": ["mylas@2.1.14", "", {}, "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog=="], - "nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="], "nano-time": ["nano-time@1.0.0", "", { "dependencies": { "big-integer": "^1.6.16" } }, "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA=="], @@ -2669,12 +2419,6 @@ "nodemailer": ["nodemailer@7.0.11", "", {}, "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw=="], - "nodemon": ["nodemon@2.0.22", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^3.2.7", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", "semver": "^5.7.1", "simple-update-notifier": "^1.0.7", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ=="], - - "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], - - "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="], @@ -2705,8 +2449,6 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "ow": ["ow@0.27.0", "", { "dependencies": { "@sindresorhus/is": "^4.0.1", "callsites": "^3.1.0", "dot-prop": "^6.0.1", "lodash.isequal": "^4.5.0", "type-fest": "^1.2.1", "vali-date": "^1.0.0" } }, "sha512-SGnrGUbhn4VaUGdU0EJLMwZWSupPmF46hnTRII7aCLCrqixTAC5eKo8kI4/XXf1eaaI8YEVT+3FeGNJI9himAQ=="], @@ -2785,8 +2527,6 @@ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], - "plimit-lit": ["plimit-lit@1.6.1", "", { "dependencies": { "queue-lit": "^1.5.1" } }, "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], @@ -2817,32 +2557,24 @@ "prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="], - "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], - - "prop-types": ["prop-types@15.6.0", "", { "dependencies": { "fbjs": "^0.8.16", "loose-envify": "^1.3.1", "object-assign": "^4.1.1" } }, "sha512-H16NHdiZ8szYSKNkCpmKmS8BCogxyABjJ1AqQknIY2iTpy1xC04egoBAzjKm+WU2pbuNxFonw921dnxR0QYAdw=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "prop-types-extra": ["prop-types-extra@1.1.1", "", { "dependencies": { "react-is": "^16.3.2", "warning": "^4.0.0" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew=="], "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], - "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "prudence": ["prudence@0.10.0", "", {}, "sha512-QPyNYQR4tx+9xp+GuceW55MWeZIC/nNT3qWUmU8eij0FmO90x3L08oY9bJiIW1weB7mvH63JBra1cY+GH70YKw=="], - "pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="], - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "qs": ["qs@6.11.0", "", { "dependencies": { "side-channel": "^1.0.4" } }, "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q=="], - "queue-lit": ["queue-lit@1.5.2", "", {}, "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -2895,7 +2627,7 @@ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -2925,8 +2657,6 @@ "remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "require_optional": ["require_optional@1.0.1", "", { "dependencies": { "resolve-from": "^2.0.0", "semver": "^5.1.0" } }, "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g=="], "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], @@ -2941,9 +2671,9 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rg-stats": ["rg-stats@1.2.1", "", {}, "sha512-mpZaD0jxf4xdWS6ddYnfnKG7Ov0okB0OiLEgxCrX4NiHH3TCFTjsCOocFBxprtP/5qiGH7x+daGGjg2L6iK+PQ=="], + "rg-stats": ["rg-stats@workspace:typescript/rg-stats"], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], "rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], @@ -2979,8 +2709,6 @@ "send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], - "seq-logging": ["seq-logging@1.1.1", "", {}, "sha512-9miWILWu22dKNCkZi2UePAnZeQEzaYsQRKbAi5eSBUbuOyyeYPyYO1bEvIvZAZRjltEwY1S0yz94hc0/f+niDg=="], - "serve-static": ["serve-static@1.16.0", "", { "dependencies": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.18.0" } }, "sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -2989,8 +2717,6 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -3013,12 +2739,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "simple-update-notifier": ["simple-update-notifier@1.1.0", "", { "dependencies": { "semver": "~7.0.0" } }, "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg=="], - "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "snappy": ["snappy@7.3.3", "", { "optionalDependencies": { "@napi-rs/snappy-android-arm-eabi": "7.3.3", "@napi-rs/snappy-android-arm64": "7.3.3", "@napi-rs/snappy-darwin-arm64": "7.3.3", "@napi-rs/snappy-darwin-x64": "7.3.3", "@napi-rs/snappy-freebsd-x64": "7.3.3", "@napi-rs/snappy-linux-arm-gnueabihf": "7.3.3", "@napi-rs/snappy-linux-arm64-gnu": "7.3.3", "@napi-rs/snappy-linux-arm64-musl": "7.3.3", "@napi-rs/snappy-linux-ppc64-gnu": "7.3.3", "@napi-rs/snappy-linux-riscv64-gnu": "7.3.3", "@napi-rs/snappy-linux-s390x-gnu": "7.3.3", "@napi-rs/snappy-linux-x64-gnu": "7.3.3", "@napi-rs/snappy-linux-x64-musl": "7.3.3", "@napi-rs/snappy-openharmony-arm64": "7.3.3", "@napi-rs/snappy-wasm32-wasi": "7.3.3", "@napi-rs/snappy-win32-arm64-msvc": "7.3.3", "@napi-rs/snappy-win32-ia32-msvc": "7.3.3", "@napi-rs/snappy-win32-x64-msvc": "7.3.3" } }, "sha512-UDJVCunvgblRpfTOjo/uT7pQzfrTsSICJ4yVS4aq7SsGBaUSpJwaVP15nF//jqinSLpN7boe/BqbUmtWMTQ5MQ=="], - "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -3035,8 +2757,6 @@ "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], - "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -3103,6 +2823,8 @@ "tachi-common": ["tachi-common@workspace:typescript/common"], + "tachi-coverage-tools": ["tachi-coverage-tools@workspace:typescript/coverage-tools"], + "tachi-db": ["tachi-db@workspace:typescript/db"], "tachi-db-migration-engine": ["tachi-db-migration-engine@workspace:typescript/db-migration-engine"], @@ -3119,8 +2841,6 @@ "tagged-comment-parser": ["tagged-comment-parser@1.3.8", "", {}, "sha512-HYkOQ/8ha113ln/Vp48wiDTPtbB59AWOdU44AXjhLyJLJjWWfn2kjnXUcyWLQepwbixA/mEeo1iW2Z9OwaBClQ=="], - "tar": ["tar@7.5.10", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw=="], - "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], @@ -3133,8 +2853,6 @@ "test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], - "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], - "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], "tildify": ["tildify@2.0.0", "", {}, "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw=="], @@ -3161,12 +2879,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], - "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], @@ -3175,10 +2889,6 @@ "ts-node": ["ts-node@10.9.1", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw=="], - "ts-protoc-gen": ["ts-protoc-gen@0.15.0", "", { "dependencies": { "google-protobuf": "^3.15.5" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts" } }, "sha512-TycnzEyrdVDlATJ3bWFTtra3SCiEP0W0vySXReAuEygXCUr1j2uaVyL0DhzjwuUdQoW5oXPwk6oZWeA0955V+g=="], - - "tsc-alias": ["tsc-alias@1.7.1", "", { "dependencies": { "chokidar": "^3.5.3", "commander": "^9.0.0", "globby": "^11.0.4", "mylas": "^2.1.9", "normalize-path": "^3.0.0", "plimit-lit": "^1.2.6" }, "bin": { "tsc-alias": "dist/bin/index.js" } }, "sha512-P4+0i+OB0hX17Ca+U6EJ4WZZ+OSupqW32VJ34N7g7+Ch+bwSx1AqYOvDdIVYEKymBh3dfG0t1qxbxPlBbtB1lQ=="], - "tsconfig-paths": ["tsconfig-paths@3.12.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.1", "minimist": "^1.2.0", "strip-bom": "^3.0.0" } }, "sha512-e5adrnOYT6zqVnWqZu7i/BQ3BnhzvGbjEjejFXO20lKIKpwTaupkCPgEfv4GZK1IBciJUEhYs3J3p75FdaTFVg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -3207,16 +2917,12 @@ "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], - "ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="], - "uid-safe": ["uid-safe@2.1.5", "", { "dependencies": { "random-bytes": "~1.0.0" } }, "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "uncontrollable": ["uncontrollable@7.2.1", "", { "dependencies": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", "invariant": "^2.2.4", "react-lifecycles-compat": "^3.0.4" }, "peerDependencies": { "react": ">=15.0.0" } }, "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ=="], - "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], - "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -3241,7 +2947,7 @@ "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], - "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unload": ["unload@2.2.0", "", { "dependencies": { "@babel/runtime": "^7.6.2", "detect-node": "^2.0.4" } }, "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA=="], @@ -3251,8 +2957,6 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "url-polyfill": ["url-polyfill@1.1.14", "", {}, "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ=="], - "url-value-parser": ["url-value-parser@2.2.0", "", {}, "sha512-yIQdxJpgkPamPPAPuGdS7Q548rLhny42tg8d4vyTNzFqvOnwqrgHXvgehT09U7fwrzxi3RxCiXjoNUNnNOlQ8A=="], "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], @@ -3263,8 +2967,6 @@ "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "uuidv7": ["uuidv7@1.1.0", "", { "bin": { "uuidv7": "cli.js" } }, "sha512-2VNnOC0+XQlwogChUDzy6pe8GQEys9QFZBGOh54l6qVfwoCUwwRvk7rDTgaIsRgsF5GFa5oiNg8LqXE3jofBBg=="], - "uvu": ["uvu@0.5.6", "", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="], "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], @@ -3293,8 +2995,6 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -3311,15 +3011,9 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "winston": ["winston@3.5.1", "", { "dependencies": { "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.3.2", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.4.2" } }, "sha512-tbRtVy+vsSSCLcZq/8nXZaOie/S2tPXPFt4be/Q3vI/WtYwm7rrwidxVw2GRa38FIXcJ1kUM6MOZ9Jmnk3F3UA=="], - - "winston-loki": ["winston-loki@6.1.4", "", { "dependencies": { "async-exit-hook": "2.0.1", "btoa": "^1.2.1", "protobufjs": "^7.2.4", "url-polyfill": "^1.1.12", "winston-transport": "^4.3.0" }, "optionalDependencies": { "snappy": "^7.2.2" } }, "sha512-/j/Zf7TGLjgSBck29BkPnpJlEnGQr5xqlx8A0N6LZnXYYYvyK7lFk6FPpWiD+VMO8xjaxOu1KNF9SzWaRDsigA=="], - - "winston-transport": ["winston-transport@4.5.0", "", { "dependencies": { "logform": "^2.3.2", "readable-stream": "^3.6.0", "triple-beam": "^1.3.0" } }, "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q=="], - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -3329,16 +3023,10 @@ "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -3395,20 +3083,12 @@ "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/source-map/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@kristiandupont/recase/ramda": ["ramda@0.30.1", "", {}, "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw=="], - "@mapbox/node-pre-gyp/consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - - "@napi-rs/snappy-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - - "@nivo/scales/d3-time": ["d3-time@1.1.0", "", {}, "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="], - "@octokit/auth-app/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], "@octokit/auth-oauth-app/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], @@ -3441,8 +3121,6 @@ "@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "@types/minimatch/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - "@types/rate-limit-redis/@types/express-rate-limit": ["@types/express-rate-limit@3.3.5", "", { "dependencies": { "@types/express": "*" } }, "sha512-v6M28DcbdATYvzTN5lFJhN2EP6BgY8MbTskcKJ/m5bmGuzFKHfbEVXF3po8U8ivOyyeT5o6HV0janPlFBBN5yg=="], "@types/superagent/form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -3465,8 +3143,6 @@ "@vitest/mocker/vite": ["vite@3.2.11", "", { "dependencies": { "esbuild": "^0.15.9", "postcss": "^8.4.18", "resolve": "^1.22.1", "rollup": "^2.79.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@types/node": ">= 14", "less": "*", "sass": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "sass", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "ast-v8-to-istanbul/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "aws-crt/@aws-sdk/util-utf8-browser": ["@aws-sdk/util-utf8-browser@3.259.0", "", { "dependencies": { "tslib": "^2.3.1" } }, "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw=="], @@ -3489,22 +3165,22 @@ "body-parser/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], + "broadcast-channel/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "bullmq/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "bullmq/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "cheerio-select/css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], - - "color-string/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], - "css-select/domhandler": ["domhandler@4.3.1", "", { "dependencies": { "domelementtype": "^2.2.0" } }, "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ=="], "css-select/domutils": ["domutils@2.8.0", "", { "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" } }, "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A=="], + "d3-scale/d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="], + + "d3-time-format/d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="], + "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "discord.js/@discordjs/builders": ["@discordjs/builders@0.11.0", "", { "dependencies": { "@sindresorhus/is": "^4.2.0", "discord-api-types": "^0.26.0", "ts-mixer": "^6.0.0", "tslib": "^2.3.1", "zod": "^3.11.6" } }, "sha512-ZTB8yJdJKrKlq44dpWkNUrAtEJEq0gqpb7ASdv4vmq6/mZal5kOv312hQ56I/vxwMre+VIkoHquNUAfnTbiYtg=="], @@ -3535,8 +3211,6 @@ "eslint-plugin-perfectionist/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "eslint-plugin-react/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@2.1.0", "", {}, "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw=="], @@ -3571,12 +3245,8 @@ "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "isomorphic-fetch/node-fetch": ["node-fetch@1.7.3", "", { "dependencies": { "encoding": "^0.1.11", "is-stream": "^1.0.1" } }, "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ=="], - "istanbul-lib-source-maps/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "kanel/rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], - "knex/colorette": ["colorette@2.0.19", "", {}, "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ=="], "knex/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], @@ -3589,84 +3259,50 @@ "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "mqtt/duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], "mqtt/help-me": ["help-me@3.0.0", "", { "dependencies": { "glob": "^7.1.6", "readable-stream": "^3.6.0" } }, "sha512-hx73jClhyk910sidBB7ERlnhMlFsJJIBqSVMFDwPN8o2v9nmp5KgLq1Xz1Bf1fCMMZ6mPrX159iG0VLy/fPMtQ=="], + "mqtt/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "mqtt/split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="], "mqtt/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "mqtt-packet/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "multer/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], - - "mv/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], - "mv/rimraf": ["rimraf@2.4.5", "", { "dependencies": { "glob": "^6.0.1" }, "bin": { "rimraf": "./bin.js" } }, "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ=="], "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "node-mocks-http/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], - "nodemon/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - - "nodemon/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - - "nodemon/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "prop-types-extra/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "react-async-script/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "react-bootstrap/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "react-google-recaptcha/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "react-markdown/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "react-router/path-to-regexp": ["path-to-regexp@1.9.0", "", { "dependencies": { "isarray": "0.0.1" } }, "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g=="], - "react-router/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "react-router/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "react-router-bootstrap/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "react-router-dom/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "react-select/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "react-transition-group/prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "rechoir/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "require_optional/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], - "safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - - "safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - - "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], @@ -3675,8 +3311,6 @@ "serve-static/send": ["send@0.18.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg=="], - "simple-update-notifier/semver": ["semver@7.0.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A=="], - "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -3689,8 +3323,6 @@ "superagent/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], - "tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - "tar-stream/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -3699,12 +3331,6 @@ "test-exclude/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - "tsc-alias/commander": ["commander@9.0.0", "", {}, "sha512-JJfP2saEKbQqvW+FI93OYUB4ByV5cizMpFMiiJI8xDbBvQvSkIk0VvQdn1CZ8mqAO8Loq2h0gYTYtDFUZUeERw=="], - - "tsconfig-paths/@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - - "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "tsutils/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], @@ -3717,13 +3343,15 @@ "vite-node/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - "vite-plugin-html/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "vite-plugin-html/pathe": ["pathe@0.2.0", "", {}, "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw=="], "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - "which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@aws-crypto/crc32/@aws-sdk/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -3731,14 +3359,14 @@ "@aws-crypto/util/@aws-sdk/util-utf8-browser/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@httptoolkit/websocket-stream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "@httptoolkit/websocket-stream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "@httptoolkit/websocket-stream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@octokit/auth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], "@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], @@ -3761,8 +3389,6 @@ "@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], - "@types/minimatch/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], - "@typescript-eslint/experimental-utils/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], @@ -3781,30 +3407,26 @@ "@vitest/mocker/vite/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="], + "bl/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "bl/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "bl/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "color/color-convert/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], - "css-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], "discord.js/@discordjs/builders/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "duplexify/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "duplexify/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "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/tsconfig-paths/@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - - "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "eslint-plugin-perfectionist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "eslint-plugin-react/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "express-session/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3813,35 +3435,13 @@ "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "isomorphic-fetch/node-fetch/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="], - - "kanel/rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "knex/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], "mv/rimraf/glob": ["glob@6.0.4", "", { "dependencies": { "inflight": "^1.0.4", "inherits": "2", "minimatch": "2 || 3", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A=="], - "nodemon/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - - "react-async-script/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-bootstrap/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-google-recaptcha/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-markdown/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-router-bootstrap/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-router-dom/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "react-router/path-to-regexp/isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="], - "react-select/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-transition-group/prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "rimraf/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3871,11 +3471,7 @@ "typescript-eslint/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - "vite-plugin-html/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - - "vite-plugin-html/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "@types/minimatch/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -3891,7 +3487,7 @@ "@vitest/mocker/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.15.18", "", { "os": "linux", "cpu": "none" }, "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ=="], - "kanel/rimraf/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "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=="], @@ -3917,10 +3513,8 @@ "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "kanel/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "kanel/rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/db/migrations/20260405120000_dirty_queues.sql b/db/migrations/20260405120000_dirty_queues.sql new file mode 100644 index 000000000..ff7a792cc --- /dev/null +++ b/db/migrations/20260405120000_dirty_queues.sql @@ -0,0 +1,58 @@ +-- Derivation checksum on chart: a SHA-256 hex digest of the chart fields +-- that feed into scoreDeriver/scoreCalcs. When this changes, all scores +-- on the chart need re-derivation. +ALTER TABLE chart ADD COLUMN derivation_checksum TEXT; + +-- Dirty queue: score mutations enqueue (user_id, chart_id) pairs that +-- need PB recalculation. Deduplication via the PK. +CREATE TABLE pb_dirty ( + user_id BIGINT NOT NULL, + chart_id TEXT NOT NULL, + enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, chart_id) +); + +-- Dirty queue: chart mutations (when derivation_checksum changes) enqueue +-- chart_ids whose scores need re-derivation. One row per chart. +CREATE TABLE score_rederive ( + chart_id TEXT NOT NULL PRIMARY KEY, + enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Trigger function: on any score INSERT/UPDATE/DELETE, mark the +-- (user_id, chart_id) pair as needing PB recalculation. +CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + INSERT INTO pb_dirty (user_id, chart_id) + VALUES (OLD.user_id, OLD.chart_id) + ON CONFLICT DO NOTHING; + ELSE + INSERT INTO pb_dirty (user_id, chart_id) + VALUES (NEW.user_id, NEW.chart_id) + ON CONFLICT DO NOTHING; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER score_pb_dirty + AFTER INSERT OR UPDATE OR DELETE ON score + FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty(); + +-- Trigger function: on chart UPDATE, if derivation_checksum changed, +-- enqueue the chart for score re-derivation. +CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$ +BEGIN + IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN + INSERT INTO score_rederive (chart_id) + VALUES (NEW.id) + ON CONFLICT DO NOTHING; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER chart_score_rederive + AFTER UPDATE ON chart + FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive(); diff --git a/dev/bootstrap.sh b/dev/bootstrap.sh index 659686a8d..0f3ca237b 100755 --- a/dev/bootstrap.sh +++ b/dev/bootstrap.sh @@ -73,24 +73,6 @@ function seedMinioCdn { ) } -function selfSignHTTPS { - if [ -e typescript/server/cert/key.pem ] && [ -e typescript/server/cert/cert.pem ] && openssl x509 -checkend 0 -noout -in typescript/server/cert/cert.pem; then - echo "HTTPS Certificates for local-dev server already exists and has not expired." - return 0 - fi - - echo "Self-Signing HTTPS Certificates for local-dev server..." - - # This is for dev servers only! You should use this to - # create a self-signed HTTPS certificate for local dev. - # That is it. This is not secure. - mkdir -p typescript/server/cert - - openssl req -x509 -newkey rsa:4096 -keyout typescript/server/cert/key.pem -out typescript/server/cert/cert.pem -sha256 -days 365 -nodes -subj "/C=AU/ST=TachiExample/L=TachiExample/O=TachiExample/CN=127.0.0.1" &> /dev/null - - echo "Created HTTPS Certificates!" -} - function bunInstall { echo "Installing dependencies..." @@ -125,7 +107,6 @@ function syncDatabaseWithSeeds { setupShell mvExampleFiles seedMinioCdn -selfSignHTTPS bunInstall if [ -e _SETUP_OK ] && [ $FORCE -eq 0 ]; then diff --git a/dev/functions.fish b/dev/functions.fish index c0acc2beb..ccd672314 100644 --- a/dev/functions.fish +++ b/dev/functions.fish @@ -60,7 +60,7 @@ function fish_greeting echo $(rgb "This machine comes pre-installed with Tachi and helpful tools." ffffff 000000) echo "" echo "Type $(cmd "just start") to start up a frontend and backend." - echo " $(rgb "The server will start on http://127.0.0.1:3000." ffff00 000000)" + echo " $(rgb "The server will start on http://localhost:3000." ffff00 000000)" echo " $(rgb "Use Ctrl+C to stop the server." ffff00 000000)" echo "" echo "Type $(cmd "seeds") to run seeds scripts." diff --git a/dev/observability/alloy-config.alloy b/dev/observability/alloy-config.alloy new file mode 100644 index 000000000..e572ccfcc --- /dev/null +++ b/dev/observability/alloy-config.alloy @@ -0,0 +1,20 @@ +// Scrape Tachi API Prometheus metrics and remote-write to local Prometheus. + +prometheus.remote_write "default" { + endpoint { + url = "http://tachi-prometheus:9090/api/v1/write" + } +} + +prometheus.scrape "tachi_api" { + targets = [ + { + "__address__" = "tachi-dev:9779", + "job" = "tachi-api", + }, + ] + + forward_to = [prometheus.remote_write.default.receiver] + scrape_interval = "15s" + metrics_path = "/metrics" +} diff --git a/dev/observability/grafana/dashboards/nodejs-application.json b/dev/observability/grafana/dashboards/nodejs-application.json new file mode 100644 index 000000000..057fadcc0 --- /dev/null +++ b/dev/observability/grafana/dashboards/nodejs-application.json @@ -0,0 +1,897 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "node.js prometheus client basic metrics", + "editable": true, + "gnetId": 11159, + "graphTooltip": 0, + "id": null, + "iteration": 1573392431370, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 10, + "x": 0, + "y": 0 + }, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(process_cpu_user_seconds_total{instance=~\"$instance\"}[2m]) * 100", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "User CPU - {{instance}}", + "refId": "A" + }, + { + "expr": "irate(process_cpu_system_seconds_total{instance=~\"$instance\"}[2m]) * 100", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Sys CPU - {{instance}}", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Process CPU Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 9, + "x": 10, + "y": 0 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_eventloop_lag_seconds{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Event Loop Lag", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "datasource": "Prometheus", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 5, + "x": 19, + "y": 0 + }, + "id": 2, + "interval": "", + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "__name__", + "targets": [ + { + "expr": "sum(nodejs_version_info{instance=~\"$instance\"}) by (version)", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{version}}", + "refId": "A" + } + ], + "thresholds": "", + "timeFrom": null, + "timeShift": null, + "title": "Node.js Version", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "name" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "datasource": "Prometheus", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 19, + "y": 3 + }, + "id": 4, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "#F2495C", + "show": true + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum(changes(process_start_time_seconds{instance=~\"$instance\"}[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "thresholds": "", + "timeFrom": null, + "timeShift": null, + "title": "Process Restart Times", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 7 + }, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_resident_memory_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Process Memory - {{instance}}", + "refId": "A" + }, + { + "expr": "nodejs_heap_size_total_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Total - {{instance}}", + "refId": "B" + }, + { + "expr": "nodejs_heap_size_used_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Used - {{instance}}", + "refId": "C" + }, + { + "expr": "nodejs_external_memory_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "External Memory - {{instance}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Process Memory Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 7 + }, + "id": 9, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_active_handles_total{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Active Handler - {{instance}}", + "refId": "A" + }, + { + "expr": "nodejs_active_requests_total{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Active Request - {{instance}}", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Active Handlers/Requests Total", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 14 + }, + "id": 10, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_heap_space_size_total_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Total - {{instance}} - {{space}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Heap Total Detail", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 14 + }, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_heap_space_size_used_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Used - {{instance}} - {{space}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Heap Used Detail", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 14 + }, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nodejs_heap_space_size_available_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Heap Used - {{instance}} - {{space}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Heap Available Detail", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 18, + "style": "dark", + "tags": [ + "nodejs" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "definition": "label_values(nodejs_version_info, instance)", + "hide": 0, + "includeAll": true, + "label": "instance", + "multi": true, + "name": "instance", + "options": [], + "query": "label_values(nodejs_version_info, instance)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "", + "title": "NodeJS Application Dashboard", + "uid": "PTSqcpJWk", + "version": 4 +} diff --git a/dev/observability/grafana/dashboards/tachi-api.json b/dev/observability/grafana/dashboards/tachi-api.json new file mode 100644 index 000000000..b3e4ed53d --- /dev/null +++ b/dev/observability/grafana/dashboards/tachi-api.json @@ -0,0 +1,1821 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "prometheus", "uid": "" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Tachi API — endpoint latencies, score import durations, error rates & system health", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Overview", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "id": 1, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Request Rate", + "type": "stat", + "targets": [ + { + "expr": "sum(rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null }, + { "color": "#FF9830", "value": 0.01 }, + { "color": "#F2495C", "value": 0.05 } + ] + }, + "unit": "percentunit", + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "id": 2, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Error Rate (5xx)", + "type": "stat", + "targets": [ + { + "expr": "sum(rate(http_requests_total{job=\"tachi-api\",status_code=~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "id": 3, + "options": { + "colorMode": "background_solid", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "P50 Latency", + "type": "stat", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "#F2495C", "value": 5 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "id": 4, + "options": { + "colorMode": "background_solid", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "P99 Latency", + "type": "stat", + "targets": [ + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "id": 5, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Imports (last hour)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(score_import_duration_seconds_count{job=\"tachi-api\"}[1h]))", + "legendFormat": "", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [ + { "options": { "0": { "color": "red", "text": "DOWN" } }, "type": "value" }, + { "options": { "1": { "color": "green", "text": "UP" } }, "type": "value" } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#F2495C", "value": null }, + { "color": "#73BF69", "value": 1 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "id": 6, + "options": { + "colorMode": "background_solid", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "API Status", + "type": "stat", + "targets": [ + { + "expr": "up{job=\"tachi-api\"}", + "legendFormat": "", + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 200, + "title": "HTTP Endpoints", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 6 }, + "id": 10, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Endpoint Latency (P95)", + "type": "timeseries", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", + "legendFormat": "{{ path }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 6 }, + "id": 11, + "options": { + "legend": { "calcs": ["mean"], "displayMode": "table", "placement": "bottom", "sortBy": "Mean", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Request Rate by Endpoint", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (path) (rate(http_requests_total{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval]))", + "legendFormat": "{{ path }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 15 }, + "id": 12, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Endpoint Latency (P50 vs P99)", + "type": "timeseries", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", + "legendFormat": "P50 (all endpoints)", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", + "legendFormat": "P95 (all endpoints)", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval])))", + "legendFormat": "P99 (all endpoints)", + "refId": "C" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 15 }, + "id": 13, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Total", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Errors by Status Code", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (status_code) (rate(http_requests_total{job=\"tachi-api\",status_code=~\"[45]..\"}[$__rate_interval]))", + "legendFormat": "{{ status_code }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#FF9830", "value": 0.5 }, + { "color": "red", "value": 2 } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Endpoint" }, + "properties": [ + { "id": "custom.width", "value": 350 }, + { "id": "unit", "value": "string" } + ] + }, + { + "matcher": { "id": "byName", "options": "P95 Latency" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + }, + { + "matcher": { "id": "byName", "options": "P99 Latency" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 24 }, + "id": 14, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "P95 Latency" }] + }, + "title": "Slowest Endpoints", + "type": "table", + "transformations": [ + { + "id": "merge", + "options": {} + } + ], + "targets": [ + { + "expr": "topk(20, histogram_quantile(0.95, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m]))))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "A" + }, + { + "expr": "topk(20, histogram_quantile(0.99, sum by (le, path) (rate(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m]))))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "B" + }, + { + "expr": "topk(20, sum by (path) (rate(http_requests_total{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[5m])))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "C" + } + ], + "transformations": [ + { + "id": "seriesToColumns", + "options": { "byField": "path" } + }, + { + "id": "organize", + "options": { + "excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "job": true, "job 1": true, "job 2": true, "instance": true, "instance 1": true, "instance 2": true }, + "renameByName": { + "path": "Endpoint", + "Value #A": "P95 Latency", + "Value #B": "P99 Latency", + "Value #C": "Req/s" + } + } + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 }, + "id": 300, + "title": "Score Imports", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { + "mode": "line+area", + "line": { "color": "#FF9830" }, + "area": { "color": "rgba(255, 152, 48, 0.05)" } + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "transparent", "value": null }, + { "color": "rgba(255,152,48,0.08)", "value": 60 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 34 }, + "id": 20, + "options": { + "legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Import Duration by Type (P95)", + "type": "timeseries", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "{{ import_type }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 34 }, + "id": 21, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Import Duration by Type (P50 vs P99)", + "type": "timeseries", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "P50 {{ import_type }}", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval])))", + "legendFormat": "P99 {{ import_type }}", + "refId": "B" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 43 }, + "id": 22, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Total", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Import Count by Type", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (import_type) (increase(score_import_duration_seconds_count{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "{{ import_type }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#FF9830", "value": 30 }, + { "color": "red", "value": 120 } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Import Type" }, + "properties": [ + { "id": "custom.width", "value": 300 }, + { "id": "unit", "value": "string" } + ] + }, + { + "matcher": { "id": "byName", "options": "P95 Duration" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + }, + { + "matcher": { "id": "byName", "options": "P99 Duration" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + }, + { + "matcher": { "id": "byName", "options": "Max Duration" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 43 }, + "id": 23, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "P95 Duration" }] + }, + "title": "Slow Imports by Type", + "type": "table", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[5m])))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, sum by (le, import_type) (rate(score_import_duration_seconds_bucket{job=\"tachi-api\"}[5m])))", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "B" + }, + { + "expr": "max by (import_type) (score_import_duration_seconds_sum{job=\"tachi-api\"} / score_import_duration_seconds_count{job=\"tachi-api\"})", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "C" + } + ], + "transformations": [ + { + "id": "seriesToColumns", + "options": { "byField": "import_type" } + }, + { + "id": "organize", + "options": { + "excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "job": true, "job 1": true, "job 2": true, "instance": true, "instance 1": true, "instance 2": true }, + "renameByName": { + "import_type": "Import Type", + "Value #A": "P95 Duration", + "Value #B": "P99 Duration", + "Value #C": "Max Duration" + } + } + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "continuous-GrYlRd" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 0, + "scaleDistribution": { "type": "symlog", "log": 2 } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 52 }, + "id": 24, + "options": { + "calculate": false, + "cellGap": 2, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "RdYlGn", + "steps": 128 + }, + "exemplars": { "color": "rgba(255,0,255,0.7)" }, + "filterValues": { "le": 1e-9 }, + "legend": { "show": true }, + "rowsFrame": { "layout": "auto" }, + "tooltip": { "mode": "single", "yHistogram": true }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + }, + "title": "Import Duration Heatmap", + "type": "heatmap", + "targets": [ + { + "expr": "sum by (le) (increase(score_import_duration_seconds_bucket{job=\"tachi-api\"}[$__rate_interval]))", + "format": "heatmap", + "legendFormat": "{{ le }}", + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 60 }, + "id": 600, + "title": "Actions", + "type": "row" + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "#73BF69", "value": null }] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 61 }, + "id": 50, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Actions (last hour)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT COUNT(*) AS \"actions\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#F2495C", "value": null }, + { "color": "#FF9830", "value": 0.90 }, + { "color": "#73BF69", "value": 0.98 } + ] + }, + "unit": "percentunit", + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 61 }, + "id": 51, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Success Rate", + "type": "stat", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT COALESCE(COUNT(*) FILTER (WHERE result = 'GOOD')::float / NULLIF(COUNT(*), 0), 1) AS \"rate\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "#F2495C", "value": 5 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 61 }, + "id": 52, + "options": { + "colorMode": "background_solid", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Avg Duration", + "type": "stat", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (ts_end - ts_start))), 0) AS \"duration\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour'", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#73BF69", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "#F2495C", "value": 5 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 61 }, + "id": 53, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto", + "wideLayout": true + }, + "title": "Errors (THROW)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT COUNT(*) AS \"errors\" FROM action WHERE ts_start >= NOW() - INTERVAL '1 hour' AND result = 'THROW'", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "GOOD" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "BAD" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "THROW" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#F2495C", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 65 }, + "id": 54, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Total", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Action Volume by Result", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), result AS metric, COUNT(*)::float AS value FROM action WHERE $__timeFilter(ts_start) GROUP BY 1, 2 ORDER BY 1", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 65 }, + "id": 55, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "sortBy": "Max", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Action Duration (P50 / P95 / P99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), percentile_cont(0.50) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P50\", percentile_cont(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P95\", percentile_cont(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))) AS \"P99\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY 1", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { + "mode": "line+area", + "line": { "color": "#F2495C" }, + "area": { "color": "rgba(242, 73, 92, 0.05)" } + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "transparent", "value": null }, + { "color": "rgba(242,73,92,0.08)", "value": 0.05 } + ] + }, + "unit": "percentunit", + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 74 }, + "id": 56, + "options": { + "legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Failure Rate Over Time", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), COUNT(*) FILTER (WHERE result != 'GOOD')::float / NULLIF(COUNT(*), 0)::float AS \"Failure Rate\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY 1", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 74 }, + "id": 57, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Total", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Actions by Kind", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT $__timeGroupAlias(ts_start, $__interval), app || '/' || kind AS metric, COUNT(*)::float AS value FROM action WHERE $__timeFilter(ts_start) GROUP BY 1, 2 ORDER BY 1", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Result" }, + "properties": [ + { "id": "custom.width", "value": 80 }, + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + }, + { + "matcher": { "id": "byName", "options": "Duration (s)" }, + "properties": [ + { "id": "custom.width", "value": 120 }, + { "id": "unit", "value": "s" }, + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + }, + { + "matcher": { "id": "byName", "options": "App" }, + "properties": [{ "id": "custom.width", "value": 200 }] + }, + { + "matcher": { "id": "byName", "options": "Kind" }, + "properties": [{ "id": "custom.width", "value": 250 }] + } + ] + }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 83 }, + "id": 58, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "Time" }] + }, + "title": "Recent Actions", + "type": "table", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT ts_start AS \"Time\", app AS \"App\", kind AS \"Kind\", result AS \"Result\", ROUND(EXTRACT(EPOCH FROM (ts_end - ts_start))::numeric, 3) AS \"Duration (s)\" FROM action WHERE $__timeFilter(ts_start) ORDER BY ts_start DESC LIMIT 50", + "format": "table", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#FF9830", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Action" }, + "properties": [ + { "id": "custom.width", "value": 300 }, + { "id": "unit", "value": "string" } + ] + }, + { + "matcher": { "id": "byName", "options": "P95 (s)" }, + "properties": [ + { "id": "unit", "value": "s" }, + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + }, + { + "matcher": { "id": "byName", "options": "P99 (s)" }, + "properties": [ + { "id": "unit", "value": "s" }, + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] + }, + { + "matcher": { "id": "byName", "options": "Throw" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } }, + { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 93 }, + "id": 59, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "P95 (s)" }] + }, + "title": "Action Performance by Kind", + "type": "table", + "targets": [ + { + "datasource": { "type": "postgres", "uid": "${pg_datasource}" }, + "rawSql": "SELECT app || '/' || kind AS \"Action\", COUNT(*) AS \"Total\", COUNT(*) FILTER (WHERE result = 'GOOD') AS \"Good\", COUNT(*) FILTER (WHERE result = 'BAD') AS \"Bad\", COUNT(*) FILTER (WHERE result = 'THROW') AS \"Throw\", ROUND(AVG(EXTRACT(EPOCH FROM (ts_end - ts_start)))::numeric, 3) AS \"Avg (s)\", ROUND((percentile_cont(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))))::numeric, 3) AS \"P95 (s)\", ROUND((percentile_cont(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (ts_end - ts_start))))::numeric, 3) AS \"P99 (s)\" FROM action WHERE $__timeFilter(ts_start) GROUP BY 1 ORDER BY \"P95 (s)\" DESC LIMIT 20", + "format": "table", + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 102 }, + "id": 400, + "title": "HTTP Detail", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 70, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 103 }, + "id": 30, + "options": { + "legend": { "calcs": ["mean"], "displayMode": "table", "placement": "bottom", "sortBy": "Mean", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Request Rate by Method", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (method) (rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "{{ method }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic-by-name" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 70, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": "^2.." }, + "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": "^3.." }, + "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": "^4.." }, + "properties": [{ "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": "^5.." }, + "properties": [{ "id": "color", "value": { "fixedColor": "#F2495C", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 103 }, + "id": 31, + "options": { + "legend": { "calcs": ["mean", "sum"], "displayMode": "table", "placement": "bottom", "sortBy": "Mean", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Request Rate by Status Code", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (status_code) (rate(http_requests_total{job=\"tachi-api\"}[$__rate_interval]))", + "legendFormat": "{{ status_code }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "continuous-GrYlRd" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "scheme", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 0, + "scaleDistribution": { "type": "symlog", "log": 2 } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 111 }, + "id": 32, + "options": { + "calculate": false, + "cellGap": 2, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Turbo", + "steps": 128 + }, + "exemplars": { "color": "rgba(255,0,255,0.7)" }, + "filterValues": { "le": 1e-9 }, + "legend": { "show": true }, + "rowsFrame": { "layout": "auto" }, + "tooltip": { "mode": "single", "yHistogram": true }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + }, + "title": "Request Latency Heatmap", + "type": "heatmap", + "targets": [ + { + "expr": "sum by (le) (increase(http_request_duration_seconds_bucket{job=\"tachi-api\",path!=\"/metrics\",path!=\"/.deploy/up\"}[$__rate_interval]))", + "format": "heatmap", + "legendFormat": "{{ le }}", + "refId": "A" + } + ] + }, + + { + "collapsed": true, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 119 }, + "id": 500, + "title": "System Health (Node.js)", + "type": "row", + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 120 }, + "id": 40, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Memory Usage", + "type": "timeseries", + "targets": [ + { + "expr": "process_resident_memory_bytes{job=\"tachi-api\"}", + "legendFormat": "RSS", + "refId": "A" + }, + { + "expr": "nodejs_heap_size_total_bytes{job=\"tachi-api\"}", + "legendFormat": "Heap Total", + "refId": "B" + }, + { + "expr": "nodejs_heap_size_used_bytes{job=\"tachi-api\"}", + "legendFormat": "Heap Used", + "refId": "C" + }, + { + "expr": "nodejs_external_memory_bytes{job=\"tachi-api\"}", + "legendFormat": "External", + "refId": "D" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "percentunit", + "min": 0 + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 120 }, + "id": 41, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "CPU Usage", + "type": "timeseries", + "targets": [ + { + "expr": "rate(process_cpu_user_seconds_total{job=\"tachi-api\"}[$__rate_interval])", + "legendFormat": "User", + "refId": "A" + }, + { + "expr": "rate(process_cpu_system_seconds_total{job=\"tachi-api\"}[$__rate_interval])", + "legendFormat": "System", + "refId": "B" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { + "mode": "line", + "line": { "color": "red" } + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.1 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 128 }, + "id": 42, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Event Loop Lag", + "type": "timeseries", + "targets": [ + { + "expr": "nodejs_eventloop_lag_seconds{job=\"tachi-api\"}", + "legendFormat": "Event Loop Lag", + "refId": "A" + }, + { + "expr": "nodejs_eventloop_lag_p99_seconds{job=\"tachi-api\"}", + "legendFormat": "P99 Lag", + "refId": "B" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": true, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 128 }, + "id": 43, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Active Handles & Requests", + "type": "timeseries", + "targets": [ + { + "expr": "nodejs_active_handles_total{job=\"tachi-api\"}", + "legendFormat": "Active Handles", + "refId": "A" + }, + { + "expr": "nodejs_active_requests_total{job=\"tachi-api\"}", + "legendFormat": "Active Requests", + "refId": "B" + } + ] + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["tachi", "api", "performance"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "Prometheus", "value": "Prometheus" }, + "hide": 0, + "includeAll": false, + "label": "Data Source", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": { "selected": false, "text": "Postgres", "value": "Postgres" }, + "hide": 0, + "includeAll": false, + "label": "Postgres", + "multi": false, + "name": "pg_datasource", + "options": [], + "query": "postgres", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { "from": "now-3h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Tachi API", + "uid": "tachi-api-overview", + "version": 1 +} diff --git a/dev/observability/grafana/provisioning/dashboards/dashboards.yml b/dev/observability/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 000000000..a9b625e99 --- /dev/null +++ b/dev/observability/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: default + orgId: 1 + folder: "" + type: file + disableDeletion: false + updateIntervalSeconds: 30 + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/dev/observability/grafana/provisioning/datasources/datasources.yml b/dev/observability/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 000000000..874e10b36 --- /dev/null +++ b/dev/observability/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,57 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://tachi-prometheus:9090 + isDefault: true + editable: false + + - name: Postgres (tachi_dev) + type: postgres + access: proxy + url: tachi-postgres:5432 + user: tachi + secureJsonData: + password: tachi + jsonData: + database: tachi_dev + sslmode: disable + maxOpenConns: 5 + maxIdleConns: 2 + connMaxLifetime: 14400 + isDefault: false + editable: true + + - name: Postgres (kamai) + type: postgres + access: proxy + url: tachi-postgres:5432 + user: tachi + secureJsonData: + password: tachi + jsonData: + database: kamai + sslmode: disable + maxOpenConns: 5 + maxIdleConns: 2 + connMaxLifetime: 14400 + isDefault: false + editable: true + + - name: Postgres (boku) + type: postgres + access: proxy + url: tachi-postgres:5432 + user: tachi + secureJsonData: + password: tachi + jsonData: + database: boku + sslmode: disable + maxOpenConns: 5 + maxIdleConns: 2 + connMaxLifetime: 14400 + isDefault: false + editable: true diff --git a/dev/observability/prometheus.yml b/dev/observability/prometheus.yml new file mode 100644 index 000000000..e71edb783 --- /dev/null +++ b/dev/observability/prometheus.yml @@ -0,0 +1,5 @@ +# Metrics are ingested via remote write from Grafana Alloy (see alloy-config.alloy). +global: + scrape_interval: 15s + +scrape_configs: [] diff --git a/dev/observability/setup-datasources.sh b/dev/observability/setup-datasources.sh new file mode 100755 index 000000000..e42295def --- /dev/null +++ b/dev/observability/setup-datasources.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +GRAFANA_URL="${GRAFANA_URL:-http://tachi-grafana:3000}" +GRAFANA_USER="${GRAFANA_USER:-admin}" +GRAFANA_PASS="${GRAFANA_PASS:-admin}" + +PROM_URL="${PROM_URL:-http://tachi-prometheus:9090}" +ALLOY_URL="${ALLOY_URL:-http://tachi-alloy:12345}" + +MAX_WAIT="${MAX_WAIT:-60}" + +auth="${GRAFANA_USER}:${GRAFANA_PASS}" + +wait_for_grafana() { + local elapsed=0 + + printf "Waiting for Grafana at %s " "$GRAFANA_URL" + while ! curl -sf -o /dev/null "${GRAFANA_URL}/api/health" 2>/dev/null; do + if (( elapsed >= MAX_WAIT )); then + printf "\nGrafana did not become ready within %ds\n" "$MAX_WAIT" >&2 + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) + printf "." + done + printf " ready (%ds)\n" "$elapsed" +} + +upsert_datasource() { + local name="$1" type="$2" url="$3" is_default="$4" + + local existing + existing=$(curl -sf -u "$auth" "${GRAFANA_URL}/api/datasources/name/${name}" 2>/dev/null || true) + + local payload + payload=$(cat <&2 + return 1 + fi + else + local resp + resp=$(curl -s -u "$auth" \ + -X POST "${GRAFANA_URL}/api/datasources" \ + -H "Content-Type: application/json" \ + -d "$payload" 2>&1) || true + + if echo "$resp" | grep -q '"datasource"'; then + local uid + uid=$(echo "$resp" | grep -o '"uid":"[^"]*"' | head -1 | cut -d'"' -f4) + printf " %-12s created (uid=%s)\n" "$name" "$uid" + elif echo "$resp" | grep -q "data source with the same name already exists"; then + printf " %-12s already exists (provisioned)\n" "$name" + else + printf " %-12s create failed: %s\n" "$name" "$resp" >&2 + return 1 + fi + fi +} + +health_check() { + local name="$1" + + local resp + resp=$(curl -sf -u "$auth" "${GRAFANA_URL}/api/datasources/name/${name}" 2>/dev/null || true) + + if [ -z "$resp" ]; then + printf " %-12s not found\n" "$name" >&2 + return 1 + fi + + local uid + uid=$(echo "$resp" | grep -o '"uid":"[^"]*"' | head -1 | cut -d'"' -f4) + + local check + check=$(curl -sf -u "$auth" \ + -X POST "${GRAFANA_URL}/api/datasources/uid/${uid}/health" \ + -H "Content-Type: application/json" 2>&1) || true + + if echo "$check" | grep -q '"status":"OK"'; then + printf " %-12s healthy\n" "$name" + elif echo "$check" | grep -q '"status":"ERROR"'; then + local msg + msg=$(echo "$check" | grep -o '"message":"[^"]*"' | head -1 | cut -d'"' -f4) + printf " %-12s unhealthy: %s\n" "$name" "$msg" >&2 + return 1 + else + printf " %-12s health unknown (endpoint may not be supported)\n" "$name" + fi +} + +reload_provisioning() { + local kind="$1" + + local status + status=$(curl -sf -o /dev/null -w "%{http_code}" -u "$auth" \ + -X POST "${GRAFANA_URL}/api/admin/provisioning/${kind}/reload" 2>/dev/null) || true + + if [ "$status" = "200" ]; then + printf " %-12s reloaded\n" "$kind" + else + printf " %-12s reload failed (HTTP %s)\n" "$kind" "$status" >&2 + fi +} + +wait_for_grafana + +echo "Registering datasources:" +upsert_datasource "Prometheus" "prometheus" "$PROM_URL" "true" + +echo "Reloading provisioning:" +reload_provisioning "datasources" +reload_provisioning "dashboards" + +echo "Health checks:" +health_check "Prometheus" +health_check "Postgres" + +echo "Reloading Alloy config:" +alloy_status=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${ALLOY_URL}/-/reload" 2>/dev/null) || true +if [ "$alloy_status" = "200" ]; then + printf " %-12s reloaded\n" "Alloy" +else + printf " %-12s reload failed (HTTP %s)\n" "Alloy" "$alloy_status" >&2 +fi + +echo "Done." diff --git a/dev/postgres-init.sql b/dev/postgres-init.sql index c396b2ab7..2d84ca6ef 100644 --- a/dev/postgres-init.sql +++ b/dev/postgres-init.sql @@ -4,3 +4,15 @@ GRANT ALL PRIVILEGES ON DATABASE tachi_dev TO tachi; -- Match genesis: pg_stat_statements (requires shared_preload_libraries in docker-compose). \connect tachi_dev CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + +-- Read-only user for Grafana dashboards. +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'grafana_ro') THEN + CREATE ROLE grafana_ro WITH LOGIN PASSWORD 'grafana_ro'; + END IF; +END$$; +GRANT CONNECT ON DATABASE tachi_dev TO grafana_ro; +GRANT USAGE ON SCHEMA public TO grafana_ro; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro; +ALTER DEFAULT PRIVILEGES FOR ROLE tachi IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro; diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 3f897c117..1d4b62555 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -76,6 +76,7 @@ services: - "8080:8080" # server - "3000:3000" # client - "3001:3001" # docs + - "9779:9779" # metrics volumes: - ./:/tachi # Mount your home files under your-pc, so you can easily access them @@ -84,9 +85,67 @@ services: - /tachi/node_modules/ - /tachi/.bun + # Local observability (Grafana + Prometheus + Alloy). Grafana UI: http://localhost:3005 + # Alloy scrapes the API metrics endpoint at tachi-dev:9779 and remote-writes into Prometheus. + tachi-grafana: + container_name: tachi-grafana + image: grafana/grafana:12.4.2 + restart: unless-stopped + depends_on: + - tachi-prometheus + ports: + - "3005:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + GF_USERS_ALLOW_SIGN_UP: "false" + GF_SERVER_HTTP_ADDR: 0.0.0.0 + GF_SERVER_HTTP_PORT: "3000" + GF_SERVER_ROOT_URL: http://localhost:3005 + volumes: + - tachi-grafana:/var/lib/grafana + - ./dev/observability/grafana/provisioning:/etc/grafana/provisioning + - ./dev/observability/grafana/dashboards:/var/lib/grafana/dashboards + + tachi-prometheus: + container_name: tachi-prometheus + image: prom/prometheus:v3.11.0 + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + - --web.enable-remote-write-receiver + ports: + - "9090:9090" + volumes: + - tachi-prometheus:/prometheus + - ./dev/observability/prometheus.yml:/etc/prometheus/prometheus.yml + + tachi-alloy: + container_name: tachi-alloy + image: grafana/alloy:v1.15.0 + restart: unless-stopped + depends_on: + - tachi-prometheus + command: + - run + - --server.http.listen-addr=0.0.0.0:12345 + - --storage.path=/var/lib/alloy/data + - /etc/alloy/config.alloy + ports: + - "12345:12345" + # Writable WAL path (named volumes are often root-owned; Alloy runs non-root and can crash-loop). + tmpfs: + - /var/lib/alloy/data + volumes: + - ./dev/observability/alloy-config.alloy:/etc/alloy/config.alloy + volumes: tachi-volume: tachi-redis: tachi-logs: tachi-postgres: - tachi-minio: \ No newline at end of file + tachi-minio: + tachi-grafana: + tachi-prometheus: \ No newline at end of file diff --git a/docs/Justfile b/docs/Justfile index 622319afa..b1d0cfbff 100644 --- a/docs/Justfile +++ b/docs/Justfile @@ -2,6 +2,6 @@ interactive: -@cd ../ && just -# Run just the docs on http://127.0.0.1:3001. +# Run just the docs on http://localhost:3001. start: mkdocs serve -a 0.0.0.0:3001 \ No newline at end of file diff --git a/docs/docs/codebase/setup/config.md b/docs/docs/codebase/setup/config.md index 76c3f6cc4..3a5914851 100644 --- a/docs/docs/codebase/setup/config.md +++ b/docs/docs/codebase/setup/config.md @@ -36,7 +36,6 @@ the main benefits for us are as follows: REDIRECT_URI: "https://example.com", }, ARC_AUTH_TOKEN: "unused", - ENABLE_SERVER_HTTPS: false, OUR_URL: "https://example.com", INVITE_CODE_CONFIG: { BATCH_SIZE: 2, @@ -44,7 +43,7 @@ the main benefits for us are as follows: BETA_USER_BONUS: 5, }, CDN_CONFIG: { - WEB_LOCATION: "http://127.0.0.1:9000/tachi-public", + WEB_LOCATION: "http://localhost:9000/tachi-public", SAVE_LOCATION: { TYPE: "S3_BUCKET", ENDPOINT: "http://tachi-s3:9000", @@ -161,18 +160,6 @@ If present, these define our OAuth2 Client data for interacting with these servi Maybe a better solution would be to have the API_URL inside the OAUTH2_INFO. Ah well. -### ENABLE_SERVER_HTTPS - -- Type: Boolean (optional) - -Whether to use HTTPS. If this is set to true, the files `cert/key.pem` and `cert/cert.pem` will be -used as the privateKey and the certificate, respectively. - -!!! note - In production, we use an Nginx reverse proxy which handles this. - A warning will be emitted if you are using HTTPS mode, as it's not generally meant to be used - at this level. - ### CLIENT_DEV_SERVER - Type: String @@ -374,7 +361,7 @@ If no LOGGER_CONFIG is provided, this is not set. Configures the CDN for the Tachi Server. Files are always stored in an S3-compatible bucket (AWS S3, Backblaze, MinIO, etc.). -For local development with `docker-compose-dev.yml`, run the `tachi-s3` MinIO service and point `SAVE_LOCATION.ENDPOINT` at it (from the `tachi-dev` container, `http://tachi-s3:9000`). Use a `WEB_LOCATION` URL that browsers can load (for example `http://127.0.0.1:9000/` when MinIO’s API port is published to the host). +For local development with `docker-compose-dev.yml`, run the `tachi-s3` MinIO service and point `SAVE_LOCATION.ENDPOINT` at it (from the `tachi-dev` container, `http://tachi-s3:9000`). Use a `WEB_LOCATION` URL that browsers can load (for example `http://localhost:9000/` when MinIO’s API port is published to the host). ```ts interface CDN_CONFIG: { @@ -414,7 +401,7 @@ If not set, this will log a warning and default to 8080. ### MONGO_URL -Where our mongoDB instance is. This would be `127.0.0.1:27017` if hosting on the same box. +Where our mongoDB instance is. This would be `localhost:27017` if hosting on the same box. If not set, this will terminate the process with a critical error. ### SEQ_URL diff --git a/docs/docs/contributing/components/core.md b/docs/docs/contributing/components/core.md index 0f2842573..6de968319 100644 --- a/docs/docs/contributing/components/core.md +++ b/docs/docs/contributing/components/core.md @@ -20,7 +20,7 @@ To run the client and server, use `just start`. You can hit `Ctrl+C` to stop the ## Editing the Client -With `just start` running, the client will listen for changes you make, and reload accordingly. You will see your changes reflected on http://127.0.0.1:3000. +With `just start` running, the client will listen for changes you make, and reload accordingly. You will see your changes reflected on http://localhost:3000. ## Editing the Server diff --git a/docs/docs/contributing/components/documentation.md b/docs/docs/contributing/components/documentation.md index 4742f9b0b..745b18f76 100644 --- a/docs/docs/contributing/components/documentation.md +++ b/docs/docs/contributing/components/documentation.md @@ -28,7 +28,7 @@ Other than that, our documentation is [markdown](https://www.markdownguide.org/b ## Running the Documentation -Use `just docs start` inside Tachi to start up a local documentation viewer on http://127.0.0.1:8001. +Use `just docs start` inside Tachi to start up a local documentation viewer on http://localhost:8001. This will automatically refresh when you edit anything related to the documentation, so you can quickly see how your stuff goes. diff --git a/docs/docs/contributing/setup.md b/docs/docs/contributing/setup.md index 6a48ad136..e69e6366f 100644 --- a/docs/docs/contributing/setup.md +++ b/docs/docs/contributing/setup.md @@ -158,21 +158,13 @@ You should now be properly authenticated! With a terminal open inside the `Tachi` container you just cloned, run `just start`. -The frontend will be running on `http://127.0.0.1:3000`. -The backend will be running on `https://127.0.0.1:8080`. - -!!! danger -The backend **always runs on HTTPS** in local development. This is because browsers -tend to _really_ hate HTTP mode nowadays, and it causes so many problems. - - You **need** to navigate to your running instance of the backend in a browser, and tell - the browser that you trust these certificates. Otherwise, all client requests to - the server will silently be chomped by the browser. +The frontend will be running on `http://localhost:3000`. +The backend listens on `http://localhost:8080`, and the Vite dev server **proxies** `/api` and `/ir` there so the browser talks to the app on port 3000 only (same-origin HTTP, no self-signed certificates). !!! tip Type `just` in the terminal to see other available commands. -Navigate to http://127.0.0.1:3000 and check your Tachi instance! +Navigate to http://localhost:3000 and check your Tachi instance! ## 6. OK, Now what. diff --git a/package.json b/package.json index 0b23d5cf5..03ecbf20f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,47 @@ "typescript/*", "typescript-vendor/*" ], + "tsMachete": { + "excludeGlobal": [ + "@typescript/native-preview", + "typescript" + ], + "exclude": { + "tachi-client": [ + "@types/history", + "bootstrap-scss", + "sass" + ], + "tachi-bot": [ + "ts-node", + "tsconfig-paths", + "@vitest/coverage-v8" + ], + "tachi-db": [ + "@types/express", + "@types/node", + "@types/node-fetch", + "@vitest/coverage-v8" + ], + "eslint-config-tachi": [ + "@typescript-eslint/eslint-plugin", + "@typescript-eslint/parser", + "eslint-import-resolver-typescript", + "@types/eslint__js" + ], + "tachi-seeds-scripts": [ + "tsconfig-paths" + ], + "tachi-server": [ + "ts-node", + "tsconfig-paths", + "@vitest/coverage-v8" + ], + "tachi-db-migration-engine": [ + "ts-node" + ] + } + }, "author": "zk", "devDependencies": { "@biomejs/biome": "^2.0.6", @@ -18,64 +59,117 @@ "kanel": "^3.16.1", "kanel-kysely": "^0.8.0", "ts-node": "10.9.1", - "tsc-alias": "1.7.1", "typescript": "5.9.3" }, - "peerDependencies": { - "@types/bson": "1.0.11", - "@types/mongodb": "3.6.20", - "@types/node": "20.19.37", - "mongodb": "3.6.3" - }, "catalog": { - "@typescript/native-preview": "7.0.0-dev.20260309.1", - "@types/node": "*", - "@types/pg": "^8.11.11", - "ts-node": "^8.5.2", - "typescript": "^4.3.4", - "kysely": "^0.27.5", - "pg": "^8.13.3", - "@types/history": "4.7.11", - "@types/humanize-duration": "^3.27.1", - "@types/luxon": "^2.3.1", - "@types/react": "17.0.13", - "@types/react-dom": "17.0.8", - "@types/react-router-bootstrap": "0.26.1", - "@types/react-router-dom": "5.1.7", - "@vitejs/plugin-react": "^6.0.1", - "bootstrap-scss": "^5.3.0", - "sass": "^1.77.0", - "vite": "^8.0.0", - "@babel/core": "7.1.0", + "@aws-sdk/client-s3": "3.49.0", + "@discordjs/builders": "0.5.0", + "@discordjs/rest": "0.1.0-canary.0", + "@eslint/js": "^9.20.0", "@fortawesome/fontawesome-free": "5.13.0", "@fullcalendar/core": "^6.0.0", "@fullcalendar/daygrid": "^6.0.0", "@fullcalendar/react": "^6.0.0", "@nivo/bar": "0.79.1", - "@nivo/colors": "0.79.1", "@nivo/core": "0.79.0", "@nivo/line": "0.79.1", - "@nivo/tooltip": "0.79.0", + "@octokit/app": "15.1.0", + "@octokit/webhooks-types": "7.5.1", + "@types/bcryptjs": "^2.4.2", + "@types/bunyan": "^1.8.8", + "@types/connect-redis": "0.0.18", + "@types/eslint__js": "8.42.3", + "@types/express": "4.17.13", + "@types/express-rate-limit": "5.1.3", + "@types/express-serve-static-core": "4.17.29", + "@types/express-session": "1.17.4", + "@types/history": "4.7.11", + "@types/humanize-duration": "^3.27.1", "@types/lodash": "4.14.172", + "@types/lodash.get": "^4.4.7", + "@types/luxon": "^2.3.1", + "@types/mongodb": "3.6.20", + "@types/multer": "1.4.7", "@types/natural-compare": "^1.4.1", + "@types/node": "*", + "@types/node-fetch": "2.5.12", + "@types/nodemailer": "^6.4.4", + "@types/pg": "^8.11.11", + "@types/rate-limit-redis": "1.7.4", + "@types/react": "17.0.13", + "@types/react-dom": "17.0.8", "@types/react-google-recaptcha": "2.1.2", - "csstype": "3.1.1", - "d3-scale": "3.3.0", + "@types/react-router-bootstrap": "0.26.1", + "@types/react-router-dom": "5.1.7", + "@types/redis": "2.8.32", + "@types/supertest": "2.0.11", + "@typescript-eslint/eslint-plugin": "^4.27.0", + "@typescript-eslint/parser": "^4.27.0", + "@typescript-eslint/utils": "^8.24.1", + "@typescript/native-preview": "7.0.0-dev.20260309.1", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "~3.2.0", + "bcryptjs": "2.4.3", + "better-sqlite3": "9.4.5", + "binary-parser": "^2.3.0", + "bms-table-loader": "2.0.0", + "bootstrap-scss": "^5.3.0", + "bullmq": "1.68.4", + "bunyan": "1.8.15", + "chalk": "4", + "cheerio": "^1.0.0-rc.12", + "commander": "^8.2.0", + "connect-redis": "6.1.1", + "csv-parse": "^5.0.3", "deepmerge": "^4.2.2", + "discord-api-types": "0.22.0", + "discord.js": "13.6.0", "dotenv": "16.0.0", + "encoding-japanese": "^1.0.30", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.7.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-perfectionist": "^3.9.1", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.14", + "express": "4.20.0", + "express-async-errors": "3.1.1", + "express-prom-bundle": "^7.0.0", + "express-rate-limit": "5.5.1", + "express-session": "1.17.2", "fast-json-stable-hash": "^1.0.3", + "fast-xml-parser": "^4.2.5", "formik": "2.2.9", - "fs-extra": "7.0.1", - "hash.js": "1.1.7", + "globals": "^15.15.0", + "helmet": "5.0.2", + "html-entities": "^2.3.2", "humanize-duration": "^3.27.1", - "json-2-csv": "3.14.4", + "iconv-lite": "^0.6.3", + "kysely": "^0.27.5", "lodash": "^4.17.23", + "lodash.get": "^4.4.2", "luxon": "^2.3.1", + "mongodb": "3.6.3", + "monk": "^7.3.4", + "multer": "2.0.1", "nanoid": "3.1.23", "natural-compare": "^1.4.0", + "node-cache": "5.1.2", + "node-fetch": "2.6.7", + "node-html-parser": "^5.3.3", + "node-mocks-http": "1.11.0", + "node-sass": "9.0.0", + "nodemailer": "7.0.11", + "pg": "^8.13.3", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "prettier": "3.2.5", "pretty-bytes": "5.6.0", - "prop-types": "15.6.0", + "prom-client": "15.1.3", "prudence": "0.10.0", + "rate-limit-redis": "2.1.0", "react": "17.0.2", "react-async": "10.0.1", "react-bootstrap": "2.9.0-beta.0", @@ -87,147 +181,23 @@ "react-router-bootstrap": "^0.25.0", "react-router-dom": "5.1.2", "react-select": "^5.6.1", - "rg-stats": "1.2.1", - "sync-fetch": "^0.3.1", - "vite-plugin-html": "^3.2.0", - "@types/express-serve-static-core": "4.17.29", - "@types/mongodb": "3.6.20", - "zod": "^4.3.6", - "@types/express": "4.17.13", - "@types/json5": "2.2.0", - "@types/node-fetch": "2.5.12", - "@discordjs/builders": "0.5.0", - "@discordjs/rest": "0.1.0-canary.0", - "@types/bson": "1.0.11", - "@valuabletouch/winston-seq": "1.3.0", - "discord-api-types": "0.22.0", - "discord.js": "13.6.0", - "express": "4.20.0", - "json5": "2.2.0", - "monk": "^7.3.4", - "node-fetch": "2.6.7", - "seq-logging": "1.1.1", - "tsconfig-paths": "3.12.0", - "winston": "3.5.1", - "@grpc/grpc-js": "^1.10.6", - "@types/google-protobuf": "^3.15.12", - "@types/supertest": "2.0.11", - "glob": "^7.1.6", - "google-protobuf": "^3.21.2", - "grpc-tools": "^1.12.4", - "node-mocks-http": "1.11.0", - "nodemon": "^2.0.20", - "supertest": "6.2.2", - "ts-protoc-gen": "^0.15.0", - "@aws-sdk/client-s3": "3.49.0", - "@aws-sdk/signature-v4-crt": "3.49.0", - "@types/bcryptjs": "^2.4.2", - "@types/bunyan": "^1.8.8", - "@types/connect-redis": "0.0.18", - "@types/express-rate-limit": "5.1.3", - "@types/express-session": "1.17.4", - "@types/glob": "^7.2.0", - "@types/mkdirp": "1.0.2", - "@types/multer": "1.4.7", - "@types/nodemailer": "^6.4.4", - "@types/rate-limit-redis": "1.7.4", - "@types/redis": "2.8.32", - "@types/rimraf": "3.0.2", - "@types/safe-json-stringify": "1.1.2", - "@types/semver": "^7.3.9", - "bcryptjs": "2.4.3", - "bms-table-loader": "2.0.0", - "bullmq": "1.68.4", - "bunyan": "1.8.15", - "commander": "^8.2.0", - "connect-redis": "6.1.1", - "csv-parse": "^5.0.3", - "express-async-errors": "3.1.1", - "express-prom-bundle": "^7.0.0", - "express-rate-limit": "5.5.1", - "express-session": "1.17.2", - "fast-xml-parser": "^4.2.5", - "helmet": "5.0.2", - "html-entities": "^2.3.2", - "mkdirp": "^1.0.4", - "mongodb": "3.6.3", - "multer": "2.0.1", - "node-cache": "5.1.2", - "node-html-parser": "^5.3.3", - "nodemailer": "7.0.11", - "rate-limit-redis": "2.1.0", "redis": "3.1.2", - "rimraf": "^3.0.0", - "safe-json-stringify": "1.2.0", - "semver": "^7.3.7", - "uuidv7": "^1.1.0", - "winston-loki": "^6.1.0", - "winston-transport": "4.5.0", - "@octokit/app": "15.1.0", - "@octokit/webhooks-types": "7.5.1", - "node-sass": "9.0.0", - "prettier": "3.2.5", - "@types/lodash.get": "^4.4.7", - "better-sqlite3": "9.4.5", - "binary-parser": "^2.3.0", - "chalk": "4", - "cheerio": "^1.0.0-rc.12", - "encoding-japanese": "^1.0.30", - "iconv-lite": "^0.6.3", - "lodash.get": "^4.4.2", - "flow-remove-types": "^2.112.0", - "node-preload": "^0.2.1", - "process-on-spawn": "^1.0.0", - "chokidar": "^3.3.0", - "coveralls": "^3.0.11", - "findit": "^2.0.0", - "foreground-child": "^2.0.0", - "fs-exists-cached": "^1.0.0", - "import-jsx": "^4.0.0", - "ink": "^2.7.1", - "isexe": "^2.0.0", - "istanbul-lib-processinfo": "^2.0.2", - "jackspeak": "^1.4.0", - "libtap": "^1.1.1", - "minipass": "^3.1.1", - "nyc": "^15.1.0", - "opener": "^1.5.1", - "signal-exit": "^3.0.0", - "source-map-support": "^0.5.16", - "tap-mocha-reporter": "^5.0.0", - "tap-parser": "^10.0.1", - "tap-yaml": "^1.0.0", - "tcompare": "^5.0.6", - "treport": "^2.0.2", - "which": "^2.0.2", - "@types/eslint__js": "8.42.3", - "@eslint/js": "^9.20.0", - "@typescript-eslint/eslint-plugin": "^4.27.0", - "@typescript-eslint/parser": "^4.27.0", - "@typescript-eslint/utils": "^8.24.1", - "eslint-config-prettier": "^9.1.0", - "eslint-import-resolver-typescript": "^3.7.0", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-perfectionist": "^3.9.1", - "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "^5.1.0", - "eslint-plugin-react-refresh": "^0.4.14", - "globals": "^15.15.0", + "rg-stats": "workspace:*", + "sass": "^1.77.0", + "supertest": "6.2.2", + "sync-fetch": "^0.3.1", + "ts-node": "^8.5.2", + "tsconfig-paths": "3.12.0", + "typescript": "^4.3.4", "typescript-eslint": "^8.24.1", + "vite": "^8.0.0", + "vite-plugin-html": "^3.2.0", "vitest": "~3.2.0", - "@vitest/coverage-v8": "~3.2.0", - "@vitest/coverage-istanbul": "~3.2.0", - "pino": "^10.3.1", - "@types/jest": "^26.0.23", - "eslint": "^7.29.0", - "eslint-config-standard": "^16.0.3", - "eslint-plugin-jest": "^24.3.6", - "jest": "^27.0.4", - "standard": "^16.0.3", - "ts-jest": "^27.0.3", - "tsd": "^0.17.0", - "pino-pretty": "^13.1.3" + "zod": "^4.3.6", + "@types/tap": "^15.0.5", + "tap": "^15.1.6", + "@types/istanbul-lib-coverage": "^2.0.6", + "istanbul-lib-coverage": "^3.2.2" }, "overrides": { "@types/mongodb": "3.6.20", @@ -242,8 +212,7 @@ "patchedDependencies": { "@types/mongodb@3.6.20": "patches/@types__mongodb@3.6.20.patch", "browserslist@4.21.4": "patches/browserslist@4.21.4.patch", - "monk@7.3.4": "patches/monk@7.3.4.patch", - "winston-loki@6.1.0": "patches/winston-loki@6.1.0.patch" + "monk@7.3.4": "patches/monk@7.3.4.patch" }, "engines": { "bun": ">=1.2.0" diff --git a/patches/winston-loki@6.1.0.patch b/patches/winston-loki@6.1.0.patch deleted file mode 100644 index 552094d43..000000000 --- a/patches/winston-loki@6.1.0.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/index.js b/index.js -index e1c76051045ea9b69b320283a2f27cd2b9ea5cb4..65d183c93ba15242826ccfaf3528e38ebe701f03 100644 ---- a/index.js -+++ b/index.js -@@ -65,6 +65,7 @@ class LokiTransport extends Transport { - } - - lokiLabels = Object.assign(lokiLabels, labels) -+ lokiLabels = Object.assign(lokiLabels, rest) - - // follow the format provided - const line = this.useCustomFormat -diff --git a/package-lock.json b/package-lock.json -deleted file mode 100644 -index e08faec5f46b8d3802f1a8bf7191fe41d695e047..0000000000000000000000000000000000000000 diff --git a/typescript/bot/package.json b/typescript/bot/package.json index da16193e1..596a56e90 100644 --- a/typescript/bot/package.json +++ b/typescript/bot/package.json @@ -17,7 +17,6 @@ }, "devDependencies": { "@types/express": "catalog:", - "@types/json5": "catalog:", "@types/lodash": "catalog:", "@types/luxon": "catalog:", "@types/node": "catalog:", @@ -31,16 +30,11 @@ "dependencies": { "@discordjs/builders": "catalog:", "@discordjs/rest": "catalog:", - "@types/bson": "catalog:", - "@types/humanize-duration": "catalog:", - "@valuabletouch/winston-seq": "catalog:", "deepmerge": "catalog:", "discord-api-types": "catalog:", "discord.js": "catalog:", "dotenv": "catalog:", "express": "catalog:", - "humanize-duration": "catalog:", - "json5": "catalog:", "kysely": "catalog:", "lodash": "catalog:", "luxon": "catalog:", @@ -48,13 +42,11 @@ "pg": "catalog:", "bliss": "workspace:*", "prudence": "catalog:", - "seq-logging": "catalog:", "tachi-common": "workspace:*", "tachi-db": "workspace:*", "ts-node": "catalog:", "tsconfig-paths": "catalog:", - "typescript": "catalog:", - "winston": "catalog:" + "typescript": "catalog:" }, "engines": { "bun": ">=1.2.0" diff --git a/typescript/bot/src/actions/sync.test.ts b/typescript/bot/src/actions/sync.test.ts index 642ab2cbb..5532e4873 100644 --- a/typescript/bot/src/actions/sync.test.ts +++ b/typescript/bot/src/actions/sync.test.ts @@ -2,6 +2,7 @@ import type { MONGO_ImportDocument } from "tachi-common"; import db from "#services/pg/db"; import { createTestAccount } from "#test-utils/db"; +import { SELECT_ACTION } from "#test-utils/select-constants"; import { type ActionTaker, ExpectedErr } from "bliss"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -70,8 +71,8 @@ describe("ACTION_Sync", () => { const action = await db .selectFrom("action") - .selectAll() - .where("kind", "=", "SYNC") + .select(SELECT_ACTION) + .where("action.kind", "=", "SYNC") .executeTakeFirst(); const input = action?.input as Record; @@ -105,8 +106,8 @@ describe("ACTION_Sync", () => { const action = await db .selectFrom("action") - .selectAll() - .where("kind", "=", "SYNC") + .select(SELECT_ACTION) + .where("action.kind", "=", "SYNC") .executeTakeFirst(); // node-pg returns BIGINT columns as strings @@ -127,8 +128,8 @@ describe("ACTION_Sync", () => { const action = await db .selectFrom("action") - .selectAll() - .where("kind", "=", "SYNC") + .select(SELECT_ACTION) + .where("action.kind", "=", "SYNC") .executeTakeFirst(); expect(action?.result).toBe("BAD"); @@ -141,8 +142,8 @@ describe("ACTION_Sync", () => { const action = await db .selectFrom("action") - .selectAll() - .where("kind", "=", "SYNC") + .select(SELECT_ACTION) + .where("action.kind", "=", "SYNC") .executeTakeFirst(); expect(action?.result).toBe("THROW"); diff --git a/typescript/bot/src/anon-actions/letmein.test.ts b/typescript/bot/src/anon-actions/letmein.test.ts index 517a86447..c4b256840 100644 --- a/typescript/bot/src/anon-actions/letmein.test.ts +++ b/typescript/bot/src/anon-actions/letmein.test.ts @@ -1,6 +1,7 @@ import type { GuildMember } from "discord.js"; import db from "#services/pg/db"; +import { SELECT_ACTION } from "#test-utils/select-constants"; import { type AnonActionTaker } from "bliss"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -57,8 +58,8 @@ describe("ACTION_Letmein", () => { const action = await db .selectFrom("action") - .selectAll() - .where("kind", "=", "LETMEIN") + .select(SELECT_ACTION) + .where("action.kind", "=", "LETMEIN") .executeTakeFirst(); // node-pg returns BIGINT columns as strings @@ -91,8 +92,8 @@ describe("ACTION_Letmein", () => { const action = await db .selectFrom("action") - .selectAll() - .where("kind", "=", "LETMEIN") + .select(SELECT_ACTION) + .where("action.kind", "=", "LETMEIN") .executeTakeFirst(); expect(action?.result).toBe("THROW"); diff --git a/typescript/bot/src/anon-actions/register.test.ts b/typescript/bot/src/anon-actions/register.test.ts index fe8e4f22b..0404c1fd4 100644 --- a/typescript/bot/src/anon-actions/register.test.ts +++ b/typescript/bot/src/anon-actions/register.test.ts @@ -1,5 +1,6 @@ import db from "#services/pg/db"; import { createTestAccount } from "#test-utils/db"; +import { SELECT_ACTION, SELECT_PRIV_DISCORD_USER_MAP } from "#test-utils/select-constants"; import { beforeEach, describe, expect, it } from "vitest"; import { ANON_ACTION_Register } from "./register"; @@ -27,8 +28,8 @@ describe("ACTION_Register", () => { const row = await db .selectFrom("priv_discord_user_map") - .selectAll() - .where("user_id", "=", userId) + .select(SELECT_PRIV_DISCORD_USER_MAP) + .where("priv_discord_user_map.user_id", "=", userId) .executeTakeFirst(); expect(row).toMatchObject({ @@ -58,8 +59,8 @@ describe("ACTION_Register", () => { const row = await db .selectFrom("priv_discord_user_map") - .selectAll() - .where("user_id", "=", userId) + .select(SELECT_PRIV_DISCORD_USER_MAP) + .where("priv_discord_user_map.user_id", "=", userId) .executeTakeFirst(); expect(row?.discord_id).toBe(newDiscordId); @@ -74,8 +75,8 @@ describe("ACTION_Register", () => { const action = await db .selectFrom("action") - .selectAll() - .where("kind", "=", "REGISTER") + .select(SELECT_ACTION) + .where("action.kind", "=", "REGISTER") .executeTakeFirst(); expect(action).toMatchObject({ diff --git a/typescript/bot/src/test-utils/select-constants.ts b/typescript/bot/src/test-utils/select-constants.ts new file mode 100644 index 000000000..f52944aa5 --- /dev/null +++ b/typescript/bot/src/test-utils/select-constants.ts @@ -0,0 +1,20 @@ +/** Kysely column lists for tests (mirrors `tachi-server` db-formats where applicable). */ + +export const SELECT_ACTION = [ + "action.row_id", + "action.user_id", + "action.ip", + "action.app", + "action.kind", + "action.result", + "action.input", + "action.output", + "action.ts_start", + "action.ts_end", +] as const; + +export const SELECT_PRIV_DISCORD_USER_MAP = [ + "priv_discord_user_map.user_id", + "priv_discord_user_map.discord_id", + "priv_discord_user_map.api_token", +] as const; diff --git a/typescript/client/.env b/typescript/client/.env index 8866bb1b2..c39d5e790 100644 --- a/typescript/client/.env +++ b/typescript/client/.env @@ -1,6 +1,7 @@ VITE_TCHIC_MODE="omni" -VITE_SERVER_URL="https://127.0.0.1:8080" -VITE_CDN_URL="http://127.0.0.1:9000/tachi-public" +# Empty = same-origin; Vite proxies /api and /ir to the backend (see vite.config.ts). +VITE_SERVER_URL= +VITE_CDN_URL="http://localhost:9000/tachi-public" VITE_GIT_REPO="GitHub:zkldi/Tachi" VITE_EAG_CLIENT_ID="" VITE_MIN_CLIENT_ID="" diff --git a/typescript/client/example/.env b/typescript/client/example/.env index e370c4216..06df5ef33 100644 --- a/typescript/client/example/.env +++ b/typescript/client/example/.env @@ -1,7 +1,8 @@ BROWSER="none" VITE_TCHIC_MODE="omni" -VITE_SERVER_URL="https://127.0.0.1:8080" -VITE_CDN_URL="http://127.0.0.1:9000/tachi-public" +# Empty = same-origin; Vite proxies /api and /ir to the backend (see vite.config.ts). +VITE_SERVER_URL= +VITE_CDN_URL="http://localhost:9000/tachi-public" VITE_GIT_REPO="GitHub:zkldi/Tachi" VITE_EAG_CLIENT_ID="" VITE_MIN_CLIENT_ID="" diff --git a/typescript/client/package.json b/typescript/client/package.json index fdd35b67b..c608d94c8 100644 --- a/typescript/client/package.json +++ b/typescript/client/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "AGPL3", "scripts": { - "dev": "vite --open", + "dev": "vite", "build": "vite build", "lint": "eslint src", "lint-fix": "eslint src --fix", @@ -34,29 +34,21 @@ "@fullcalendar/daygrid": "catalog:", "@fullcalendar/react": "catalog:", "@nivo/bar": "catalog:", - "@nivo/colors": "catalog:", "@nivo/core": "catalog:", "@nivo/line": "catalog:", - "@nivo/tooltip": "catalog:", "@types/lodash": "catalog:", "@types/natural-compare": "catalog:", "@types/react-google-recaptcha": "catalog:", - "csstype": "catalog:", - "d3-scale": "catalog:", "deepmerge": "catalog:", "dotenv": "catalog:", "fast-json-stable-hash": "catalog:", "formik": "catalog:", - "fs-extra": "catalog:", - "hash.js": "catalog:", "humanize-duration": "catalog:", - "json-2-csv": "catalog:", "lodash": "catalog:", "luxon": "catalog:", "nanoid": "catalog:", "natural-compare": "catalog:", "pretty-bytes": "catalog:", - "prop-types": "catalog:", "prudence": "catalog:", "react": "catalog:", "react-async": "catalog:", diff --git a/typescript/client/src/app/pages/LoginPage.tsx b/typescript/client/src/app/pages/LoginPage.tsx index 9943dfd4d..c8b2986fb 100644 --- a/typescript/client/src/app/pages/LoginPage.tsx +++ b/typescript/client/src/app/pages/LoginPage.tsx @@ -5,7 +5,7 @@ import { APIFetchV1 } from "#util/api"; import { HumaniseError } from "#util/humanise-error"; import { HistorySafeGoBack } from "#util/misc"; import { useFormik } from "formik"; -import React, { useContext, useRef, useState } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; import { Alert, Button, Form } from "react-bootstrap"; import ReCAPTCHA from "react-google-recaptcha"; import toast from "react-hot-toast"; @@ -21,6 +21,35 @@ export default function LoginPage() { const recaptchaRef = useRef(null); + const [localDevFirstAdminLogin, setLocalDevFirstAdminLogin] = useState< + | { password: string; status: "ready"; username: string } + | { status: "loading" } + | { status: "unavailable" } + | null + >(null); + + useEffect(() => { + if (!import.meta.env.VITE_IS_LOCAL_DEV) { + return; + } + setLocalDevFirstAdminLogin({ status: "loading" }); + void APIFetchV1<{ password: string; username: string }>("/localdev/first-admin-login") + .then((r) => { + if (r.success && r.body) { + setLocalDevFirstAdminLogin({ + status: "ready", + username: r.body.username, + password: r.body.password, + }); + } else { + setLocalDevFirstAdminLogin({ status: "unavailable" }); + } + }) + .catch(() => { + setLocalDevFirstAdminLogin({ status: "unavailable" }); + }); + }, []); + const performLogin = async (values: { "!password": string; captcha: string; @@ -86,22 +115,26 @@ export default function LoginPage() { onSubmit: performLogin, }); + const localDevReadyLogin = + localDevFirstAdminLogin?.status === "ready" ? localDevFirstAdminLogin : null; + return ( } heading="Log In">
- {import.meta.env.VITE_IS_LOCAL_DEV && ( + {import.meta.env.VITE_IS_LOCAL_DEV && localDevReadyLogin && (
- You are in local development mode. You can login as the admin - account. + You are in local development mode. You can log in as the first admin + account ({localDevReadyLogin.username} /{" "} + {localDevReadyLogin.password}).
diff --git a/typescript/client/src/lib/config.ts b/typescript/client/src/lib/config.ts index 2dbcb541b..ad4ab29fe 100644 --- a/typescript/client/src/lib/config.ts +++ b/typescript/client/src/lib/config.ts @@ -17,8 +17,6 @@ try { throw new Error(`Failed to fetch config -- ${configRes.description}.`); } } catch (err) { - const devServerUrl = import.meta.env.VITE_SERVER_URL ?? ""; - const showHttpsCertHint = devServerUrl.startsWith("https://"); const statusHelpUrl = ToAbsoluteAPIURLForHelpLink("/status"); document.open(); document.write(` @@ -47,11 +45,7 @@ try {

Couldn't connect to the server.

You are in local development mode.

    -
  • ${ - showHttpsCertHint - ? `Have you accepted the HTTPS certificates for the server? If not, the site won't load.` - : `Is the backend running? Try opening the status endpoint in your browser (default dev URL is http://127.0.0.1:8080).` - }
  • +
  • Is the backend running on http://localhost:8080? Try the status endpoint. With the default Vite proxy, the API is also available at http://localhost:3000/api/v1/....
` : `

Failed to connect!

diff --git a/typescript/client/src/util/api.ts b/typescript/client/src/util/api.ts index 8d6de8358..eff139c87 100644 --- a/typescript/client/src/util/api.ts +++ b/typescript/client/src/util/api.ts @@ -24,8 +24,8 @@ export function ToAPIURL(url: string) { /** * When `VITE_SERVER_URL` is unset, `ToAPIURL` is same-origin relative. On the Vite dev - * server, a plain `/api/v1/...` link would hit the SPA and reload `index.html` in a loop. - * For "open in browser" help links, force an absolute backend URL in local dev. + * server, `vite.config` proxies `/api` to the backend so `/api/v1/...` works. + * For "open in browser" help links in local dev, use the page origin (Vite on :3000), not :8080. */ export function ToAbsoluteAPIURLForHelpLink(url: string) { const u = ToAPIURL(url); @@ -33,7 +33,11 @@ export function ToAbsoluteAPIURLForHelpLink(url: string) { return u; } if (import.meta.env.VITE_IS_LOCAL_DEV) { - return `http://127.0.0.1:8080${u}`; + const origin = + typeof window !== "undefined" && window.location?.origin + ? window.location.origin + : "http://localhost:3000"; + return `${origin}${u}`; } return u; } diff --git a/typescript/client/vite.config.ts b/typescript/client/vite.config.ts index 7c577c68a..ae4f8b619 100644 --- a/typescript/client/vite.config.ts +++ b/typescript/client/vite.config.ts @@ -1,11 +1,74 @@ +import { spawn } from "node:child_process"; import react from "@vitejs/plugin-react"; import { config } from "dotenv"; import path from "path"; import { defineConfig } from "vite"; +import type { Plugin, PreviewServer, ViteDevServer } from "vite"; import { createHtmlPlugin } from "vite-plugin-html"; config(); +/** + * Vite's `server.open` / `preview.open` resolve against `resolvedUrls`, which use + * `localhost` for loopback when `host: true` (see vite `resolveServerUrls`). The IDE + * may also open forwarded ports as `localhost`. Opening the OS browser ourselves + * with an explicit loopback URL keeps the address bar on localhost when that matters. + */ +function openLoopbackInBrowserPlugin(): Plugin { + const schedule = ( + httpServer: ViteDevServer["httpServer"], + portFallback: number | undefined, + useHttps: boolean, + ) => { + if (!httpServer) return; + const run = () => { + const addr = httpServer.address(); + const port = + typeof addr === "object" && addr && "port" in addr + ? addr.port + : (portFallback ?? 3000); + const protocol = useHttps ? "https" : "http"; + const url = `${protocol}://localhost:${port}/`; + if (process.platform === "darwin") { + spawn("open", [url], { detached: true, stdio: "ignore" }).unref(); + } else if (process.platform === "win32") { + spawn("cmd", ["/c", "start", "", url], { + detached: true, + stdio: "ignore", + shell: false, + }).unref(); + } else { + spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); + } + }; + if (httpServer.listening) run(); + else httpServer.once("listening", run); + }; + + return { + name: "open-loopback-explicit", + apply: "serve", + configureServer(server: ViteDevServer) { + return () => { + schedule( + server.httpServer, + server.config.server.port, + Boolean(server.config.server.https), + ); + }; + }, + configurePreviewServer(server: PreviewServer) { + return () => { + schedule( + server.httpServer, + server.config.preview.port, + Boolean(server.config.preview.https), + ); + }; + }, + }; +} + export default defineConfig({ build: { assetsDir: "static", @@ -25,6 +88,7 @@ export default defineConfig({ }, }, plugins: [ + openLoopbackInBrowserPlugin(), react(), createHtmlPlugin({ inject: { @@ -49,6 +113,7 @@ root.setAttribute("data-bs-theme", theme); }), ], preview: { + open: false, port: 3000, }, resolve: { @@ -65,7 +130,13 @@ root.setAttribute("data-bs-theme", theme); }, server: { host: true, + open: false, port: 3000, + // Same-origin `/api` + `/ir` so local dev can use HTTP + SameSite=Lax session cookies (no HTTPS). + proxy: { + "/api": { target: "http://localhost:8080", changeOrigin: true }, + "/ir": { target: "http://localhost:8080", changeOrigin: true }, + }, watch: { usePolling: process.env.FORCE_FS_POLLING === "true", }, diff --git a/typescript/common/package.json b/typescript/common/package.json index a5b89c6e3..67e9716dd 100644 --- a/typescript/common/package.json +++ b/typescript/common/package.json @@ -36,14 +36,13 @@ "author": "zk", "license": "MIT", "dependencies": { - "@types/express-serve-static-core": "catalog:", "@types/mongodb": "catalog:", - "deepmerge": "catalog:", "prudence": "catalog:", "zod": "catalog:" }, "devDependencies": { "@typescript/native-preview": "catalog:", + "@vitest/coverage-v8": "catalog:", "eslint-config-tachi": "workspace:*", "typescript": "catalog:", "vitest": "catalog:" diff --git a/typescript/common/src/config/config.test.ts b/typescript/common/src/config/config.test.ts index 441e50bcd..ed507ded7 100644 --- a/typescript/common/src/config/config.test.ts +++ b/typescript/common/src/config/config.test.ts @@ -1,19 +1,19 @@ -import t from "tap"; +import { describe, expect, it } from "vitest"; import { allSupportedGameGroups, GetGameGroupConfig, GetGamePTConfig } from "./config"; -t.test("#GetGameGroupConfig", (t) => { - for (const game of allSupportedGameGroups) { - // i don't feel *that* strongly about this restriction, but game IDs *definitely* - // can't have things like `:` in them. - t.match(game, /^[a-z]+$/u, "Game IDs must be a-z only."); +describe("#GetGameGroupConfig", () => { + it("defines configs for every supported game group with valid IDs", () => { + for (const game of allSupportedGameGroups) { + // i don't feel *that* strongly about this restriction, but game IDs *definitely* + // can't have things like `:` in them. + expect(game).toMatch(/^[a-z]+$/u); - const conf = GetGameGroupConfig(game); + const conf = GetGameGroupConfig(game); - t.not(conf, undefined, `'${game}' should have a config defined.`); - } - - t.end(); + expect(conf, `'${game}' should have a config defined.`).toBeDefined(); + } + }); }); const BANNED_METRIC_NAMES = [ @@ -28,46 +28,50 @@ const BANNED_METRIC_NAMES = [ "chartCount", ]; -t.test("#GetGamePTConfig", (t) => { - for (const game of allSupportedGameGroups) { - const gameConfig = GetGameGroupConfig(game); +describe("#GetGamePTConfig", () => { + it("defines playtype configs with consistent rating algs and metrics", () => { + for (const game of allSupportedGameGroups) { + const gameConfig = GetGameGroupConfig(game); - for (const playtype of gameConfig.playtypes) { - const conf = GetGamePTConfig(game, playtype); + for (const playtype of gameConfig.playtypes) { + const conf = GetGamePTConfig(game, playtype); - t.not(conf, undefined, `'${game}:${playtype}' should have a config defined.`); - - t.ok( - conf.scoreRatingAlgs[conf.defaultScoreRatingAlg], - "The default score rating alg should have an implementation.", - ); - - t.ok( - conf.sessionRatingAlgs[conf.defaultSessionRatingAlg], - "The default session rating alg should have an implementation.", - ); - - t.ok( - conf.profileRatingAlgs[conf.defaultProfileRatingAlg], - "The default profile rating alg should have an implementation.", - ); - - if (conf.difficulties.type === "FIXED") { - t.ok( - conf.difficulties.order.includes(conf.difficulties.default), - "The default difficulty should be part of difficultyOrder.", - ); - } - - for (const metric of Object.keys(conf.scoreRatingAlgs)) { - if (BANNED_METRIC_NAMES.includes(metric)) { - t.fail(`Cannot have a metric called ${metric}. This is a banned metric name.`); + expect(conf, `'${game}:${playtype}' should have a config defined.`).toBeDefined(); + if (!conf) { + continue; } - t.match(metric, /^[a-zA-Z][a-zA-Z0-9]+$/u, `Should be alphanumeric.`); + expect( + conf.scoreRatingAlgs[conf.defaultScoreRatingAlg], + "The default score rating alg should have an implementation.", + ).toBeTruthy(); + + expect( + conf.sessionRatingAlgs[conf.defaultSessionRatingAlg], + "The default session rating alg should have an implementation.", + ).toBeTruthy(); + + expect( + conf.profileRatingAlgs[conf.defaultProfileRatingAlg], + "The default profile rating alg should have an implementation.", + ).toBeTruthy(); + + if (conf.difficulties.type === "FIXED") { + expect( + conf.difficulties.order.includes(conf.difficulties.default), + "The default difficulty should be part of difficultyOrder.", + ).toBe(true); + } + + for (const metric of Object.keys(conf.scoreRatingAlgs)) { + expect( + BANNED_METRIC_NAMES.includes(metric), + `Cannot have a metric called ${metric}. This is a banned metric name.`, + ).toBe(false); + + expect(metric, `Should be alphanumeric.`).toMatch(/^[a-zA-Z][a-zA-Z0-9]+$/u); + } } } - } - - t.end(); + }); }); diff --git a/typescript/common/src/constants/bms-tables.test.ts b/typescript/common/src/constants/bms-tables.test.ts index 529cae48b..7602e91fb 100644 --- a/typescript/common/src/constants/bms-tables.test.ts +++ b/typescript/common/src/constants/bms-tables.test.ts @@ -1,11 +1,11 @@ -import t from "tap"; +import { describe, expect, it } from "vitest"; import { BMS_TABLES } from "./bms-tables"; -t.test("BMS Tables should be unique.", (t) => { - const allKeys = BMS_TABLES.map((e) => `${e.playtype}-${e.prefix}`); +describe("BMS_TABLES", () => { + it("should have unique playtype-prefix keys", () => { + const allKeys = BMS_TABLES.map((e) => `${e.playtype}-${e.prefix}`); - t.strictSame(allKeys, [...new Set(allKeys)], "There should be no duplicate table prefixes."); - - t.end(); + expect(allKeys).toStrictEqual([...new Set(allKeys)]); + }); }); diff --git a/typescript/common/src/index.test.ts b/typescript/common/src/index.test.ts index 3e0c3e8ee..7844083c3 100644 --- a/typescript/common/src/index.test.ts +++ b/typescript/common/src/index.test.ts @@ -1,12 +1,12 @@ -import t from "tap"; +import { describe, expect, it } from "vitest"; import * as tachiCommon from "./index"; // we just check that we're exporting stuff properly -t.test("Property Checks", (t) => { - t.type(tachiCommon.GetGameGroupConfig, "function"); - t.type(tachiCommon.GetGamePTConfig, "function"); - t.type(tachiCommon.COLOUR_SET, "object"); - - t.end(); +describe("Property Checks", () => { + it("exports core APIs and constants", () => { + expect(typeof tachiCommon.GetGameGroupConfig).toBe("function"); + expect(typeof tachiCommon.GetGamePTConfig).toBe("function"); + expect(typeof tachiCommon.COLOUR_SET).toBe("object"); + }); }); diff --git a/typescript/common/vitest.config.ts b/typescript/common/vitest.config.ts index ff3815a78..4e3d90768 100644 --- a/typescript/common/vitest.config.ts +++ b/typescript/common/vitest.config.ts @@ -2,13 +2,17 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + env: { + NODE_ENV: "test", + }, exclude: [ // Compiled output — not test sources. "build/**", - // Legacy tap-based tests not yet migrated to vitest. - "src/index.test.ts", - "src/config/config.test.ts", - "src/constants/bms-tables.test.ts", ], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "build/**"], + }, }, }); diff --git a/typescript/coverage-tools/package.json b/typescript/coverage-tools/package.json new file mode 100644 index 000000000..e53213d24 --- /dev/null +++ b/typescript/coverage-tools/package.json @@ -0,0 +1,23 @@ +{ + "name": "tachi-coverage-tools", + "version": "0.1.0", + "private": true, + "description": "Aggregate Vitest v8/Istanbul coverage reports across workspaces.", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "report": "bun run src/report-coverage.ts", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "istanbul-lib-coverage": "catalog:" + }, + "devDependencies": { + "@types/istanbul-lib-coverage": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/typescript/coverage-tools/src/index.ts b/typescript/coverage-tools/src/index.ts new file mode 100644 index 000000000..c3951bd61 --- /dev/null +++ b/typescript/coverage-tools/src/index.ts @@ -0,0 +1,8 @@ +export { COVERAGE_SOURCES, type CoverageSource } from "./manifest"; +export { + loadCoverageMapFromFinal, + metricsFromSummary, + summarizeByTopSrcDir, + type Metrics, +} from "./summary-from-final"; +export { buildReport, type CoverageReport, type PackageReport } from "./report-coverage"; diff --git a/typescript/coverage-tools/src/manifest.ts b/typescript/coverage-tools/src/manifest.ts new file mode 100644 index 000000000..34dabbfee --- /dev/null +++ b/typescript/coverage-tools/src/manifest.ts @@ -0,0 +1,38 @@ +/** + * Registry of Vitest packages that emit `coverage/coverage-final.json` when tests + * run with `--coverage` (see Justfile-test `test-typescript`). + * + * When you add a new workspace with Vitest + v8 coverage, append an entry here + * and (if needed) point `coverageFinal` at that package’s reportsDirectory. + */ +export type CoverageSource = { + /** Short id for `--packages` */ + id: string; + /** Root package directory relative to the repo root (e.g. typescript/server) */ + packageRoot: string; + /** Istanbul `coverage-final.json` path relative to the repo root */ + coverageFinal: string; +}; + +export const COVERAGE_SOURCES: readonly CoverageSource[] = [ + { + id: "server", + packageRoot: "typescript/server", + coverageFinal: "typescript/server/coverage/coverage-final.json", + }, + { + id: "bot", + packageRoot: "typescript/bot", + coverageFinal: "typescript/bot/coverage/coverage-final.json", + }, + { + id: "rg-stats", + packageRoot: "typescript/rg-stats", + coverageFinal: "typescript/rg-stats/coverage/coverage-final.json", + }, + { + id: "common", + packageRoot: "typescript/common", + coverageFinal: "typescript/common/coverage/coverage-final.json", + }, +] as const; diff --git a/typescript/coverage-tools/src/report-coverage.ts b/typescript/coverage-tools/src/report-coverage.ts new file mode 100644 index 000000000..b21e146d1 --- /dev/null +++ b/typescript/coverage-tools/src/report-coverage.ts @@ -0,0 +1,233 @@ +#!/usr/bin/env bun +/** + * Read aggregated Istanbul coverage from Vitest v8 output and print summaries. + * + * Typical workflow: + * just test-typescript # generates coverage under each package’s coverage/ + * just coverage-report # this tool + * + * Flags: + * --json machine-readable report on stdout + * --by-dir per top-level src/ subdirectory (server/bot only; needs src layout) + * --packages a,b limit to manifest ids (default: all) + * --min-lines N exit 1 if any selected package is below this line % + * --strict exit 1 if a coverage file is missing + * + * Programmatic: import { buildReport } from "./report-coverage.js" (see buildReport). + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { COVERAGE_SOURCES } from "./manifest"; +import { + loadCoverageMapFromFinal, + metricsFromSummary, + summarizeByTopSrcDir, + type Metrics, +} from "./summary-from-final"; + +export type PackageReport = { + id: string; + packageRoot: string; + coverageFinal: string; + present: boolean; + metrics: Metrics | null; + byDir?: Record; +}; + +export type CoverageReport = { + repoRoot: string; + packages: PackageReport[]; +}; + +function pct(n: number): string { + return `${n.toFixed(1)}%`; +} + +function row(label: string, m: Metrics): string { + return [ + label.padEnd(10), + pct(m.lines.pct).padStart(7), + pct(m.statements.pct).padStart(7), + pct(m.functions.pct).padStart(7), + pct(m.branches.pct).padStart(7), + `${m.lines.covered}/${m.lines.total}`.padStart(12), + ].join(" "); +} + +function resolveRepoRoot(): string { + // This file lives at typescript/coverage-tools/src/ + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +} + +export function buildReport(options: { + repoRoot: string; + packageIds: Set | null; + byDir: boolean; +}): CoverageReport { + const { repoRoot, packageIds, byDir } = options; + const packages: PackageReport[] = []; + + for (const src of COVERAGE_SOURCES) { + if (packageIds && !packageIds.has(src.id)) { + continue; + } + const finalPath = path.join(repoRoot, src.coverageFinal); + let present = fs.existsSync(finalPath); + let metrics: Metrics | null = null; + let byDirMetrics: Record | undefined; + + if (present) { + try { + const map = loadCoverageMapFromFinal(finalPath); + metrics = metricsFromSummary(map.getCoverageSummary()); + if (byDir) { + byDirMetrics = summarizeByTopSrcDir(map, src.packageRoot, repoRoot); + } + } catch { + present = false; + } + } + + packages.push({ + id: src.id, + packageRoot: src.packageRoot, + coverageFinal: src.coverageFinal, + present, + metrics, + byDir: byDirMetrics, + }); + } + + return { repoRoot, packages }; +} + +function printHuman(report: CoverageReport): void { + console.log("Coverage (Vitest v8 / Istanbul)\n"); + console.log( + [ + "".padEnd(10), + "Lines".padStart(7), + "Stmt".padStart(7), + "Funcs".padStart(7), + "Branch".padStart(7), + "Lines hit".padStart(12), + ].join(" "), + ); + console.log("-".repeat(62)); + + for (const p of report.packages) { + if (!p.present || !p.metrics) { + console.log(`${p.id.padEnd(10)} (no data — run: just test-typescript)`); + continue; + } + console.log(row(p.id, p.metrics)); + if (p.byDir && Object.keys(p.byDir).length > 0) { + const names = Object.keys(p.byDir).sort((a, b) => a.localeCompare(b)); + for (const name of names) { + console.log(row(` ${name}`, p.byDir[name]!)); + } + } + } + console.log(""); +} + +function main(): void { + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + json: { type: "boolean", default: false }, + "by-dir": { type: "boolean", default: false }, + "min-lines": { type: "string" }, + packages: { type: "string" }, + strict: { type: "boolean", default: false }, + help: { type: "boolean", default: false, short: "h" }, + }, + strict: true, + allowPositionals: false, + }); + + if (values.help) { + console.log(`Usage: bun run src/report-coverage.ts [options] + +Options: + --json Emit JSON (CoverageReport shape) + --by-dir Break down each package by top-level directory under src/ + --packages a,b Only include manifest ids (default: all in manifest.ts) + --min-lines N Fail if any included package line coverage < N + --strict Fail if any expected coverage file is missing or unreadable +`); + process.exit(0); + } + + const repoRoot = resolveRepoRoot(); + let packageIds: Set | null = null; + if (values.packages) { + packageIds = new Set( + values.packages + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + } + + const report = buildReport({ + repoRoot, + packageIds, + byDir: values["by-dir"] ?? false, + }); + + let exitCode = 0; + + if (values.strict) { + for (const p of report.packages) { + if (!p.present || !p.metrics) { + exitCode = 1; + } + } + } + + const minLines = values["min-lines"] ? Number(values["min-lines"]) : null; + if (minLines !== null && !Number.isFinite(minLines)) { + console.error("Invalid --min-lines"); + process.exit(1); + } + if (minLines !== null) { + for (const p of report.packages) { + if (p.metrics && p.metrics.lines.pct < minLines) { + exitCode = 1; + } + } + } + + if (values.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + printHuman(report); + if (exitCode !== 0) { + if (values.strict) { + console.error("Strict mode: missing or invalid coverage for one or more packages."); + } + if (minLines !== null) { + console.error( + `Line coverage below --min-lines ${minLines} for one or more packages.`, + ); + } + } + } + + process.exit(exitCode); +} + +function isMainModule(): boolean { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === path.resolve(fileURLToPath(import.meta.url)); +} + +if (isMainModule()) { + main(); +} diff --git a/typescript/coverage-tools/src/summary-from-final.ts b/typescript/coverage-tools/src/summary-from-final.ts new file mode 100644 index 000000000..6f29e3ba3 --- /dev/null +++ b/typescript/coverage-tools/src/summary-from-final.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createCoverageMap } from "istanbul-lib-coverage"; +import type { CoverageMap, CoverageSummary } from "istanbul-lib-coverage"; + +export type Metrics = { + lines: { pct: number; covered: number; total: number }; + statements: { pct: number; covered: number; total: number }; + functions: { pct: number; covered: number; total: number }; + branches: { pct: number; covered: number; total: number }; +}; + +export function metricsFromSummary(s: CoverageSummary): Metrics { + return { + lines: { + pct: s.lines.pct, + covered: s.lines.covered, + total: s.lines.total, + }, + statements: { + pct: s.statements.pct, + covered: s.statements.covered, + total: s.statements.total, + }, + functions: { + pct: s.functions.pct, + covered: s.functions.covered, + total: s.functions.total, + }, + branches: { + pct: s.branches.pct, + covered: s.branches.covered, + total: s.branches.total, + }, + }; +} + +export function loadCoverageMapFromFinal(coverageFinalPath: string): CoverageMap { + const raw = fs.readFileSync(coverageFinalPath, "utf8"); + const data = JSON.parse(raw) as Parameters[0]; + const map = createCoverageMap(); + map.merge(data); + return map; +} + +/** Group file paths by top-level segment under `src/` (e.g. actions, server, utils). */ +export function summarizeByTopSrcDir( + map: CoverageMap, + packageRoot: string, + repoRoot: string, +): Record { + const srcPrefix = path.join(repoRoot, packageRoot, "src") + path.sep; + const groups: Record = {}; + + for (const file of map.files()) { + if (!file.startsWith(srcPrefix)) { + continue; + } + const rel = file.slice(srcPrefix.length); + const top = rel.split(path.sep)[0]; + if (!top || top.startsWith("@")) { + continue; + } + if (!groups[top]) { + groups[top] = createCoverageMap(); + } + const fc = map.fileCoverageFor(file); + groups[top].merge({ [file]: fc.data }); + } + + const out: Record = {}; + for (const [name, g] of Object.entries(groups)) { + out[name] = metricsFromSummary(g.getCoverageSummary()); + } + return out; +} diff --git a/typescript/coverage-tools/tsconfig.json b/typescript/coverage-tools/tsconfig.json new file mode 100644 index 000000000..58f24c8f4 --- /dev/null +++ b/typescript/coverage-tools/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "module": "Preserve", + "moduleResolution": "bundler", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/typescript/db-migration-engine/package.json b/typescript/db-migration-engine/package.json index b68499786..3f5fdd872 100644 --- a/typescript/db-migration-engine/package.json +++ b/typescript/db-migration-engine/package.json @@ -21,7 +21,6 @@ "bench": "node -e \"process.exit(0)\"" }, "dependencies": { - "kysely": "catalog:", "pg": "catalog:" }, "devDependencies": { diff --git a/typescript/db/package.json b/typescript/db/package.json index fb63c621c..768cf2c1e 100644 --- a/typescript/db/package.json +++ b/typescript/db/package.json @@ -12,8 +12,7 @@ "bench": "vitest bench --passWithNoTests" }, "dependencies": { - "kysely": "catalog:", - "pg": "catalog:" + "kysely": "catalog:" }, "devDependencies": { "@typescript/native-preview": "catalog:", diff --git a/typescript/db/src/generated/index.ts b/typescript/db/src/generated/index.ts index fec9ba3c6..23bd3d2d8 100644 --- a/typescript/db/src/generated/index.ts +++ b/typescript/db/src/generated/index.ts @@ -2,6 +2,7 @@ // This file is automatically generated by Kanel. Do not modify manually. export { type priv_svc_cg_card_info_service, type default as PrivSvcCgCardInfoTable, type PrivSvcCgCardInfo, type NewPrivSvcCgCardInfo, type PrivSvcCgCardInfoUpdate } from './public/PrivSvcCgCardInfo'; +export { type score_rederive_chart_id, type default as ScoreRederiveTable, type ScoreRederive, type NewScoreRederive, type ScoreRederiveUpdate } from './public/ScoreRederive'; export { type class_achievement_row_id, type default as ClassAchievementTable, type ClassAchievement, type NewClassAchievement, type ClassAchievementUpdate } from './public/ClassAchievement'; export { type account_id, type default as AccountTable, type Account, type NewAccount, type AccountUpdate } from './public/Account'; export { type import_tracker_import_id, type default as ImportTrackerTable, type ImportTracker, type NewImportTracker, type ImportTrackerUpdate } from './public/ImportTracker'; @@ -33,6 +34,7 @@ export { type session_id, type default as SessionTable, type Session, type NewSe export { type priv_password_reset_token_token, type default as PrivPasswordResetTokenTable, type PrivPasswordResetToken, type NewPrivPasswordResetToken, type PrivPasswordResetTokenUpdate } from './public/PrivPasswordResetToken'; export { type song_id, type default as SongTable, type Song, type NewSong, type SongUpdate } from './public/Song'; export { type account_username_change_row_id, type default as AccountUsernameChangeTable, type AccountUsernameChange, type NewAccountUsernameChange, type AccountUsernameChangeUpdate } from './public/AccountUsernameChange'; +export { type pb_dirty_user_id, type pb_dirty_chart_id, type default as PbDirtyTable, type PbDirty, type NewPbDirty, type PbDirtyUpdate } from './public/PbDirty'; export { type default as MigrationTable, type Migration, type NewMigration, type MigrationUpdate } from './public/Migration'; export { type action_row_id, type default as ActionTable, type Action, type NewAction, type ActionUpdate } from './public/Action'; export { type priv_svc_kai_auth_token_service, type default as PrivSvcKaiAuthTokenTable, type PrivSvcKaiAuthToken, type NewPrivSvcKaiAuthToken, type PrivSvcKaiAuthTokenUpdate } from './public/PrivSvcKaiAuthToken'; @@ -71,5 +73,7 @@ export { type default as Game } from './public/Game'; export { type default as ActionResult } from './public/ActionResult'; export { type default as GameGroup } from './public/GameGroup'; export { type default as ImportType } from './public/ImportType'; +export { type enqueue_pb_dirty_params } from './public/enqueue_pb_dirty'; +export { type enqueue_score_rederive_params } from './public/enqueue_score_rederive'; export { type default as PublicSchema } from './public/PublicSchema'; export { type default as Database } from './Database'; diff --git a/typescript/db/src/generated/public/Chart.ts b/typescript/db/src/generated/public/Chart.ts index fe33153dd..e5e1d5a00 100644 --- a/typescript/db/src/generated/public/Chart.ts +++ b/typescript/db/src/generated/public/Chart.ts @@ -29,6 +29,8 @@ export default interface ChartTable { versions: ColumnType; data: ColumnType; + + derivation_checksum: ColumnType; } export type Chart = Selectable; diff --git a/typescript/db/src/generated/public/PbDirty.ts b/typescript/db/src/generated/public/PbDirty.ts new file mode 100644 index 000000000..e2d6c12f5 --- /dev/null +++ b/typescript/db/src/generated/public/PbDirty.ts @@ -0,0 +1,25 @@ +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; + +/** Identifier type for public.pb_dirty */ +export type pb_dirty_user_id = number; + +/** Identifier type for public.pb_dirty */ +export type pb_dirty_chart_id = string; + +/** Represents the table public.pb_dirty */ +export default interface PbDirtyTable { + user_id: ColumnType; + + chart_id: ColumnType; + + enqueued_at: ColumnType; +} + +export type PbDirty = Selectable; + +export type NewPbDirty = Insertable; + +export type PbDirtyUpdate = Updateable; diff --git a/typescript/db/src/generated/public/PublicSchema.ts b/typescript/db/src/generated/public/PublicSchema.ts index dad578964..7f1a622fc 100644 --- a/typescript/db/src/generated/public/PublicSchema.ts +++ b/typescript/db/src/generated/public/PublicSchema.ts @@ -2,6 +2,7 @@ // This file is automatically generated by Kanel. Do not modify manually. import type { default as PrivSvcCgCardInfoTable } from './PrivSvcCgCardInfo'; +import type { default as ScoreRederiveTable } from './ScoreRederive'; import type { default as ClassAchievementTable } from './ClassAchievement'; import type { default as AccountTable } from './Account'; import type { default as ImportTrackerTable } from './ImportTracker'; @@ -33,6 +34,7 @@ import type { default as SessionTable } from './Session'; import type { default as PrivPasswordResetTokenTable } from './PrivPasswordResetToken'; import type { default as SongTable } from './Song'; import type { default as AccountUsernameChangeTable } from './AccountUsernameChange'; +import type { default as PbDirtyTable } from './PbDirty'; import type { default as MigrationTable } from './Migration'; import type { default as ActionTable } from './Action'; import type { default as PrivSvcKaiAuthTokenTable } from './PrivSvcKaiAuthToken'; @@ -69,6 +71,8 @@ import type { default as ChartLeaderboardTable } from './ChartLeaderboard'; export default interface PublicSchema { priv_svc_cg_card_info: PrivSvcCgCardInfoTable; + score_rederive: ScoreRederiveTable; + class_achievement: ClassAchievementTable; account: AccountTable; @@ -131,6 +135,8 @@ export default interface PublicSchema { account_username_change: AccountUsernameChangeTable; + pb_dirty: PbDirtyTable; + _migration: MigrationTable; action: ActionTable; diff --git a/typescript/db/src/generated/public/ScoreRederive.ts b/typescript/db/src/generated/public/ScoreRederive.ts new file mode 100644 index 000000000..a7282858b --- /dev/null +++ b/typescript/db/src/generated/public/ScoreRederive.ts @@ -0,0 +1,20 @@ +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; + +/** Identifier type for public.score_rederive */ +export type score_rederive_chart_id = string; + +/** Represents the table public.score_rederive */ +export default interface ScoreRederiveTable { + chart_id: ColumnType; + + enqueued_at: ColumnType; +} + +export type ScoreRederive = Selectable; + +export type NewScoreRederive = Insertable; + +export type ScoreRederiveUpdate = Updateable; diff --git a/typescript/db/src/generated/public/enqueue_pb_dirty.ts b/typescript/db/src/generated/public/enqueue_pb_dirty.ts new file mode 100644 index 000000000..adb9357f5 --- /dev/null +++ b/typescript/db/src/generated/public/enqueue_pb_dirty.ts @@ -0,0 +1,5 @@ +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +export interface enqueue_pb_dirty_params { +} diff --git a/typescript/db/src/generated/public/enqueue_score_rederive.ts b/typescript/db/src/generated/public/enqueue_score_rederive.ts new file mode 100644 index 000000000..7c6cc1190 --- /dev/null +++ b/typescript/db/src/generated/public/enqueue_score_rederive.ts @@ -0,0 +1,5 @@ +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +export interface enqueue_score_rederive_params { +} diff --git a/typescript/docs-autogen-scripts/package.json b/typescript/docs-autogen-scripts/package.json index 75ea60b7a..c7f699391 100644 --- a/typescript/docs-autogen-scripts/package.json +++ b/typescript/docs-autogen-scripts/package.json @@ -22,7 +22,6 @@ "bun": ">=1.2.0" }, "dependencies": { - "commander": "catalog:", "tachi-common": "workspace:*" }, "imports": { diff --git a/typescript/rg-stats/eslint.config.mjs b/typescript/rg-stats/eslint.config.mjs new file mode 100644 index 000000000..f7c5149ca --- /dev/null +++ b/typescript/rg-stats/eslint.config.mjs @@ -0,0 +1,22 @@ +import configTachi from "eslint-config-tachi"; + +export default [ + ...configTachi.base, + configTachi.node, + { + ignores: ["build/**", "node_modules/**", "tap-snapshots/**"], + }, + { + files: ["src/**/*.ts"], + name: "rg-stats/vendored-style", + rules: { + "@typescript-eslint/consistent-type-imports": "off", + "perfectionist/sort-exports": "off", + // Upstream rg-stats keeps intentional ordering (e.g. score tiers, lamp unions). + "perfectionist/sort-imports": "off", + "perfectionist/sort-maps": "off", + "perfectionist/sort-object-types": "off", + "perfectionist/sort-union-types": "off", + }, + }, +]; diff --git a/typescript/rg-stats/package.json b/typescript/rg-stats/package.json new file mode 100644 index 000000000..7518af7e6 --- /dev/null +++ b/typescript/rg-stats/package.json @@ -0,0 +1,62 @@ +{ + "name": "rg-stats", + "version": "1.2.1", + "description": "A library for calculating various rhythm game stats.", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./js/util/types": { + "types": "./src/util/types.ts", + "default": "./src/util/types.ts" + } + }, + "scripts": { + "test": "vitest run --coverage", + "snapshot": "vitest run -u", + "snap": "vitest run -u", + "build": "tsgo -p tsconfig.build.json", + "prepublishOnly": "vitest run --coverage && tsgo -p tsconfig.build.json", + "lint": "eslint .", + "lint-fix": "eslint . --fix", + "typecheck": "tsgo --noEmit", + "bench": "node -e \"process.exit(0)\"" + }, + "publishConfig": { + "main": "./build/index.js", + "exports": { + ".": { + "types": "./build/index.d.ts", + "default": "./build/index.js" + }, + "./js/util/types": { + "types": "./build/util/types.d.ts", + "default": "./build/util/types.js" + } + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/zkldi/rg-stats.git" + }, + "keywords": [ + "rhythm", + "game", + "stats", + "math" + ], + "author": "zkldi", + "license": "MIT", + "bugs": { + "url": "https://github.com/zkldi/rg-stats/issues" + }, + "homepage": "https://github.com/zkldi/rg-stats#readme", + "devDependencies": { + "@typescript/native-preview": "catalog:", + "@vitest/coverage-v8": "catalog:", + "eslint-config-tachi": "workspace:*", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/chunithm-rating.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/chunithm-rating.test.ts.snap new file mode 100644 index 000000000..772050162 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/chunithm-rating.test.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CHUNITHM Rating Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. internalChartLevel=-1."`; + +exports[`CHUNITHM Rating Validation Tests > Should throw if your score is >= 1.01million. 1`] = `"Invalid input, Score cannot be greater than 1.01Million. score=1010001."`; + +exports[`CHUNITHM Rating Validation Tests > Should throw if your score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/ddr-flare.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/ddr-flare.test.ts.snap new file mode 100644 index 000000000..5a67cc219 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/ddr-flare.test.ts.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DDR Flare Validation Tests > Should throw if chart level is greater than 19. 1`] = `"Invalid input, Internal chart level cannot be greater than 19. level=20."`; + +exports[`DDR Flare Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Internal chart level cannot be negative or zero. level=-1."`; + +exports[`DDR Flare Validation Tests > Should throw if flare level is greater than 10. 1`] = `"Invalid input, Flare level cannot be greater than 10. level=11."`; + +exports[`DDR Flare Validation Tests > Should throw if flare level is negative. 1`] = `"Invalid input, Flare level cannot be negative. level=-1."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/gitadora-skill.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/gitadora-skill.test.ts.snap new file mode 100644 index 000000000..6b6208a15 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/gitadora-skill.test.ts.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GITADORA Inverse Skill Validation Tests > Should throw if level provided was negative 1`] = `"Invalid input, Chart Level cannot be negative. level=-1."`; + +exports[`GITADORA Inverse Skill Validation Tests > Should throw if skill provided was negative 1`] = `"Invalid input, Skill cannot be negative. skill=-1."`; + +exports[`GITADORA Inverse Skill Validation Tests > Should throw if the skill provided was not possible on this chart. 1`] = `"Invalid input, A skill level of 9000 is not possible on a chart with level 1. skill=9000, level=1."`; + +exports[`GITADORA Skill Validation Tests > Should throw if level provided was negative 1`] = `"Invalid input, Chart Level cannot be negative. level=-1."`; + +exports[`GITADORA Skill Validation Tests > Should throw if skill provided was negative 1`] = `"Invalid input, Score Percent cannot be negative. scorePercent=-1."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/itg-highest-unbroken.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/itg-highest-unbroken.test.ts.snap new file mode 100644 index 000000000..b2c7fec1c --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/itg-highest-unbroken.test.ts.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ITG Highest Unbroken Tests > Should throw if bpms and notes don't have same length 1`] = `"Invalid input, Notes Per Measure and NPS Per Measure didn't have the same length. npsLen=0, notesLen=1."`; + +exports[`ITG Highest Unbroken Tests > Should throw if died at is negative 1`] = `"Invalid input, DiedAt must be positive. diedAt=-1."`; + +exports[`ITG Highest Unbroken Tests > Should throw if measures is 0 1`] = `"Invalid input, Measures must be greater than 1. measures=0."`; + +exports[`ITG Highest Unbroken Tests > Should throw if measures is 1 1`] = `"Invalid input, Measures must be greater than 1. measures=1."`; + +exports[`ITG Highest Unbroken Tests > Should throw if measures is negative 1`] = `"Invalid input, Measures must be greater than 1. measures=-1."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/jubility.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/jubility.test.ts.snap new file mode 100644 index 000000000..b70ae71b5 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/jubility.test.ts.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Inverse Jubility Tests > Should throw if the requested jubility is not possible on a chart of that level. 1`] = `"Invalid input, A jubility of 9000 is not possible on a chart with level 1. jubility=9000, level=1."`; + +exports[`Jubility Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Level cannot be negative. level=-1."`; + +exports[`Jubility Validation Tests > Should throw if music rate is > 120. 1`] = `"Invalid input, MusicRate cannot be greater than 120. musicRate=120.1."`; + +exports[`Jubility Validation Tests > Should throw if music rate is negative. 1`] = `"Invalid input, MusicRate cannot be negative. musicRate=-1."`; + +exports[`Jubility Validation Tests > Should throw if score is > 1million. 1`] = `"Invalid input, Score cannot be greater than 1million. score=1000001."`; + +exports[`Jubility Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/maimai-rate.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/maimai-rate.test.ts.snap new file mode 100644 index 000000000..00f43f927 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/maimai-rate.test.ts.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`maimai Rate Validation Tests > Should throw if level is negative. 1`] = `"Invalid input, Internal chart level cannot be negative. level=-1."`; + +exports[`maimai Rate Validation Tests > Should throw if max score is greater than 104%. 1`] = `"Invalid input, Max score cannot be greater than 104%. maxScore=104.1."`; + +exports[`maimai Rate Validation Tests > Should throw if score is greater than max score. 1`] = `"Invalid input, Score cannot be greater than max score. score=101.5, maxScore=100.68."`; + +exports[`maimai Rate Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/maimaidx-rate.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/maimaidx-rate.test.ts.snap new file mode 100644 index 000000000..dcc533b19 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/maimaidx-rate.test.ts.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`maimai DX Rate Validation Tests > Should throw if lamp is ALL PERFECT but score is below 100%. 1`] = `"Invalid input, Cannot have an ALL PERFECT without at least 100%. score=99.9, lamp="ALL PERFECT"."`; + +exports[`maimai DX Rate Validation Tests > Should throw if lamp is ALL PERFECT+ but score is not 101%. 1`] = `"Invalid input, Cannot have an ALL PERFECT+ without 101%. score=100.5, lamp="ALL PERFECT+"."`; + +exports[`maimai DX Rate Validation Tests > Should throw if lamp is CLEAR but score is below 80%. 1`] = `"Invalid input, A score of <80% should not be a CLEAR. score=79, lamp="CLEAR"."`; + +exports[`maimai DX Rate Validation Tests > Should throw if lamp is FAILED but score is above 80%. 1`] = `"Invalid input, A score of >=80% should not be a FAILED. score=81, lamp="FAILED"."`; + +exports[`maimai DX Rate Validation Tests > Should throw if level is negative. 1`] = `"Invalid input, Internal chart level cannot be negative. level=-1."`; + +exports[`maimai DX Rate Validation Tests > Should throw if score is 101% but lamp is not ALL PERFECT+ 1`] = `"Invalid input, A score of 101% should be an ALL PERFECT+. score=101, lamp="ALL PERFECT"."`; + +exports[`maimai DX Rate Validation Tests > Should throw if score is greater than 101%. 1`] = `"Invalid input, Score cannot be greater than 101%. score=101.5."`; + +exports[`maimai DX Rate Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/ongeki-rating.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/ongeki-rating.test.ts.snap new file mode 100644 index 000000000..295a676b1 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/ongeki-rating.test.ts.snap @@ -0,0 +1,27 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`O.N.G.E.K.I. Rating Validation Tests > Classic should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. internalChartLevel=-1."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Classic should throw if your score is >= 1.01million. 1`] = `"Invalid input, Technical score cannot be greater than 1.01Million. technicalScore=1010001."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Classic should throw if your score is negative. 1`] = `"Invalid input, Technical score cannot be negative. technicalScore=-1."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Platinum should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. internalChartLevel=-1."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Platinum should throw if star number is negative. 1`] = `"Invalid input, Invalid number of stars. stars=-1."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Platinum should throw if stars > 6. 1`] = `"Invalid input, Invalid number of stars. stars=7."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. internalChartLevel=-1."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if lamp is ALL BREAK+ without 1.01M. 1`] = `"Invalid input, Invalid AB+. fullBell="true", noteLamp="ALL BREAK+"."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is >= 1.01million. 1`] = `"Invalid input, Technical score cannot be greater than 1.01Million. technicalScore=1010001."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is 1.01M without ALL BREAK+. 1`] = `"Invalid input, Invalid AB+. fullBell="true", noteLamp="ALL BREAK"."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is 1.01M without FULL BELL. 1`] = `"Invalid input, Invalid AB+. fullBell="false", noteLamp="ALL BREAK+"."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is a LOSS FULL BELL. 1`] = `"Invalid input, Cannot have a LOSS FULL BELL. noteLamp="LOSS"."`; + +exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is negative. 1`] = `"Invalid input, Technical score cannot be negative. technicalScore=-1."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/popn-classpoints.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/popn-classpoints.test.ts.snap new file mode 100644 index 000000000..699b39dee --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/popn-classpoints.test.ts.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Pop'n Class Points Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. level=-1."`; + +exports[`Pop'n Class Points Validation Tests > Should throw if lamp is invalid. 1`] = `"Unknown lamp of FOO passed to pop'n Class Points calculations. Expected any of FAILED, EASY CLEAR, CLEAR, FULL COMBO, PERFECT."`; + +exports[`Pop'n Class Points Validation Tests > Should throw if score is > 100k. 1`] = `"Invalid input, Score cannot be better than 100k. score=100001."`; + +exports[`Pop'n Class Points Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`; + +exports[`Pop'n Inverse Class Points Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. level=-1."`; + +exports[`Pop'n Inverse Class Points Validation Tests > Should throw if the provided class points are impossible to achieve given the other constraints. 1`] = `"Invalid input, 9000 class points is not achievable on a chart with level 1 and lamp FAILED. classPoints=9000, level=1, lamp="FAILED"."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/poyashi-bpi.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/poyashi-bpi.test.ts.snap new file mode 100644 index 000000000..7afd16192 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/poyashi-bpi.test.ts.snap @@ -0,0 +1,51 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BPI Validation Tests > Should throw if KAVG == WR 1`] = `"Invalid input, BPI is undefined for the case where Kaiden Average equals the World Record. kaidenAverage=120, worldRecord=120."`; + +exports[`BPI Validation Tests > Should throw if KAVG > WR 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=125, worldRecord=120."`; + +exports[`BPI Validation Tests > Should throw if KAVG is not an integer. 1`] = `"Invalid input, Kaiden Average was not an integer. kaidenAverage=15.5."`; + +exports[`BPI Validation Tests > Should throw if Kaiden Average is negative. 1`] = `"Invalid input, Kaiden Average was negative or zero. kaidenAverage=-1."`; + +exports[`BPI Validation Tests > Should throw if MAX is negative. 1`] = `"Invalid input, Kaiden Average was greater than MAX. kaidenAverage=100, max=-1."`; + +exports[`BPI Validation Tests > Should throw if MAX is not an integer. 1`] = `"Invalid input, MAX was not an integer. max=15.5."`; + +exports[`BPI Validation Tests > Should throw if WR > MAX 1`] = `"Invalid input, World Record was greater than MAX. worldRecord=130, max=120."`; + +exports[`BPI Validation Tests > Should throw if WR is negative. 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=100, worldRecord=-1."`; + +exports[`BPI Validation Tests > Should throw if WR is not an integer. 1`] = `"Invalid input, World Record was not an integer. worldRecord=15.5."`; + +exports[`BPI Validation Tests > Should throw if your score > MAX 1`] = `"Invalid input, Provided EX was greater than MAX. yourEx=130, max=120."`; + +exports[`BPI Validation Tests > Should throw if your score is negative. 1`] = `"Invalid input, Provided EX was negative. yourEx=-1."`; + +exports[`BPI Validation Tests > Should throw if your score is not an integer. 1`] = `"Invalid input, Provided EX was not an integer. yourEx=15.5."`; + +exports[`InverseBPI Validation Tests > BPI less than -15 should throw an error. 1`] = `"Invalid input, BPI must be greater than or equal to -15. bpi=-16."`; + +exports[`InverseBPI Validation Tests > Should throw if KAVG == WR 1`] = `"Invalid input, BPI is undefined for the case where Kaiden Average equals the World Record. kaidenAverage=120, worldRecord=120."`; + +exports[`InverseBPI Validation Tests > Should throw if KAVG > WR 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=125, worldRecord=120."`; + +exports[`InverseBPI Validation Tests > Should throw if KAVG is not an integer. 1`] = `"Invalid input, Kaiden Average was not an integer. kaidenAverage=15.5."`; + +exports[`InverseBPI Validation Tests > Should throw if Kaiden Average is 0. 1`] = `"Invalid input, Kaiden Average was negative or zero. kaidenAverage=0."`; + +exports[`InverseBPI Validation Tests > Should throw if Kaiden Average is negative. 1`] = `"Invalid input, Kaiden Average was negative or zero. kaidenAverage=-1."`; + +exports[`InverseBPI Validation Tests > Should throw if MAX is 0. 1`] = `"Invalid input, Kaiden Average was greater than MAX. kaidenAverage=100, max=0."`; + +exports[`InverseBPI Validation Tests > Should throw if MAX is negative. 1`] = `"Invalid input, Kaiden Average was greater than MAX. kaidenAverage=100, max=-1."`; + +exports[`InverseBPI Validation Tests > Should throw if MAX is not an integer. 1`] = `"Invalid input, MAX was not an integer. max=15.5."`; + +exports[`InverseBPI Validation Tests > Should throw if WR > MAX 1`] = `"Invalid input, World Record was greater than MAX. worldRecord=130, max=120."`; + +exports[`InverseBPI Validation Tests > Should throw if WR is 0. 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=100, worldRecord=0."`; + +exports[`InverseBPI Validation Tests > Should throw if WR is negative. 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=100, worldRecord=-1."`; + +exports[`InverseBPI Validation Tests > Should throw if WR is not an integer. 1`] = `"Invalid input, World Record was not an integer. worldRecord=15.5."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/volforce.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/volforce.test.ts.snap new file mode 100644 index 000000000..632e7c08b --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/volforce.test.ts.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`InverseVF5 Tests > Should throw if the volforce is impossible to achieve with this lamp/level. 1`] = `"Invalid input, A VF5 of 900 is not possible on a chart with level 1. vf5=900, level=1."`; + +exports[`InverseVF6 Tests > Should throw if the volforce is impossible to achieve with this lamp/level. 1`] = `"Invalid input, A VF6 of 900 is not possible on a chart with level 1. vf6=900, level=1."`; + +exports[`VF4 Validation Tests > Should throw if score is greater than 10 million. 1`] = `"Invalid input, Score cannot be greater than 10million. score=10000001."`; + +exports[`VF4 Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`; + +exports[`VF4 Validation Tests > Should throw if the VF4 requested as an inversion is impossible. 1`] = `"Invalid input, A VF4 of 9000 is not possible on a chart with level 20. vf4=9000, level=20."`; diff --git a/typescript/rg-stats/src/algorithms/__snapshots__/wacca-rate.test.ts.snap b/typescript/rg-stats/src/algorithms/__snapshots__/wacca-rate.test.ts.snap new file mode 100644 index 000000000..65cebc3c6 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/__snapshots__/wacca-rate.test.ts.snap @@ -0,0 +1,21 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`WACCA Inverse Rate Validation Tests > Should throw if level is negative. 1`] = `"Invalid input, Chart level cannot be negative. level=-1."`; + +exports[`WACCA Inverse Rate Validation Tests > Should throw if level is negative. 2`] = `"Invalid input, Chart level cannot be negative. level=-1."`; + +exports[`WACCA Inverse Rate Validation Tests > Should throw if rate is impossible. 1`] = `"Invalid input, A rate of 100 is not possible on a chart of level 1. rate=100, level=1."`; + +exports[`WACCA Inverse Rate Validation Tests > Should throw if rate is impossible. 2`] = `"Invalid input, A rate of 100 is not possible on a chart of level 1. rate=100, level=1."`; + +exports[`WACCA Rate Validation Tests > Should throw if level is negative. 1`] = `"Invalid input, Chart level cannot be negative. level=-1."`; + +exports[`WACCA Rate Validation Tests > Should throw if level is negative. 2`] = `"Invalid input, Chart level cannot be negative. level=-1."`; + +exports[`WACCA Rate Validation Tests > Should throw if score is greater than 1million. 1`] = `"Invalid input, Score cannot be greater than 1million. score=1000001."`; + +exports[`WACCA Rate Validation Tests > Should throw if score is greater than 1million. 2`] = `"Invalid input, Score cannot be greater than 1million. score=1000001."`; + +exports[`WACCA Rate Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`; + +exports[`WACCA Rate Validation Tests > Should throw if score is negative. 2`] = `"Invalid input, Score cannot be negative. score=-1."`; diff --git a/typescript/rg-stats/src/algorithms/chunithm-rating.test.ts b/typescript/rg-stats/src/algorithms/chunithm-rating.test.ts new file mode 100644 index 000000000..fc8e16e5a --- /dev/null +++ b/typescript/rg-stats/src/algorithms/chunithm-rating.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { calculate } from "./chunithm-rating"; + +test("CHUNITHM Rating Tests", () => { + const LEVEL = 12.5; + expect(calculate(1_010_000, LEVEL)).toBe(LEVEL + 2.15); + expect(calculate(1_007_500, LEVEL)).toBe(LEVEL + 2); + expect(calculate(1_005_000, LEVEL)).toBe(LEVEL + 1.5); + expect(calculate(1_000_000, LEVEL)).toBe(LEVEL + 1); + expect(calculate(975_000, LEVEL)).toBe(LEVEL); + expect(calculate(900_000, LEVEL)).toBe(LEVEL - 5); + expect(calculate(800_000, LEVEL)).toBe(3.75); + expect(calculate(500_000, LEVEL)).toBe(0); + expect(calculate(0, LEVEL)).toBe(0); + + expect(calculate(987_000, LEVEL)).toBe(12.98); + expect(calculate(1_008_000, LEVEL)).toBe(14.55); + expect(calculate(1_003_000, LEVEL)).toBe(13.8); + expect(calculate(999_000, LEVEL)).toBe(13.46); + expect(calculate(980_000, LEVEL)).toBe(12.7); + expect(calculate(950_000, LEVEL)).toBe(10.83); + expect(calculate(810_000, LEVEL)).toBe(4.12); + expect(calculate(600_000, LEVEL)).toBe(1.25); + expect(calculate(50_000, LEVEL)).toBe(0); +}); + +test("CHUNITHM Rating Edge Cases", () => { + expect( + calculate(1_010_000, 0), + "A perfect score on a chart with level 0 should be valid, and worth 0 + 2.15.", + ).toBe(2.15); + expect(calculate(0, 12.5), "A score of 0 should be worth 0.").toBe(0); + expect(calculate(0, 0), "A score of 0 on a chart with level 0 should be worth 0.").toBe(0); +}); + +test("CHUNITHM Rating Validation Tests", () => { + expectThrowsSnapshot(() => calculate(-1, 12.5), "Should throw if your score is negative."); + + expectThrowsSnapshot( + () => calculate(1_010_001, 12.5), + "Should throw if your score is >= 1.01million.", + ); + + expectThrowsSnapshot(() => calculate(900_000, -1), "Should throw if chart level is negative."); +}); diff --git a/typescript/rg-stats/src/algorithms/chunithm-rating.ts b/typescript/rg-stats/src/algorithms/chunithm-rating.ts new file mode 100644 index 000000000..134d6217f --- /dev/null +++ b/typescript/rg-stats/src/algorithms/chunithm-rating.ts @@ -0,0 +1,38 @@ +import { ThrowIf } from "../util/throw-if"; + +/** + * Calculates the rating for a CHUNITHM score. + * This is accurate up to SUN. + * + * @param score - The score the user got. This is a value between 0 and 1.01million. + * @param internalChartLevel - The internal chart level. This is a decimal value stored by the game internally. + */ +export function calculate(score: number, internalChartLevel: number) { + const levelBase = internalChartLevel * 100; + + ThrowIf(score > 1_010_000, "Score cannot be greater than 1.01Million.", { score }); + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { internalChartLevel }); + + let val = 0; + + if (score >= 1_009_000) { + val = levelBase + 215; + } else if (score >= 1_007_500) { + val = levelBase + 200 + (score - 1_007_500) / 100; + } else if (score >= 1_005_000) { + val = levelBase + 150 + ((score - 1_005_000) * 10) / 500; + } else if (score >= 1_000_000) { + val = levelBase + 100 + ((score - 1_000_000) * 5) / 500; + } else if (score >= 975_000) { + val = levelBase + ((score - 975_000) * 2) / 500; + } else if (score >= 900_000) { + val = levelBase - 500 + ((score - 900_000) * 2) / 300; + } else if (score >= 800_000) { + val = (levelBase - 500) / 2 + ((score - 800_000) * ((levelBase - 500) / 2)) / 100_000; + } else if (score >= 500_000) { + val = (((levelBase - 500) / 2) * (score - 500_000)) / 300_000; + } + + return Math.max(Math.floor(val) / 100, 0); +} diff --git a/typescript/rg-stats/src/algorithms/curator-skill.test.ts b/typescript/rg-stats/src/algorithms/curator-skill.test.ts new file mode 100644 index 000000000..5c71ceb75 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/curator-skill.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "vitest"; +import { TestCase } from "../test-utils/test-case"; +import { calculate, inverse } from "./curator-skill"; + +const testCases = [ + [980_740, 16, 1569], + [975_531, 16, 1560], + [971_879, 16, 1555], + [968_195, 16, 1549], + [964_310, 16, 1542], + [959_301, 16, 1534], + [950_871, 16, 1521], + [998_269, 15, 1497], + [996_327, 15, 1494], + [995_580, 15, 1493], + [995_690, 15, 1493], + [994_676, 15, 1492], + [992_261, 15, 1488], + [992_304, 15, 1488], + [992_365, 15, 1488], + [992_162, 15, 1488], + [991_697, 15, 1487], + [991_488, 15, 1487], + [990_949, 15, 1486], + [990_748, 15, 1486], +]; + +test("MUSECA Curator Skill Tests", () => { + function MakeTestCase(score: number, level: number, skill: number): TestCase { + return () => + expect( + calculate(score, level), + `A score of ${score} on a level ${level} chart should be worth ${skill} skill.`, + ).toBe(skill); + } + + for (const testCase of testCases) { + MakeTestCase(testCase[0], testCase[1], testCase[2])(); + } +}); + +test("MUSECA Inverse Curator Skill Tests", () => { + function MakeTestCase(score: number, level: number, skill: number): TestCase { + return () => + expect( + inverse(skill, level), + `A skill of ${skill} on a level ${level} chart should need ${score} score.`, + ).toBe(score); + } + + const inverseTestCases = [ + [980_625, 16, 1569], + [975_000, 16, 1560], + [971_875, 16, 1555], + [968_125, 16, 1549], + [963_750, 16, 1542], + [958_750, 16, 1534], + [950_625, 16, 1521], + [998_000, 15, 1497], + [996_000, 15, 1494], + [995_334, 15, 1493], + [994_667, 15, 1492], + [992_000, 15, 1488], + [991_334, 15, 1487], + [990_667, 15, 1486], + ]; + + for (const testCase of inverseTestCases) { + MakeTestCase(testCase[0], testCase[1], testCase[2])(); + } +}); diff --git a/typescript/rg-stats/src/algorithms/curator-skill.ts b/typescript/rg-stats/src/algorithms/curator-skill.ts new file mode 100644 index 000000000..9977d9972 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/curator-skill.ts @@ -0,0 +1,35 @@ +import { ThrowIf } from "../util/throw-if"; + +/** + * Calculate MUSECA "Curator Skill" for a score. + * + * @param score - The score to calculate the skill rating for. + * @param chartLevel - The level for this chart. Note that some charts are considered + * to be level 16s, and you should pass 16 to this function instead. + */ +export function calculate(score: number, chartLevel: number): number { + ThrowIf(score > 1_000_000, "Score cannot be greater than 1 million.", { score }); + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf.negative(chartLevel, "Chart level cannot be negative.", { chartLevel }); + + return Math.floor(chartLevel * (score / 10_000)); +} + +/** + * Given a curator skill value, return the score necessary to get that skill + * on a chart of the provided level. Throws if this is not possible. + * + * @param skill - The curator skill value to invert. + * @param chartLevel - The difficulty of chart you'd want to achieve this skill value on. + */ +export function inverse(skill: number, chartLevel: number): number { + const score = (skill / chartLevel) * 10_000; + + ThrowIf( + score >= 1_000_000, + `A skill level of ${skill} is not possible on a chart with level ${chartLevel}.`, + { skill, chartLevel }, + ); + + return Math.ceil(score); +} diff --git a/typescript/rg-stats/src/algorithms/ddr-flare.test.ts b/typescript/rg-stats/src/algorithms/ddr-flare.test.ts new file mode 100644 index 000000000..bb957792a --- /dev/null +++ b/typescript/rg-stats/src/algorithms/ddr-flare.test.ts @@ -0,0 +1,239 @@ +import { expect, test } from "vitest"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { calculate } from "./ddr-flare"; + +const testCases = [ + [1, 0, 145], + [1, 1, 153], + [1, 2, 162], + [1, 3, 171], + [1, 4, 179], + [1, 5, 188], + [1, 6, 197], + [1, 7, 205], + [1, 8, 214], + [1, 9, 223], + [1, 10, 232], + [2, 0, 155], + [2, 1, 164], + [2, 2, 173], + [2, 3, 182], + [2, 4, 192], + [2, 5, 201], + [2, 6, 210], + [2, 7, 220], + [2, 8, 229], + [2, 9, 238], + [2, 10, 248], + [3, 0, 170], + [3, 1, 180], + [3, 2, 190], + [3, 3, 200], + [3, 4, 210], + [3, 5, 221], + [3, 6, 231], + [3, 7, 241], + [3, 8, 251], + [3, 9, 261], + [3, 10, 272], + [4, 0, 185], + [4, 1, 196], + [4, 2, 207], + [4, 3, 218], + [4, 4, 229], + [4, 5, 240], + [4, 6, 251], + [4, 7, 262], + [4, 8, 273], + [4, 9, 284], + [4, 10, 296], + [5, 0, 205], + [5, 1, 217], + [5, 2, 229], + [5, 3, 241], + [5, 4, 254], + [5, 5, 266], + [5, 6, 278], + [5, 7, 291], + [5, 8, 303], + [5, 9, 315], + [5, 10, 328], + [6, 0, 230], + [6, 1, 243], + [6, 2, 257], + [6, 3, 271], + [6, 4, 285], + [6, 5, 299], + [6, 6, 312], + [6, 7, 326], + [6, 8, 340], + [6, 9, 354], + [6, 10, 368], + [7, 0, 255], + [7, 1, 270], + [7, 2, 285], + [7, 3, 300], + [7, 4, 316], + [7, 5, 331], + [7, 6, 346], + [7, 7, 362], + [7, 8, 377], + [7, 9, 392], + [7, 10, 408], + [8, 0, 290], + [8, 1, 307], + [8, 2, 324], + [8, 3, 342], + [8, 4, 359], + [8, 5, 377], + [8, 6, 394], + [8, 7, 411], + [8, 8, 429], + [8, 9, 446], + [8, 10, 464], + [9, 0, 335], + [9, 1, 355], + [9, 2, 375], + [9, 3, 395], + [9, 4, 415], + [9, 5, 435], + [9, 6, 455], + [9, 7, 475], + [9, 8, 495], + [9, 9, 515], + [9, 10, 536], + [10, 0, 400], + [10, 1, 424], + [10, 2, 448], + [10, 3, 472], + [10, 4, 496], + [10, 5, 520], + [10, 6, 544], + [10, 7, 568], + [10, 8, 592], + [10, 9, 616], + [10, 10, 640], + [11, 0, 465], + [11, 1, 492], + [11, 2, 520], + [11, 3, 548], + [11, 4, 576], + [11, 5, 604], + [11, 6, 632], + [11, 7, 660], + [11, 8, 688], + [11, 9, 716], + [11, 10, 744], + [12, 0, 510], + [12, 1, 540], + [12, 2, 571], + [12, 3, 601], + [12, 4, 632], + [12, 5, 663], + [12, 6, 693], + [12, 7, 724], + [12, 8, 754], + [12, 9, 785], + [12, 10, 816], + [13, 0, 545], + [13, 1, 577], + [13, 2, 610], + [13, 3, 643], + [13, 4, 675], + [13, 5, 708], + [13, 6, 741], + [13, 7, 773], + [13, 8, 806], + [13, 9, 839], + [13, 10, 872], + [14, 0, 575], + [14, 1, 609], + [14, 2, 644], + [14, 3, 678], + [14, 4, 713], + [14, 5, 747], + [14, 6, 782], + [14, 7, 816], + [14, 8, 851], + [14, 9, 885], + [14, 10, 920], + [15, 0, 600], + [15, 1, 636], + [15, 2, 672], + [15, 3, 708], + [15, 4, 744], + [15, 5, 780], + [15, 6, 816], + [15, 7, 852], + [15, 8, 888], + [15, 9, 924], + [15, 10, 960], + [16, 0, 620], + [16, 1, 657], + [16, 2, 694], + [16, 3, 731], + [16, 4, 768], + [16, 5, 806], + [16, 6, 843], + [16, 7, 880], + [16, 8, 917], + [16, 9, 954], + [16, 10, 992], + [17, 0, 635], + [17, 1, 673], + [17, 2, 711], + [17, 3, 749], + [17, 4, 787], + [17, 5, 825], + [17, 6, 863], + [17, 7, 901], + [17, 8, 939], + [17, 9, 977], + [17, 10, 1016], + [18, 0, 650], + [18, 1, 689], + [18, 2, 728], + [18, 3, 767], + [18, 4, 806], + [18, 5, 845], + [18, 6, 884], + [18, 7, 923], + [18, 8, 962], + [18, 9, 1001], + [18, 10, 1040], + [19, 0, 665], + [19, 1, 704], + [19, 2, 744], + [19, 3, 784], + [19, 4, 824], + [19, 5, 864], + [19, 6, 904], + [19, 7, 944], + [19, 8, 984], + [19, 9, 1024], + [19, 10, 1064], +]; + +test("DDR Flare Tests", () => { + function MakeTestCase(internalChartLevel: number, flareLevel: number, flare: number): TestCase { + return () => { + const result = calculate(internalChartLevel, flareLevel); + expect( + result, + `Flare ${flareLevel} on level ${internalChartLevel} chart should be worth ${flare} flare points. Got ${result} instead.`, + ).toBe(flare); + }; + } + + for (const testCase of testCases) { + MakeTestCase(testCase[0], testCase[1], testCase[2])(); + } +}); + +test("DDR Flare Validation Tests", () => { + expectThrowsSnapshot(() => calculate(-1, 0), "Should throw if chart level is negative."); + expectThrowsSnapshot(() => calculate(20, 0), "Should throw if chart level is greater than 19."); + expectThrowsSnapshot(() => calculate(1, -1), "Should throw if flare level is negative."); + expectThrowsSnapshot(() => calculate(1, 11), "Should throw if flare level is greater than 10."); +}); diff --git a/typescript/rg-stats/src/algorithms/ddr-flare.ts b/typescript/rg-stats/src/algorithms/ddr-flare.ts new file mode 100644 index 000000000..ac38c070e --- /dev/null +++ b/typescript/rg-stats/src/algorithms/ddr-flare.ts @@ -0,0 +1,28 @@ +import { ThrowIf } from "../util/throw-if"; + +const FLARE_0_POINTS = [ + 145, 155, 170, 185, 205, 230, 255, 290, 335, 400, 465, 510, 545, 575, 600, 620, 635, 650, 665, +]; + +/** + * Calculate DDR Flare for a score. + * + * @param internalChartLevel - The internal decimal level of the chart the score was achieved on. + * @param flareLevel - The Flare level chosen to play the song. Goes from 0 ("Flare 0" or no Flare) to 10 ("Flare EX") + */ +export function calculate(internalChartLevel: number, flareLevel: number) { + ThrowIf.negativeOrZero(internalChartLevel, "Internal chart level cannot be negative or zero.", { + level: internalChartLevel, + }); + ThrowIf(internalChartLevel > 19, "Internal chart level cannot be greater than 19", { + level: internalChartLevel, + }); + ThrowIf.negative(flareLevel, "Flare level cannot be negative.", { + level: flareLevel, + }); + ThrowIf(flareLevel > 10, "Flare level cannot be greater than 10.", { + level: flareLevel, + }); + + return Math.floor((FLARE_0_POINTS[internalChartLevel - 1]! * (100 + flareLevel * 6)) / 100); +} diff --git a/typescript/rg-stats/src/algorithms/gitadora-skill.test.ts b/typescript/rg-stats/src/algorithms/gitadora-skill.test.ts new file mode 100644 index 000000000..1b8ab88f1 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/gitadora-skill.test.ts @@ -0,0 +1,64 @@ +import { test } from "vitest"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { calculate, inverse } from "./gitadora-skill"; + +test("GITADORA Skill Tests", () => { + function MakeTestCase(scorePercent: number, level: number, expectedSkill: number): TestCase { + return () => + isAprx( + calculate(scorePercent, level), + expectedSkill, + `A Score Percent of ${scorePercent} on a chart with level ${level} should be worth ${expectedSkill} skill.`, + ); + } + + const testCases = [ + MakeTestCase(89.48, 3.4, 60.84), + MakeTestCase(70.76, 5.8, 82.08), + MakeTestCase(40.2, 9.5, 76.38), + + MakeTestCase(97.8, 1, 19.56), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("GITADORA Skill Validation Tests", () => { + expectThrowsSnapshot(() => calculate(-1, 1), "Should throw if skill provided was negative"); + expectThrowsSnapshot(() => calculate(50, -1), "Should throw if level provided was negative"); +}); + +test("GITADORA Inverse Skill Tests", () => { + function MakeTestCase(expectedPercent: number, level: number, skill: number): TestCase { + return () => + isAprx( + inverse(skill, level), + expectedPercent, + `A Skill Level of ${skill} on a chart with level ${level} should invert to ${expectedPercent} percent.`, + ); + } + + const testCases = [ + MakeTestCase(89.48, 3.4, 60.84), + MakeTestCase(70.76, 5.8, 82.08), + MakeTestCase(40.2, 9.5, 76.38), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("GITADORA Inverse Skill Validation Tests", () => { + expectThrowsSnapshot( + () => inverse(9_000, 1), + "Should throw if the skill provided was not possible on this chart.", + ); + + expectThrowsSnapshot(() => inverse(-1, 1), "Should throw if skill provided was negative"); + expectThrowsSnapshot(() => inverse(50, -1), "Should throw if level provided was negative"); +}); diff --git a/typescript/rg-stats/src/algorithms/gitadora-skill.ts b/typescript/rg-stats/src/algorithms/gitadora-skill.ts new file mode 100644 index 000000000..5feda4602 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/gitadora-skill.ts @@ -0,0 +1,41 @@ +import { ThrowIf } from "../util/throw-if"; + +/** + * Calculate a GITADORA Skill value. + * + * @param scorePercent - The percent this score was worth. Must be between 0 and 100. + * @param level - The level for this chart. + */ +export function calculate(scorePercent: number, level: number) { + ThrowIf(scorePercent > 100, "Score Percent cannot be greater than 100.", { scorePercent }); + ThrowIf.negative(scorePercent, "Score Percent cannot be negative.", { scorePercent }); + ThrowIf.negative(level, "Chart Level cannot be negative.", { level }); + + const trueRating = scorePercent * level * 20; + + return Math.floor(trueRating) / 100; +} + +/** + * Given a GITADORA Skill and a level of a chart, return the percent necessary + * to achieve that skill value. + * + * This throws if the skill value is not possible. + * + * @param skill - The skill level to invert. + * @param level - The level of the chart this skill level should be inverted for. + */ +export function inverse(skill: number, level: number) { + ThrowIf.negative(skill, "Skill cannot be negative.", { skill }); + ThrowIf.negative(level, "Chart Level cannot be negative.", { level }); + + const percent = (100 * skill) / (20 * level); + + ThrowIf( + percent > 100, + `A skill level of ${skill} is not possible on a chart with level ${level}.`, + { skill, level }, + ); + + return percent; +} diff --git a/typescript/rg-stats/src/algorithms/itg-highest-unbroken.test.ts b/typescript/rg-stats/src/algorithms/itg-highest-unbroken.test.ts new file mode 100644 index 000000000..130ba8d46 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/itg-highest-unbroken.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "vitest"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { calculateFromBPMPerMeasure } from "./itg-highest-unbroken"; +import { RepeatNTimes } from "../util/misc"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; + +test("ITG Highest Unbroken Tests", () => { + function MakeTestCase( + expected32: number | null, + bpmPerMeasure: Array, + notesPerMeasure: Array, + diedAt?: number | null, + measures?: number, + ): TestCase { + return () => { + const value = calculateFromBPMPerMeasure( + bpmPerMeasure, + notesPerMeasure, + diedAt, + measures, + ); + + const msg = `A breakdown of ${bpmPerMeasure.join(", ")} should result in a highest ${ + measures ?? 32 + } of ${expected32}`; + + if (expected32 === null || value === null) { + return expect(value, msg).toBe(expected32); + } else { + return isAprx(value, expected32, msg); + } + }; + } + + const testCases = [ + MakeTestCase(null, [], []), + MakeTestCase(190, [50, 50, 50, ...RepeatNTimes(190, 32)], RepeatNTimes(16, 32 + 3)), + MakeTestCase( + 340, + [50, 50, 50, ...RepeatNTimes(340, 16)], + [16, 16, 16, ...RepeatNTimes(32, 16)], + ), + + MakeTestCase(null, [50, 50, 50, ...RepeatNTimes(190, 32)], RepeatNTimes(16, 32 + 3), 1), + + MakeTestCase(50, [50, 50, 50, ...RepeatNTimes(190, 32)], RepeatNTimes(16, 32 + 3), 32), + + MakeTestCase( + 200, + + RepeatNTimes(200, 21), + + [RepeatNTimes(16, 12), RepeatNTimes(32, 6), RepeatNTimes(64, 3)].flat(), + ), + + MakeTestCase( + null, + [...RepeatNTimes(200, 31), ...RepeatNTimes(0, 1), ...RepeatNTimes(200, 31)], + [...RepeatNTimes(16, 31), ...RepeatNTimes(0, 1), ...RepeatNTimes(16, 31)], + ), + MakeTestCase( + 200, + [...RepeatNTimes(200, 31), ...RepeatNTimes(0, 1), ...RepeatNTimes(200, 32)], + [...RepeatNTimes(16, 31), ...RepeatNTimes(0, 1), ...RepeatNTimes(16, 32)], + ), + + MakeTestCase(200, RepeatNTimes(200, 1024), RepeatNTimes(16, 1024), null, 1024), + MakeTestCase(null, RepeatNTimes(200, 1024), RepeatNTimes(16, 1024), null, 1025), + + MakeTestCase(200, RepeatNTimes(200, 2), RepeatNTimes(16, 2), null, 2), + MakeTestCase(null, RepeatNTimes(200, 2), RepeatNTimes(16, 2), null, 3), + ]; + + for (const testCase of testCases) { + testCase(); + } + + expectThrowsSnapshot( + () => calculateFromBPMPerMeasure([], [1]), + "Should throw if bpms and notes don't have same length", + ); + expectThrowsSnapshot( + () => calculateFromBPMPerMeasure([], [], -1), + "Should throw if died at is negative", + ); + expectThrowsSnapshot( + () => calculateFromBPMPerMeasure([], [], null, 0), + "Should throw if measures is 0", + ); + expectThrowsSnapshot( + () => calculateFromBPMPerMeasure([], [], null, 1), + "Should throw if measures is 1", + ); + expectThrowsSnapshot( + () => calculateFromBPMPerMeasure([], [], null, -1), + "Should throw if measures is negative", + ); +}); diff --git a/typescript/rg-stats/src/algorithms/itg-highest-unbroken.ts b/typescript/rg-stats/src/algorithms/itg-highest-unbroken.ts new file mode 100644 index 000000000..b72867383 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/itg-highest-unbroken.ts @@ -0,0 +1,131 @@ +import { ThrowIf } from "../util/throw-if"; + +/** + * Given an array of notes per measure, NPS per measure and (optionally) when the user died + * calculate the fastest series of unbroken '16ths' at N measures. + * + * 32 @ 190 will return 190, + * 96 @ 190, user died at m40 is converted to 40 @ 190 and will return 190. + * + * 31 @ 190 will return null, + * 32 @ 200 / 32 @ 220 will return 220. + * 31 @ 200 into 1 @ 50 will return 50. (assuming no break between the streams) + * + * @param npsPerMeasure - The notes per second per measure. + * @param notesPerMeasure - The notes per measure. + * @param diedAt - Optionally, when to cut this short, such as if the user died at measure + * 39, and still might've technically did 32 measures of stream. + * @param measures - Optionally, override how many measures need to be unbroken. This must + * be greater than 1. This allows you to calculate Highest 256, or similar. + * + * @returns The BPM of the highest N unbroken measures in this chart. + */ +export function calculateFromNPSPerMeasure( + npsPerMeasure: Array, + notesPerMeasure: Array, + diedAt: number | null = null, + measures = 32, +): number | null { + // Implementation note: Measures of 1 is annoying to handle, and nobody should + // ever want to calculate that because it's stupid. Just gonna not allow it. + ThrowIf(measures <= 1, `Measures must be greater than 1`, { measures }); + ThrowIf( + npsPerMeasure.length !== notesPerMeasure.length, + `Notes Per Measure and NPS Per Measure didn't have the same length.`, + { npsLen: npsPerMeasure.length, notesLen: notesPerMeasure.length }, + ); + ThrowIf(diedAt !== null && diedAt < 0, `DiedAt must be positive.`, { + diedAt: diedAt ?? "Null? (Impossible)", + }); + + // Given an array of equivalent bpms per measure, return the fastest bpm + // this user could've effectively streamed N measures of. + let candidates: Array = []; + + let highestUnbroken: number | null = null; + + const measureData = npsPerMeasure.map((e, i) => [e, notesPerMeasure[i]]); + + // If the user died at an earlier measure, only iterate through those measures. + for (let curMeasureIdx = 0; curMeasureIdx < (diedAt ?? measureData.length); curMeasureIdx++) { + const [nps, notes] = measureData[curMeasureIdx]; + + // if any measure we're in stops doing anything, this is a break. + // lerping over this sucks and is counterintuitive, so we're not going to. + if (notes === 0) { + candidates = []; + } + + const unexhaustedCandidates: Array = []; + + for (const candidate of candidates) { + candidate.npsWindow.push(nps); + candidate.notesWindow.push(notes); + + // If this window contains more than (16 * measures) notes + // it meets the criteria for fastest unbroken of 16ths for N measures. + if (candidate.notesWindow.reduce((a, e) => a + e, 0) >= measures * 16) { + const lowestNPSInWindow = candidate.npsWindow.slice(0).sort((a, b) => a - b)[0]; + + // if highestUnbroken is null, this is our best shot + if (highestUnbroken === null || lowestNPSInWindow > highestUnbroken) { + highestUnbroken = lowestNPSInWindow; + } + } else { + unexhaustedCandidates.push(candidate); + } + } + + // Only keep candidates who's window doesn't already subsume 512 notes. + candidates = unexhaustedCandidates; + + candidates.push({ + // for debugging + windowStart: curMeasureIdx, + npsWindow: [nps], + notesWindow: [notes], + }); + } + + if (highestUnbroken === null) { + return null; + } + + return npsToBPM(highestUnbroken); +} + +interface Candidate { + windowStart: number; + npsWindow: Array; + notesWindow: Array; +} + +/** + * Given an array of bpms per measure instead of nps per measure, calculate the fastest + * N measures of unbroken streams played. This is a thin wrapper around the NPS/Measure + * calculator, used for humanising input. + * + * {@see calculateFromNPSPerMeasure} + */ +export function calculateFromBPMPerMeasure( + bpmPerMeasure: Array, + notesPerMeasure: Array, + diedAt?: null | number, + measures?: number, +): number | null { + return calculateFromNPSPerMeasure( + bpmPerMeasure.map(bpmToNPS), + notesPerMeasure, + diedAt, + measures, + ); +} + +// Always assume 4/4 time. I know it sucks, but stepmania does the same. +function npsToBPM(nps: number) { + return (nps * 60) / 4; +} + +function bpmToNPS(bpm: number) { + return (bpm * 4) / 60; +} diff --git a/typescript/rg-stats/src/algorithms/jubility.test.ts b/typescript/rg-stats/src/algorithms/jubility.test.ts new file mode 100644 index 000000000..55bec702c --- /dev/null +++ b/typescript/rg-stats/src/algorithms/jubility.test.ts @@ -0,0 +1,320 @@ +import { expect, test } from "vitest"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { calculate, inverse } from "./jubility"; + +const testCases = [ + [80, 10.9, 110.1], + [85, 10.9, 116.9], + [90, 10.9, 123.8], + [95, 10.9, 130.7], + [100, 10.9, 137.6], + [105, 10.9, 144.5], + [110, 10.9, 151.3], + [115, 10.9, 158.2], + [120, 10.9, 165.1], + [80, 10.8, 109.0], + [85, 10.8, 115.9], + [90, 10.8, 122.7], + [95, 10.8, 129.5], + [100, 10.8, 136.3], + [105, 10.8, 143.1], + [110, 10.8, 150.0], + [115, 10.8, 156.8], + [120, 10.8, 163.6], + [80, 10.7, 108.0], + [85, 10.7, 114.8], + [90, 10.7, 121.5], + [95, 10.7, 128.3], + [100, 10.7, 135.1], + [105, 10.7, 141.8], + [110, 10.7, 148.6], + [115, 10.7, 155.3], + [120, 10.7, 162.1], + [80, 10.6, 107.0], + [85, 10.6, 113.7], + [90, 10.6, 120.4], + [95, 10.6, 127.1], + [100, 10.6, 133.8], + [105, 10.6, 140.5], + [110, 10.6, 147.2], + [115, 10.6, 153.9], + [120, 10.6, 160.6], + [80, 10.5, 106.0], + [85, 10.5, 112.6], + [90, 10.5, 119.3], + [95, 10.5, 125.9], + [100, 10.5, 132.5], + [105, 10.5, 139.2], + [110, 10.5, 145.8], + [115, 10.5, 152.4], + [120, 10.5, 159.0], + [80, 10.4, 105.0], + [85, 10.4, 111.6], + [90, 10.4, 118.1], + [95, 10.4, 124.7], + [100, 10.4, 131.3], + [105, 10.4, 137.8], + [110, 10.4, 144.4], + [115, 10.4, 151.0], + [120, 10.4, 157.5], + [80, 10.3, 104.0], + [85, 10.3, 110.5], + [90, 10.3, 117.0], + [95, 10.3, 123.5], + [100, 10.3, 130.0], + [105, 10.3, 136.5], + [110, 10.3, 143.0], + [115, 10.3, 149.5], + [120, 10.3, 156.0], + [80, 10.2, 103.0], + [85, 10.2, 109.4], + [90, 10.2, 115.9], + [95, 10.2, 122.3], + [100, 10.2, 128.7], + [105, 10.2, 135.2], + [110, 10.2, 141.6], + [115, 10.2, 148.1], + [120, 10.2, 154.5], + [80, 10.1, 102.0], + [85, 10.1, 108.3], + [90, 10.1, 114.7], + [95, 10.1, 121.1], + [100, 10.1, 127.5], + [105, 10.1, 133.9], + [110, 10.1, 140.2], + [115, 10.1, 146.6], + [120, 10.1, 153.0], + [80, 10.0, 101.0], + [85, 10.0, 107.3], + [90, 10.0, 113.6], + [95, 10.0, 119.9], + [100, 10.0, 126.2], + [105, 10.0, 132.5], + [110, 10.0, 138.8], + [115, 10.0, 145.2], + [120, 10.0, 151.5], + [80, 9.9, 100.0], + [85, 9.9, 106.2], + [90, 9.9, 112.5], + [95, 9.9, 118.7], + [100, 9.9, 125.0], + [105, 9.9, 131.2], + [110, 9.9, 137.5], + [115, 9.9, 143.7], + [120, 9.9, 150.0], + [80, 9.8, 98.9], + [85, 9.8, 105.1], + [90, 9.8, 111.3], + [95, 9.8, 117.5], + [100, 9.8, 123.7], + [105, 9.8, 129.9], + [110, 9.8, 136.1], + [115, 9.8, 142.2], + [120, 9.8, 148.4], + [80, 9.7, 97.9], + [85, 9.7, 104.1], + [90, 9.7, 110.2], + [95, 9.7, 116.3], + [100, 9.7, 122.4], + [105, 9.7, 128.5], + [110, 9.7, 134.7], + [115, 9.7, 140.8], + [120, 9.7, 146.9], + [80, 9.6, 96.9], + [85, 9.6, 103.0], + [90, 9.6, 109.0], + [95, 9.6, 115.1], + [100, 9.6, 121.2], + [105, 9.6, 127.2], + [110, 9.6, 133.3], + [115, 9.6, 139.3], + [120, 9.6, 145.4], + [80, 9.5, 95.9], + [85, 9.5, 101.9], + [90, 9.5, 107.9], + [95, 9.5, 113.9], + [100, 9.5, 119.9], + [105, 9.5, 125.9], + [110, 9.5, 131.9], + [115, 9.5, 137.9], + [120, 9.5, 143.9], + [80, 9.4, 94.9], + [85, 9.4, 100.8], + [90, 9.4, 106.8], + [95, 9.4, 112.7], + [100, 9.4, 118.6], + [105, 9.4, 124.6], + [110, 9.4, 130.5], + [115, 9.4, 136.4], + [120, 9.4, 142.4], + [80, 9.3, 93.9], + [85, 9.3, 99.8], + [90, 9.3, 105.6], + [95, 9.3, 111.5], + [100, 9.3, 117.4], + [105, 9.3, 123.2], + [110, 9.3, 129.1], + [115, 9.3, 135.0], + [120, 9.3, 140.9], + [80, 9.2, 92.9], + [85, 9.2, 98.7], + [90, 9.2, 104.5], + [95, 9.2, 110.3], + [100, 9.2, 116.1], + [105, 9.2, 121.9], + [110, 9.2, 127.7], + [115, 9.2, 133.5], + [120, 9.2, 139.3], + [80, 9.1, 91.9], + [85, 9.1, 97.6], + [90, 9.1, 103.4], + [95, 9.1, 109.1], + [100, 9.1, 114.8], + [105, 9.1, 120.6], + [110, 9.1, 126.3], + [115, 9.1, 132.1], + [120, 9.1, 137.8], + [80, 9.0, 90.9], + [85, 9.0, 96.5], + [90, 9.0, 102.2], + [95, 9.0, 107.9], + [100, 9.0, 113.6], + [105, 9.0, 119.3], + [110, 9.0, 125.0], + [115, 9.0, 130.6], + [120, 9.0, 136.3], + [80, 8, 80.8], + [85, 8, 85.8], + [90, 8, 90.9], + [95, 8, 95.9], + [100, 8, 101.0], + [105, 8, 106.0], + [110, 8, 111.1], + [115, 8, 116.1], + [120, 8, 121.2], + [80, 7, 70.7], + [85, 7, 75.1], + [90, 7, 79.5], + [95, 7, 83.9], + [100, 7, 88.3], + [105, 7, 92.8], + [110, 7, 97.2], + [115, 7, 101.6], + [120, 7, 106.0], + [80, 6, 60.6], + [85, 6, 64.3], + [90, 6, 68.1], + [95, 6, 71.9], + [100, 6, 75.7], + [105, 6, 79.5], + [110, 6, 83.3], + [115, 6, 87.1], + [120, 6, 90.9], + [80, 5, 50.5], + [85, 5, 53.6], + [90, 5, 56.8], + [95, 5, 59.9], + [100, 5, 63.1], + [105, 5, 66.2], + [110, 5, 69.4], + [115, 5, 72.6], + [120, 5, 75.7], + [80, 4, 40.4], + [85, 4, 42.9], + [90, 4, 45.4], + [95, 4, 47.9], + [100, 4, 50.5], + [105, 4, 53.0], + [110, 4, 55.5], + [115, 4, 58.0], + [120, 4, 60.6], + [80, 3, 30.3], + [85, 3, 32.1], + [90, 3, 34.0], + [95, 3, 35.9], + [100, 3, 37.8], + [105, 3, 39.7], + [110, 3, 41.6], + [115, 3, 43.5], + [120, 3, 45.4], + [80, 2, 20.2], + [85, 2, 21.4], + [90, 2, 22.7], + [95, 2, 23.9], + [100, 2, 25.2], + [105, 2, 26.5], + [110, 2, 27.7], + [115, 2, 29.0], + [120, 2, 30.3], + [80, 1, 10.1], + [85, 1, 10.7], + [90, 1, 11.3], + [95, 1, 11.9], + [100, 1, 12.6], + [105, 1, 13.2], + [110, 1, 13.8], + [115, 1, 14.5], + [120, 1, 15.1], +]; + +test("Jubility Tests", () => { + function MakeTestCase(rate: number, level: number, jubility: number): TestCase { + return () => + expect( + calculate(700_001, rate, level), + `${rate}% on a level ${level} chart should be worth ${jubility} jubility.`, + ).toBe(jubility); + } + + for (const testCase of testCases) { + MakeTestCase(testCase[0], testCase[1], testCase[2])(); + } + + expect(calculate(699_999, 100, 10), "Should return 0 for scores < 700k no matter what.").toBe( + 0, + ); +}); + +test("Jubility Validation Tests", () => { + expectThrowsSnapshot( + () => calculate(1_000_001, 100, 10.5), + "Should throw if score is > 1million.", + ); + expectThrowsSnapshot( + () => calculate(1_000_000, 120.1, 10.5), + "Should throw if music rate is > 120.", + ); + + expectThrowsSnapshot(() => calculate(-1, 100, 10.5), "Should throw if score is negative."); + expectThrowsSnapshot( + () => calculate(1_000_000, -1, 10.5), + "Should throw if music rate is negative.", + ); + expectThrowsSnapshot( + () => calculate(1_000_000, -1, -1), + "Should throw if chart level is negative.", + ); +}); + +test("Inverse Jubility Tests", () => { + function MakeTestCase(rate: number, level: number, jubility: number): TestCase { + return () => + isAprx( + Math.ceil(inverse(jubility, level)), + rate, + `Inverse ${jubility} on a level ${level} chart should be equal to ${rate} music rate.`, + 1, + ); + } + + for (const testCase of testCases) { + MakeTestCase(testCase[0], testCase[1], testCase[2])(); + } + + expectThrowsSnapshot( + () => inverse(9000, 1), + "Should throw if the requested jubility is not possible on a chart of that level.", + ); +}); diff --git a/typescript/rg-stats/src/algorithms/jubility.ts b/typescript/rg-stats/src/algorithms/jubility.ts new file mode 100644 index 000000000..efceae9a4 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/jubility.ts @@ -0,0 +1,50 @@ +import { ThrowIf } from "../util/throw-if"; + +/** + * Calculates the jubility of a score. Returns 0 for anything less than 700k. + * + * @param score - The score value. This is between 0 and 1million. + * @param musicRate - The Music Rate of their score -- this is a percentage value. + * This is between 0 and 120, where 100-120 is only achiveable on hard mode. + * @param level - The level for the chart. + */ +export function calculate(score: number, musicRate: number, level: number) { + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf.negative(level, "Level cannot be negative.", { level }); + ThrowIf.negative(musicRate, "MusicRate cannot be negative.", { musicRate }); + ThrowIf(score > 1_000_000, "Score cannot be greater than 1million.", { score }); + ThrowIf(musicRate > 120, "MusicRate cannot be greater than 120.", { musicRate }); + + if (score < 700_000) { + return 0; + } + + const levelInt = Math.round(level * 10); + const musicRateInt = Math.floor(musicRate * 10); + + // Note: These constants are arbitrary. I have no idea why they're like this. + const jub = (levelInt * 125 * musicRateInt) / 99; + + // Jubility is *always* floored to one decimal place. + return Math.floor(jub / 100) / 10; +} + +/** + * Given a jubility, return the musicRate necessary to get that jubility. + * This assumes that the player has a score >= 700k, as otherwise no inversion + * is possible. + * + * @param jubility - The jubility to inversely calculate. + * @param level - The level for the chart. + */ +export function inverse(jubility: number, level: number) { + const rate = (99 / (12.5 * level)) * jubility; + + ThrowIf( + rate > 120, + `A jubility of ${jubility} is not possible on a chart with level ${level}.`, + { jubility, level }, + ); + + return rate; +} diff --git a/typescript/rg-stats/src/algorithms/maimai-rate.test.ts b/typescript/rg-stats/src/algorithms/maimai-rate.test.ts new file mode 100644 index 000000000..b84c26243 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/maimai-rate.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "vitest"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { calculate, getRank } from "./maimai-rate"; + +test("maimai Rate Tests", () => { + function MakeTestCase( + score: number, + maxScore: number, + level: number, + expectedRate: number, + ): TestCase { + return () => + isAprx( + calculate(score, maxScore, level), + expectedRate, + `A score of ${score}/${maxScore} on a chart of level ${level} should be worth ${expectedRate} rate.`, + ); + } + + const testCases = [ + MakeTestCase(100.89, 101.58, 11.8, 14.36), + MakeTestCase(100.8, 101.18, 12.0, 14.68), + MakeTestCase(100.23, 100.72, 11.7, 14.02), + MakeTestCase(99.36, 100.42, 11.0, 12.27), + MakeTestCase(98.04, 101.21, 10.2, 10.8), + MakeTestCase(97.62, 100.56, 10.1, 10.47), + MakeTestCase(96.79, 101.19, 10.8, 9.76), + MakeTestCase(95.08, 100.91, 10.5, 9.18), + MakeTestCase(94.16, 100.83, 12.5, 11.04), + MakeTestCase(93.05, 100.31, 11.5, 9.88), + MakeTestCase(92.5, 100.53, 13.6, 11.91), + MakeTestCase(91.62, 100.5, 11.7, 9.9), + + MakeTestCase(100.68, 100.68, 14, 19), + MakeTestCase(100, 100.79, 13.9, 17.8), + + MakeTestCase(99, 100.71, 12.9, 14.8), + MakeTestCase(99, 100.54, 8, 9.5), + MakeTestCase(99, 100.54, 7, 9), + + MakeTestCase(97, 100.36, 11.5, 11.5), + MakeTestCase(97, 100.54, 8, 8), + MakeTestCase(97, 100.54, 7, 7.5), + + MakeTestCase(94, 100.3, 10, 8.5), + MakeTestCase(90, 100.3, 12.5, 10.5), + MakeTestCase(80, 100.5, 13, 10), + MakeTestCase(60, 100.59, 12.7, 5.08), + MakeTestCase(40, 100.54, 9, 1.8), + MakeTestCase(20, 100.72, 13.6, 1.36), + MakeTestCase(13, 100.61, 11.7, 0.35), + MakeTestCase(9, 100.26, 10.5, 0), + + MakeTestCase(100, 100, 13.9, 18.8), + MakeTestCase(96.99, 100, 8.9, 7.95), + + MakeTestCase(100.9, 100.9, 15.4, 21.8), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("maimai Rate Validation Tests", () => { + expectThrowsSnapshot( + () => calculate(100, 104.1, 10), + "Should throw if max score is greater than 104%.", + ); + expectThrowsSnapshot( + () => calculate(101.5, 100.68, 10), + "Should throw if score is greater than max score.", + ); + expectThrowsSnapshot(() => calculate(-1, 100.68, 10), "Should throw if score is negative."); + expectThrowsSnapshot(() => calculate(99.5, 100.68, -1), "Should throw if level is negative."); +}); + +test("getRank falls back to F when no higher rank matches", () => { + expect(getRank(500)).toBe("F"); +}); diff --git a/typescript/rg-stats/src/algorithms/maimai-rate.ts b/typescript/rg-stats/src/algorithms/maimai-rate.ts new file mode 100644 index 000000000..8a11543f2 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/maimai-rate.ts @@ -0,0 +1,130 @@ +import { ThrowIf } from "../util/throw-if"; + +// [F, E, D, C, B, A, AA, AAA, S - 0.01, S, SS - 0.01, SS, SSS - 0.01, SSS, SSS+] +const RATE_TABLE: Record = { + 0: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + 10: [0, 0, 10, 20, 40, 100, 150, 250, 400, 400, 500, 550, 650, 700, 800], + 20: [0, 0, 20, 40, 80, 200, 250, 300, 450, 450, 550, 600, 700, 750, 850], + 30: [0, 0, 30, 60, 120, 300, 350, 400, 500, 500, 600, 650, 750, 800, 900], + 40: [0, 0, 40, 80, 160, 350, 400, 450, 550, 550, 650, 700, 800, 850, 950], + 50: [0, 0, 50, 100, 200, 400, 500, 550, 600, 600, 700, 750, 850, 900, 1000], + 60: [0, 0, 60, 120, 240, 450, 550, 600, 650, 650, 750, 800, 900, 950, 1050], + 70: [0, 0, 70, 140, 280, 500, 600, 700, 725, 750, 875, 900, 975, 1000, 1100], + 80: [0, 0, 80, 160, 320, 550, 650, 725, 750, 800, 925, 950, 1025, 1050, 1150], + 90: [0, 0, 90, 180, 360, 600, 700, 750, 800, 900, 1025, 1050, 1125, 1150, 1250], + 100: [0, 0, 100, 200, 400, 700, 800, 850, 900, 1000, 1125, 1150, 1225, 1250, 1350], + 110: [0, 0, 110, 220, 440, 800, 900, 950, 1000, 1100, 1175, 1200, 1275, 1300, 1400], + 120: [0, 0, 120, 240, 480, 900, 1000, 1050, 1100, 1200, 1275, 1300, 1375, 1400, 1500], + 130: [0, 0, 130, 260, 520, 1000, 1100, 1150, 1250, 1350, 1475, 1500, 1575, 1600, 1700], + 140: [0, 0, 140, 280, 560, 1100, 1200, 1250, 1400, 1500, 1675, 1700, 1775, 1800, 1900], + 150: [0, 0, 150, 300, 600, 1200, 1300, 1350, 1550, 1650, 1875, 1900, 1975, 2000, 2100], +}; + +const RANK_BOUNDARIES = { + F: 0, + E: 1000, + D: 2000, + C: 4000, + B: 6000, + A: 8000, + AA: 9000, + AAA: 9400, + "S-": 9699, + S: 9700, + "SS-": 9899, + SS: 9900, + "SSS-": 9999, + SSS: 10000, + "SSS+": 20000, +} as const; + +export function getRank(score: number): keyof typeof RANK_BOUNDARIES { + const entries = Object.entries(RANK_BOUNDARIES).reverse(); + const match = entries.slice(0, -1).find(([_, boundary]) => score >= boundary); + return (match ?? entries[entries.length - 1])[0] as keyof typeof RANK_BOUNDARIES; +} + +function calculateRatingCurve(iclInt: number): number[] { + let lowerIcl = Math.floor(iclInt / 10) * 10; + let upperIcl = Math.ceil(iclInt / 10) * 10; + + // extrapolate for levels beyond 15 + if (iclInt > 150) { + lowerIcl = 140; + upperIcl = 150; + } + + if (lowerIcl === upperIcl) { + return RATE_TABLE[lowerIcl]; + } + + const lowerCurve = RATE_TABLE[lowerIcl]; + const upperCurve = RATE_TABLE[upperIcl]; + + return lowerCurve.map((rateValue, index) => { + const upperRateValue = upperCurve[index]; + + return ( + rateValue + ((upperRateValue - rateValue) * (iclInt - lowerIcl)) / (upperIcl - lowerIcl) + ); + }); +} + +/** + * Calculates maimai rate for a score. + * + * @param score - The percentage score to calculate the rate for. + * @param maxScore - The max percentage score attainable of the chart the score was achieved on. + * @param internalChartLevel - The internal decimal level of the chart the score was achieved on. + */ +export function calculate(score: number, maxScore: number, internalChartLevel: number): number { + // Breaks are the most valuable note type, with a 2500 base score for PERFECTs and up to 100 bonus score. + // Assuming all notes are breaks, the max score is (2600 * n) / (2500 * n) * 100 = 104 + ThrowIf(maxScore > 104, "Max score cannot be greater than 104%.", { maxScore }); + ThrowIf(score > maxScore, "Score cannot be greater than max score.", { score, maxScore }); + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf.negative(internalChartLevel, "Internal chart level cannot be negative.", { + level: internalChartLevel, + }); + + let scoreInt = Math.round(score * 100); + const maxScoreInt = Math.round(maxScore * 100); + const iclInt = Math.round(internalChartLevel * 10); + + if (scoreInt <= 1000) { + return 0; + } + + const curve = calculateRatingCurve(iclInt); + + if (scoreInt === maxScoreInt) { + return curve[curve.length - 1] / 100; + } + + const rank = getRank(scoreInt); + const rankIndex = Object.keys(RANK_BOUNDARIES).indexOf(rank); + + // For whatever reason, rating between SSS and SSS+ is calculated by scaling + // the score between 100% and 200%, with the base score taking up 100% and + // the break score being another 100%. + if (rank === "SSS") { + scoreInt = Math.floor(((scoreInt - 10000) / (maxScoreInt - 10000)) * 10000 + 10000); + } + + const lowerRateValue = curve[rankIndex]; + const lowerScoreBoundary = RANK_BOUNDARIES[rank]; + + // Fast path: Return the rate value as-is if the score is right on the border. + if (scoreInt === lowerScoreBoundary) { + return lowerRateValue / 100; + } + + const upperRateValue = curve[rankIndex + 1]; + const upperScoreBoundary = Object.values(RANK_BOUNDARIES)[rankIndex + 1]; + const rate = + lowerRateValue + + (upperRateValue - lowerRateValue) * + ((scoreInt - lowerScoreBoundary) / (upperScoreBoundary - lowerScoreBoundary)); + + return Math.floor(rate) / 100; +} diff --git a/typescript/rg-stats/src/algorithms/maimaidx-rate.test.ts b/typescript/rg-stats/src/algorithms/maimaidx-rate.test.ts new file mode 100644 index 000000000..6632289d8 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/maimaidx-rate.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "vitest"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { calculate, MaimaiDXLamps } from "./maimaidx-rate"; + +test("maimai DX Rate Tests", () => { + function MakeTestCase( + score: number, + level: number, + lamp: MaimaiDXLamps | undefined, + expectedRate: number, + ): TestCase { + return () => + isAprx( + calculate(score, level, lamp), + expectedRate, + `A score of ${score} on a chart of level ${level} with lamp ${lamp} should be worth ${expectedRate} rate.`, + ); + } + + const testCases = [ + MakeTestCase(100.1398, 13, "CLEAR", 281), + MakeTestCase(100.1379, 12.7, "CLEAR", 274), + MakeTestCase(100.227, 12.5, "CLEAR", 270), + MakeTestCase(99.4936, 12.8, "CLEAR", 264), + + MakeTestCase(101, 13, "ALL PERFECT+", 293), + MakeTestCase(100.5, 13, "ALL PERFECT", 293), + MakeTestCase(100.5, 13, "CLEAR", 292), + MakeTestCase(100.4999, 14, "CLEAR", 312), + MakeTestCase(100, 13, "ALL PERFECT", 281), + MakeTestCase(100, 13, "CLEAR", 280), + MakeTestCase(99.9999, 13.7, "CLEAR", 293), + MakeTestCase(99.5, 13.7, "CLEAR", 287), + MakeTestCase(99, 12.7, "CLEAR", 261), + MakeTestCase(98.9999, 12.9, "CLEAR", 263), + MakeTestCase(98, 10, "CLEAR", 198), + MakeTestCase(97, 8, "CLEAR", 155), + MakeTestCase(96.9999, 10.7, "CLEAR", 182), + MakeTestCase(94, 10.7, "CLEAR", 168), + MakeTestCase(90, 5, "CLEAR", 68), + MakeTestCase(80, 11.5, "CLEAR", 125), + MakeTestCase(79.9999, 11.5, "FAILED", 117), + MakeTestCase(75, 12.4, "FAILED", 111), + MakeTestCase(70, 14.2, "FAILED", 111), + MakeTestCase(60, 15, "FAILED", 86), + MakeTestCase(50, 12.6, "FAILED", 50), + MakeTestCase(40, 12.6, "FAILED", 32), + MakeTestCase(30, 12.6, "FAILED", 18), + MakeTestCase(20, 12.6, "FAILED", 8), + MakeTestCase(10, 12.6, "FAILED", 2), + MakeTestCase(0, 12.6, "FAILED", 0), + + MakeTestCase(101, 13, undefined, 292), + MakeTestCase(100.5, 13, undefined, 292), + MakeTestCase(75, 12.4, undefined, 111), + ]; + + for (const testCase of testCases) { + testCase(); + } + + expect( + calculate(100.5, 13, "ALL PERFECT"), + "101% and 100.5% should give identical rate on ALL PERFECT.", + ).toBe(calculate(101, 13, "ALL PERFECT+")); +}); + +test("maimai DX Rate Validation Tests", () => { + expectThrowsSnapshot(() => calculate(-1, 10, "FAILED"), "Should throw if score is negative."); + expectThrowsSnapshot( + () => calculate(101.5, 10, "ALL PERFECT+"), + "Should throw if score is greater than 101%.", + ); + expectThrowsSnapshot(() => calculate(99.5, -1, "CLEAR"), "Should throw if level is negative."); + expectThrowsSnapshot( + () => calculate(100.5, 10, "ALL PERFECT+"), + "Should throw if lamp is ALL PERFECT+ but score is not 101%.", + ); + expectThrowsSnapshot( + () => calculate(101, 10, "ALL PERFECT"), + "Should throw if score is 101% but lamp is not ALL PERFECT+", + ); + expectThrowsSnapshot( + () => calculate(99.9, 10, "ALL PERFECT"), + "Should throw if lamp is ALL PERFECT but score is below 100%.", + ); + expectThrowsSnapshot( + () => calculate(79, 10, "CLEAR"), + "Should throw if lamp is CLEAR but score is below 80%.", + ); + expectThrowsSnapshot( + () => calculate(81, 10, "FAILED"), + "Should throw if lamp is FAILED but score is above 80%.", + ); +}); diff --git a/typescript/rg-stats/src/algorithms/maimaidx-rate.ts b/typescript/rg-stats/src/algorithms/maimaidx-rate.ts new file mode 100644 index 000000000..a06d7ca6f --- /dev/null +++ b/typescript/rg-stats/src/algorithms/maimaidx-rate.ts @@ -0,0 +1,98 @@ +import { ThrowIf } from "../util/throw-if"; + +export type MaimaiDXLamps = + | "FAILED" + | "CLEAR" + | "FULL COMBO" + | "FULL COMBO+" + | "ALL PERFECT" + | "ALL PERFECT+"; + +const RATING_COEFFICIENTS = new Map([ + [100_5000, 224], + [100_4999, 222], + [100_0000, 216], + [99_9999, 214], + [99_5000, 211], + [99_0000, 208], + [98_9999, 206], + [98_0000, 203], + [97_0000, 200], + [96_9999, 176], + [94_0000, 168], + [90_0000, 152], + [80_0000, 136], + [79_9999, 128], + [75_0000, 120], + [70_0000, 112], + [60_0000, 96], + [50_0000, 80], + [40_0000, 64], + [30_0000, 48], + [20_0000, 32], + [10_0000, 16], + [0, 0], +]); + +/** + * Calculate maimai DX Splash+ (and newer) rate for a score. + * + * @param score - The score to calculate the rate for. + * @param internalChartLevel - The internal decimal level of the chart the score was achieved on. + * @param lamp - The lamp for this score. Since maimai DX CiRCLE, there is a 1 rating bonus + * for ALL PERFECT/ALL PERFECT+ scores. + */ +export function calculate(score: number, internalChartLevel: number, lamp?: MaimaiDXLamps) { + ThrowIf(score > 101, "Score cannot be greater than 101%.", { score }); + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf.negative(internalChartLevel, "Internal chart level cannot be negative.", { + level: internalChartLevel, + }); + ThrowIf( + lamp === "ALL PERFECT+" && score !== 101, + "Cannot have an ALL PERFECT+ without 101%.", + // @ts-expect-error Lamp is "ALL PERFECT+" if the exception is thrown. + { score, lamp }, + ); + ThrowIf( + score === 101 && lamp !== undefined && lamp !== "ALL PERFECT+", + "A score of 101% should be an ALL PERFECT+.", + // @ts-expect-error Lamp is defined if the exception is thrown. + { score, lamp }, + ); + ThrowIf( + lamp === "ALL PERFECT" && score < 100, + "Cannot have an ALL PERFECT without at least 100%.", + // @ts-expect-error Lamp is "ALL PERFECT" if the exception is thrown. + { score, lamp }, + ); + ThrowIf( + score >= 80 && lamp === "FAILED", + "A score of >=80% should not be a FAILED.", + // @ts-expect-error Lamp is "FAILED" if the exception is thrown. + { score, lamp }, + ); + ThrowIf( + score < 80 && lamp === "CLEAR", // check specifically CLEARs so that sub-80% FCs are still permitted + "A score of <80% should not be a CLEAR.", + // @ts-expect-error Lamp is "CLEAR" if the exception is thrown. + { score, lamp }, + ); + + // Scores above 100.5% are capped at 100.5% by the algorithm. + const scoreInt = Math.min(Math.round(score * 10000), 100_5000); + const iclInt = Math.round(internalChartLevel * 10); + const bonus = lamp === "ALL PERFECT" || lamp === "ALL PERFECT+" ? 1 : 0; + + const coeffs = [...RATING_COEFFICIENTS]; + // Last entry is boundary 0. Omitting it from the loop avoids an impossible + // branch (scoreInt < 0) on the final `scoreInt >= scoreBoundary` check. + for (let i = 0; i < coeffs.length - 1; i++) { + const [scoreBoundary, coefficient] = coeffs[i]; + if (scoreInt >= scoreBoundary) { + return Math.floor((scoreInt * coefficient * iclInt) / 100_000_000) + bonus; + } + } + const [, coefficient] = coeffs[coeffs.length - 1]; + return Math.floor((scoreInt * coefficient * iclInt) / 100_000_000) + bonus; +} diff --git a/typescript/rg-stats/src/algorithms/ongeki-rating.test.ts b/typescript/rg-stats/src/algorithms/ongeki-rating.test.ts new file mode 100644 index 000000000..5cfd3c8b7 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/ongeki-rating.test.ts @@ -0,0 +1,169 @@ +import { expect, test } from "vitest"; +import fs from "node:fs"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { calculate, calculatePlatinum, calculateRefresh, OngekiNoteLamp } from "./ongeki-rating"; + +interface TestData { + ratingRefresh: { + internalChartLevel: number; + technicalScore: number; + noteLamp: OngekiNoteLamp; + fullBell: boolean; + expectedRating: number; + }[]; + ratingPlatinum: { + internalChartLevel: number; + stars: number; + expectedRating: number; + }[]; +} + +test("O.N.G.E.K.I. Classic Rating Tests", () => { + const LEVEL = 12.5; + expect(calculate(1_010_000, LEVEL)).toBe(LEVEL + 2); + expect(calculate(1_007_500, LEVEL)).toBe(LEVEL + 2); + expect(calculate(1_000_000, LEVEL)).toBe(LEVEL + 1.5); + expect(calculate(990_000, LEVEL)).toBe(LEVEL + 1); + expect(calculate(970_000, LEVEL)).toBe(LEVEL); + expect(calculate(900_000, LEVEL)).toBe(LEVEL - 4); + expect(calculate(800_000, LEVEL)).toBe(LEVEL - 6); + expect(calculate(500_000, LEVEL)).toBe(0); + expect(calculate(0, LEVEL)).toBe(0); + + expect(calculate(987_000, LEVEL)).toBe(13.35); + expect(calculate(1_003_000, LEVEL)).toBe(14.2); + expect(calculate(999_000, LEVEL)).toBe(13.95); + expect(calculate(994_000, LEVEL)).toBe(13.7); + expect(calculate(980_000, LEVEL)).toBe(13); + expect(calculate(950_000, LEVEL)).toBe(11.35); + expect(calculate(600_000, LEVEL)).toBe(0); + expect(calculate(50_000, LEVEL)).toBe(0); +}); + +test("O.N.G.E.K.I. Classic Rating Edge Cases", () => { + expect( + calculate(1_010_000, 0), + "A perfect score on a chart with level 0 should be worth 0.", + ).toBe(0); + expect(calculate(0, 12.5), "A score of 0 should be worth 0.").toBe(0); + expect(calculate(0, 0), "A score of 0 on a chart with level 0 should be worth 0.").toBe(0); + expect(calculate(1_007_880, 14.4), "An SSS+ on a 14.4 should be worth 16.4.").toBe(16.4); +}); + +test("O.N.G.E.K.I. Refresh Rating Tests", () => { + const LEVEL = 12.5; + expect(calculateRefresh(LEVEL, 1_010_000, "ALL BREAK+", true)).toBe(LEVEL + 2.7); + expect(calculateRefresh(LEVEL, 970_000, "CLEAR", false)).toBe(LEVEL); + expect(calculateRefresh(LEVEL, 970_000, "FULL COMBO", true)).toBe(LEVEL + 0.15); + expect(calculateRefresh(LEVEL, 952_510, "LOSS", false)).toBe(LEVEL - 1); + expect(calculateRefresh(LEVEL, 952_500, "LOSS", false)).toBe(LEVEL - 1); + expect(calculateRefresh(LEVEL, 952_490, "LOSS", false)).toBe(LEVEL - 1.001); + expect(calculateRefresh(LEVEL, 900_000, "LOSS", false)).toBe(LEVEL - 4); + expect(calculateRefresh(LEVEL, 850_000, "LOSS", false)).toBe(LEVEL - 5); + expect(calculateRefresh(LEVEL, 800_000, "CLEAR", true)).toBe(LEVEL - 6 + 0.05); +}); + +test("O.N.G.E.K.I. Refresh Rating Edge Cases", () => { + expect( + calculateRefresh(0, 1_010_000, "ALL BREAK+", true), + "A perfect score on a chart with level 0 should be worth 0.", + ).toBe(0); + expect(calculateRefresh(12.5, 0, "LOSS", false), "A score of 0 should be worth 0.").toBe(0); + expect( + calculateRefresh(0, 0, "LOSS", false), + "A score of 0 on a chart with level 0 should be worth 0.", + ).toBe(0); +}); + +test("O.N.G.E.K.I. Refresh Rating Real-world Tests", () => { + const testData: TestData = JSON.parse( + fs.readFileSync("test-data/ongeki-rating.json").toString(), + ); + + for (const d of testData.ratingRefresh) { + expect( + calculateRefresh(d.internalChartLevel, d.technicalScore, d.noteLamp, d.fullBell), + `${d.technicalScore} on a ${d.internalChartLevel} should be equal ${d.expectedRating}`, + ).toBe(d.expectedRating); + } +}); + +test("O.N.G.E.K.I. Platinum Rating Real-world Tests", () => { + const testData: TestData = JSON.parse( + fs.readFileSync("test-data/ongeki-rating.json").toString(), + ); + + for (const d of testData.ratingPlatinum) { + expect( + calculatePlatinum(d.internalChartLevel, d.stars), + `${d.stars} on a ${d.internalChartLevel} should be equal ${d.expectedRating}`, + ).toBe(d.expectedRating); + } + + expect(calculatePlatinum(15.7, 0), "0 stars should always equal 0 rating").toBe(0); +}); + +test("O.N.G.E.K.I. Rating Validation Tests", () => { + expectThrowsSnapshot( + () => calculate(-1, 12.5), + "Classic should throw if your score is negative.", + ); + + expectThrowsSnapshot( + () => calculateRefresh(12.5, -1, "LOSS", false), + "Refresh should throw if your score is negative.", + ); + + expectThrowsSnapshot( + () => calculate(1_010_001, 12.5), + "Classic should throw if your score is >= 1.01million.", + ); + + expectThrowsSnapshot( + () => calculateRefresh(12.5, 1_010_001, "CLEAR", true), + "Refresh should throw if your score is >= 1.01million.", + ); + + expectThrowsSnapshot( + () => calculate(900_000, -1), + + "Classic should throw if chart level is negative.", + ); + + expectThrowsSnapshot( + () => calculateRefresh(-1, 900_000, "CLEAR", false), + "Refresh should throw if chart level is negative.", + ); + + expectThrowsSnapshot( + () => calculatePlatinum(-1, 5), + "Platinum should throw if chart level is negative.", + ); + + expectThrowsSnapshot( + () => calculateRefresh(12.5, 900_000, "ALL BREAK+", true), + "Refresh should throw if lamp is ALL BREAK+ without 1.01M.", + ); + + expectThrowsSnapshot( + () => calculateRefresh(12.5, 1_010_000, "ALL BREAK", true), + "Refresh should throw if your score is 1.01M without ALL BREAK+.", + ); + + expectThrowsSnapshot( + () => calculateRefresh(12.5, 1_010_000, "ALL BREAK+", false), + "Refresh should throw if your score is 1.01M without FULL BELL.", + ); + + expectThrowsSnapshot( + () => calculateRefresh(12.5, 1_000_000, "LOSS", true), + "Refresh should throw if your score is a LOSS FULL BELL.", + ); + + expectThrowsSnapshot( + () => calculatePlatinum(12.5, -1), + "Platinum should throw if star number is negative.", + ); + + expectThrowsSnapshot(() => calculatePlatinum(12.5, 7), "Platinum should throw if stars > 6."); +}); diff --git a/typescript/rg-stats/src/algorithms/ongeki-rating.ts b/typescript/rg-stats/src/algorithms/ongeki-rating.ts new file mode 100644 index 000000000..f0f3ab7cb --- /dev/null +++ b/typescript/rg-stats/src/algorithms/ongeki-rating.ts @@ -0,0 +1,147 @@ +import { ThrowIf } from "../util/throw-if"; + +export type OngekiNoteLamp = "LOSS" | "CLEAR" | "FULL COMBO" | "ALL BREAK" | "ALL BREAK+"; + +/** + * Calculates the rating for an O.N.G.E.K.I. score. + * This is accurate up to bright MEMORY Act.3. + * + * @param technicalScore - The technical score the user got. This is a value between 0 and 1.01million. + * @param internalChartLevel - The internal chart level. This is a decimal value stored by the game internally. + */ +export function calculate(technicalScore: number, internalChartLevel: number) { + ThrowIf(technicalScore > 1_010_000, "Technical score cannot be greater than 1.01Million.", { + technicalScore, + }); + ThrowIf.negative(technicalScore, "Technical score cannot be negative.", { + technicalScore, + }); + ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { + internalChartLevel, + }); + + if (internalChartLevel === 0) { + return 0; + } + + let ratingValue = 0; + const iclInt = Math.round(internalChartLevel * 100.0); + + if (technicalScore >= 1_007_500) { + ratingValue = iclInt + 200; + } else if (technicalScore >= 1_000_000) { + ratingValue = iclInt + 150 + Math.floor((technicalScore - 1_000_000) / 150); + } else if (technicalScore >= 970_000) { + ratingValue = iclInt + Math.floor((technicalScore - 970_000) / 200); + } else if (technicalScore >= 900000) { + ratingValue = iclInt - Math.ceil((970_000 - technicalScore) / 175); + } else if (technicalScore >= 800000) { + ratingValue = iclInt - 400 - Math.ceil((900_000 - technicalScore) / 500); + } + + return Math.max(ratingValue / 100.0, 0); +} + +/** + * Calculates the rating for an O.N.G.E.K.I. score. + * This is accurate as of Re:Fresh. + * + * @param internalChartLevel - The internal chart level. This is a decimal value stored by the game internally. + * @param technicalScore - The technical score the user got. This is a value between 0 and 1.01million. + * @param noteLamp - The note lamp + * @param fullBell - Whether the bell lamp is "FULL BELL" + */ +export function calculateRefresh( + internalChartLevel: number, + technicalScore: number, + noteLamp: OngekiNoteLamp, + fullBell: boolean, +) { + ThrowIf(technicalScore > 1_010_000, "Technical score cannot be greater than 1.01Million.", { + technicalScore, + }); + ThrowIf.negative(technicalScore, "Technical score cannot be negative.", { + technicalScore, + }); + ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { + internalChartLevel, + }); + ThrowIf( + technicalScore === 1_010_000 && (!fullBell || noteLamp !== "ALL BREAK+"), + "Invalid AB+", + { fullBell: `${fullBell}`, noteLamp }, + ); + ThrowIf(technicalScore < 1_010_000 && noteLamp === "ALL BREAK+", "Invalid AB+", { + fullBell: `${fullBell}`, + noteLamp, + }); + ThrowIf(noteLamp === "LOSS" && fullBell, "Cannot have a LOSS FULL BELL", { noteLamp }); + + if (internalChartLevel === 0) { + return 0; + } + + const iclInt = Math.round(internalChartLevel * 1000.0); + + let ratingValue = iclInt; + + if (technicalScore >= 1_007_500) { + ratingValue += 1750 + Math.trunc((250 * (technicalScore - 1_007_500)) / 2500); + + // SSS+ bonus + ratingValue = ratingValue + 300; + } else if (technicalScore >= 1_000_000) { + ratingValue += 1250 + Math.trunc((500 * (technicalScore - 1_000_000)) / 7500); + + // SSS bonus + ratingValue = ratingValue + 200; + } else if (technicalScore >= 990_000) { + ratingValue += 750 + Math.trunc((500 * (technicalScore - 990_000)) / 10000); + + // SS bonus + ratingValue = ratingValue + 100; + } else if (technicalScore >= 970_000) { + ratingValue += Math.floor((750 * (technicalScore - 970_000)) / 20000); + } else if (technicalScore >= 900_000) { + ratingValue -= Math.ceil((4000 * (970_000 - technicalScore)) / 70000); + } else if (technicalScore >= 800_000) { + ratingValue -= 4000 + Math.ceil((2000 * (900_000 - technicalScore)) / 100000); + } else { + return 0; + } + + if (fullBell) { + ratingValue += 50; + } + + if (noteLamp === "FULL COMBO") { + ratingValue += 100; + } else if (noteLamp === "ALL BREAK") { + ratingValue += 300; + } else if (noteLamp === "ALL BREAK+") { + ratingValue += 350; + } + + return Math.max(ratingValue / 1000.0, 0); +} + +/** + * Calculates the star rating for an O.N.G.E.K.I. score. + * This is accurate as of Re:Fresh. + * + * @param internalChartLevel - The internal chart level. This is a decimal value stored by the game internally. + * @param stars - The number of stars, 0-6 with 6 being "rainbow 5-stars" + */ +export function calculatePlatinum(internalChartLevel: number, stars: number) { + ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { + internalChartLevel, + }); + ThrowIf(stars < 0 || stars > 6, "Invalid number of stars", { stars }); + + // Rainbow 5-stars have the same rating value as regular 5-stars + if (stars === 6) { + stars = 5; + } + + return Math.floor(stars * internalChartLevel * internalChartLevel) / 1000.0; +} diff --git a/typescript/rg-stats/src/algorithms/popn-classpoints.test.ts b/typescript/rg-stats/src/algorithms/popn-classpoints.test.ts new file mode 100644 index 000000000..08d34c0a7 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/popn-classpoints.test.ts @@ -0,0 +1,100 @@ +import { test } from "vitest"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { calculate, inverse, PopnLamps } from "./popn-classpoints"; + +test("Pop'n Class Points Tests", () => { + function MakeTestCase( + score: number, + lamp: PopnLamps, + level: number, + expectedClassPoints: number, + ): TestCase { + return () => + isAprx( + calculate(score, lamp, level), + expectedClassPoints, + `A score of ${score} and lamp ${lamp} on a chart with level ${level} should be worth ${expectedClassPoints} class points.`, + ); + } + + const testCases = [ + MakeTestCase(91_134, "CLEAR", 49, 98.19), + MakeTestCase(91_134, "EASY CLEAR", 49, 98.19), + MakeTestCase(91_134, "FULL COMBO", 49, 98.55), + MakeTestCase(91_134, "FAILED", 49, 97.63), + MakeTestCase(89_028, "CLEAR", 49, 97.79), + MakeTestCase(90_480, "FAILED", 49, 97.51), + MakeTestCase(88_259, "CLEAR", 48, 95.82), + MakeTestCase(0, "CLEAR", 48, 0), + MakeTestCase(49_999, "CLEAR", 48, 0), + MakeTestCase(100_000, "PERFECT", 1, 11.94), + MakeTestCase(100_000, "PERFECT", 50, 102.02), + MakeTestCase(100_000, "FULL COMBO", 50, 102.02), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("Pop'n Class Points Validation Tests", () => { + expectThrowsSnapshot( + () => calculate(95_000, "CLEAR", -1), + "Should throw if chart level is negative.", + ); + expectThrowsSnapshot(() => calculate(100_001, "CLEAR", 10), "Should throw if score is > 100k."); + expectThrowsSnapshot(() => calculate(-1, "CLEAR", 10), "Should throw if score is negative."); + + expectThrowsSnapshot( + () => calculate(72_000, "FOO" as PopnLamps, 10), + "Should throw if lamp is invalid.", + ); +}); + +test("Pop'n Inverse Class Points Tests", () => { + function MakeTestCase( + expectedScore: number, + lamp: PopnLamps, + level: number, + classPoints: number, + ): TestCase { + return () => + isAprx( + inverse(classPoints, lamp, level), + expectedScore, + `${classPoints} class points and lamp ${lamp} on a chart with level ${level} should invert to ${expectedScore} score.`, + ); + } + + const testCases = [ + MakeTestCase(91_154, "CLEAR", 49, 98.19), + MakeTestCase(91_154, "EASY CLEAR", 49, 98.19), + MakeTestCase(91_112, "FULL COMBO", 49, 98.55), + MakeTestCase(91_107, "FAILED", 49, 97.63), + MakeTestCase(88_978, "CLEAR", 49, 97.79), + MakeTestCase(90_454, "FAILED", 49, 97.51), + MakeTestCase(88_261, "CLEAR", 48, 95.82), + MakeTestCase(0, "CLEAR", 48, 0), + MakeTestCase(99_954, "PERFECT", 1, 11.94), + MakeTestCase(99_989, "PERFECT", 50, 102.02), + MakeTestCase(99_989, "FULL COMBO", 50, 102.02), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("Pop'n Inverse Class Points Validation Tests", () => { + expectThrowsSnapshot( + () => inverse(9000, "FAILED", 1), + "Should throw if the provided class points are impossible to achieve given the other constraints.", + ); + + expectThrowsSnapshot( + () => inverse(10, "CLEAR", -1), + "Should throw if chart level is negative.", + ); +}); diff --git a/typescript/rg-stats/src/algorithms/popn-classpoints.ts b/typescript/rg-stats/src/algorithms/popn-classpoints.ts new file mode 100644 index 000000000..cf4008aab --- /dev/null +++ b/typescript/rg-stats/src/algorithms/popn-classpoints.ts @@ -0,0 +1,70 @@ +import { ThrowIf } from "../util/throw-if"; +import { integer } from "../util/types"; + +export type PopnLamps = "FAILED" | "EASY CLEAR" | "CLEAR" | "FULL COMBO" | "PERFECT"; + +/** + * Calculate the pop'n class points for an individual score. + * + * @param score - The score value. Between 0 and 100k. + * @param lamp - The lamp for this score. + * @param level - The level for this chart. Typically between 1 and 50, + * but the upper bound is not enforced here. + */ +export function calculate(score: integer, lamp: PopnLamps, level: integer) { + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf(score > 100_000, "Score cannot be better than 100k.", { score }); + ThrowIf.negative(level, "Chart level cannot be negative.", { level }); + + if (score <= 50000) { + return 0; + } + + const clearBonus = GetClearBonus(lamp); + + return (10_000 * level + score - 50_000 + clearBonus) / 5440; +} + +function GetClearBonus(lamp: PopnLamps) { + if (lamp === "CLEAR" || lamp === "EASY CLEAR") { + return 3000; + } else if (lamp === "FULL COMBO" || lamp === "PERFECT") { + return 5000; + } else if (lamp === "FAILED") { + return 0; + } + + throw new Error( + `Unknown lamp of ${lamp} passed to pop'n Class Points calculations. Expected any of FAILED, EASY CLEAR, CLEAR, FULL COMBO, PERFECT.`, + ); +} + +/** + * Given a pop'n class points value, expected lamp and chart level, return the + * score necessary to get that amount of class points. + * + * @param classPoints - The class points to invert. + * @param lamp - The lamp to invert for. The lamp affects how much score is needed + * to achieve a given class points value. + * @param level - The level for the chart. Typically between 1 and 50, + * but the upper bound is not enforced here. + */ +export function inverse(classPoints: number, lamp: PopnLamps, level: integer) { + ThrowIf.negative(level, "Chart level cannot be negative.", { level }); + + if (classPoints === 0) { + return 0; + } + + const clearBonus = GetClearBonus(lamp); + + const expectedScore = 5440 * classPoints - 10_000 * level + 50_000 - clearBonus; + + ThrowIf( + expectedScore > 100_000, + `${classPoints} class points is not achievable on a chart with level ${level} and lamp ${lamp}.`, + { classPoints, level, lamp }, + ); + + return Math.round(expectedScore); +} diff --git a/typescript/rg-stats/src/algorithms/potential.test.ts b/typescript/rg-stats/src/algorithms/potential.test.ts new file mode 100644 index 000000000..2b13e64a0 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/potential.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "vitest"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { calculate } from "./potential"; + +test("Arcaea Potential Tests", () => { + function MakeTestCase(score: number, level: number, expectedPotential: number): TestCase { + return () => + isAprx( + calculate(score, level), + expectedPotential, + `A score of ${score} on a chart of level ${level} should be worth ${expectedPotential}`, + ); + } + + const testCases = [ + MakeTestCase(9_977_755, 11.3, 13.19), + MakeTestCase(9_934_498, 11.1, 12.77), + MakeTestCase(9_932_746, 10.9, 12.56), + + MakeTestCase(10_000_000, 7.0, 9.0), + MakeTestCase(9_900_000, 8.0, 9.5), + MakeTestCase(9_800_000, 9.5, 10.5), + MakeTestCase(9_500_000, 8.9, 8.9), + MakeTestCase(9_200_000, 7.5, 6.5), + MakeTestCase(8_900_000, 9.5, 7.5), + MakeTestCase(8_600_000, 10.5, 7.5), + ]; + + for (const testCase of testCases) { + testCase(); + } + + expect( + calculate(10_000_000, 10.0), + "Anything above 10,000,000 should give identical potential.", + ).toBe(calculate(10_001_000, 10.0)); +}); diff --git a/typescript/rg-stats/src/algorithms/potential.ts b/typescript/rg-stats/src/algorithms/potential.ts new file mode 100644 index 000000000..5d5324a60 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/potential.ts @@ -0,0 +1,27 @@ +import { ThrowIf } from "../util/throw-if"; + +/** + * Calculate Arcaea potential for a score. + * + * @param score - The score to calculate the potential for. + * @param internalChartLevel - The internal decimal level of the chart the score was achieved on. + */ +export function calculate(score: number, internalChartLevel: number) { + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf.negative(internalChartLevel, "Internal chart level cannot be negative.", { + level: internalChartLevel, + }); + + const iclInt = Math.round(internalChartLevel * 100); + let potential = 0; + + if (score >= 10_000_000) { + potential = iclInt + 200; + } else if (score >= 9_800_000) { + potential = iclInt + 100 + Math.floor((score - 9_800_000) / 2_000); + } else { + potential = iclInt + Math.floor((score - 9_500_000) / 3_000); + } + + return Math.max(potential / 100, 0); +} diff --git a/typescript/rg-stats/src/algorithms/poyashi-bpi.test.ts b/typescript/rg-stats/src/algorithms/poyashi-bpi.test.ts new file mode 100644 index 000000000..7a8598fd1 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/poyashi-bpi.test.ts @@ -0,0 +1,275 @@ +import { expect, test } from "vitest"; +import { calculate, inverse } from "./poyashi-bpi"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; + +const WR_AA = 3650; +const KAVG_AA = 3204; +const MAX_AA = 1834 * 2; +const COEF_AA = 1.25945; + +const WR_AFT = 2891; +const KAVG_AFT = 2497; +const MAX_AFT = 1480 * 2; +const COEF_AFT = null; + +test("BPI Tests", () => { + function AA_TestCase(providedEx: number, expectedBPI: number): TestCase { + return () => + isAprx( + calculate(providedEx, KAVG_AA, WR_AA, MAX_AA, COEF_AA), + expectedBPI, + `${providedEx} on AA should be worth ${expectedBPI}.`, + ); + } + + function AFT_TestCase(providedEx: number, expectedBPI: number): TestCase { + return () => + isAprx( + calculate(providedEx, KAVG_AFT, WR_AFT, MAX_AFT, COEF_AFT), + expectedBPI, + `${providedEx} on Afterimage d'automne should be worth ${expectedBPI}`, + ); + } + + const testCases = [ + AA_TestCase(KAVG_AA, 0), + AA_TestCase(WR_AA, 100), + AA_TestCase(MAX_AA, 244.55), + AA_TestCase(MAX_AA - 1, 222.86), + AA_TestCase(0, -15), + AA_TestCase(3393, 10.02), + AA_TestCase(3481, 20.09), + AA_TestCase(3535, 30.01), + AA_TestCase(3572, 40.18), + AA_TestCase(3597, 50.1), + AA_TestCase(3615, 60.12), + AA_TestCase(3628, 70.11), + AA_TestCase(3638, 80.62), + AA_TestCase(3645, 90.59), + AA_TestCase(3041, -5), + AA_TestCase(2886, -9.99), + + AFT_TestCase(KAVG_AFT, 0), + AFT_TestCase(WR_AFT, 100), + AFT_TestCase(MAX_AFT, 431.57), + AFT_TestCase(MAX_AFT - 1, 395.73), + AFT_TestCase(0, -15), + AFT_TestCase(2606, 10), + AFT_TestCase(2675, 20.07), + AFT_TestCase(2727, 30.18), + AFT_TestCase(2767, 40.12), + AFT_TestCase(2799, 50.06), + AFT_TestCase(2826, 60.43), + AFT_TestCase(2847, 70.3), + AFT_TestCase(2865, 80.57), + AFT_TestCase(2879, 90.18), + AFT_TestCase(2423, -4.99), + AFT_TestCase(2354, -10.04), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("BPI Validation Tests", () => { + expectThrowsSnapshot( + () => calculate(-1, 100, 110, 120, null), + "Should throw if your score is negative.", + ); + expectThrowsSnapshot( + () => calculate(100, -1, 110, 120, null), + "Should throw if Kaiden Average is negative.", + ); + + expectThrowsSnapshot( + () => calculate(100, 100, -1, 120, null), + "Should throw if WR is negative.", + ); + expectThrowsSnapshot( + () => calculate(100, 100, 110, -1, null), + "Should throw if MAX is negative.", + ); + expectThrowsSnapshot(() => calculate(100, 100, 130, 120, null), "Should throw if WR > MAX"); + expectThrowsSnapshot( + () => calculate(130, 100, 110, 120, null), + "Should throw if your score > MAX", + ); + expectThrowsSnapshot(() => calculate(100, 125, 120, 130, null), "Should throw if KAVG > WR"); + expectThrowsSnapshot(() => calculate(100, 120, 120, 130, null), "Should throw if KAVG == WR"); + + expectThrowsSnapshot( + () => calculate(15.5, 100, 110, 120, null), + "Should throw if your score is not an integer.", + ); + + expectThrowsSnapshot( + () => calculate(100, 15.5, 110, 120, null), + "Should throw if KAVG is not an integer.", + ); + expectThrowsSnapshot( + () => calculate(100, 100, 15.5, 120, null), + "Should throw if WR is not an integer.", + ); + expectThrowsSnapshot( + () => calculate(100, 100, 110, 15.5, null), + "Should throw if MAX is not an integer.", + ); +}); + +test("BPI Edge Cases", () => { + expect( + calculate(1150, 1100, 1200, 1300, 1.175), + "Null as a co-efficient should be identical to 1.175 as a co-efficient", + ).toBe(calculate(1150, 1100, 1200, 1300, null)); + expect( + calculate(1150, 1100, 1200, 1300, 1.175), + "-1 as a co-efficient should be identical to 1.175 as a co-efficient", + ).toBe(calculate(1150, 1100, 1200, 1300, -1)); + + isAprx( + calculate(1250, 1100, 1200, 1300, 1.175), + 225.79, + "Provided EX should be allowed to be greater than WR and less than MAX.", + ); + + isAprx( + calculate(1300, 1100, 1200, 1300, 1.175), + 1205.76, + "Provided EX should be allowed to be equal to MAX.", + ); + + isAprx( + calculate(1200, 1100, 1300, 1300, 1.175), + 8.29, + "WR should be allowed to be equal to MAX.", + ); +}); + +test("InverseBPI Tests", () => { + function AA_TestCase(expectedEx: number, providedBPI: number): TestCase { + return () => + isAprx( + inverse(providedBPI, KAVG_AA, WR_AA, MAX_AA, COEF_AA), + expectedEx, + `Inverse ${providedBPI}BPI on AA should be worth ${expectedEx}`, + ); + } + + function AFT_TestCase(expectedEx: number, providedBPI: number): TestCase { + return () => + isAprx( + inverse(providedBPI, KAVG_AFT, WR_AFT, MAX_AFT, COEF_AFT), + expectedEx, + `Inverse ${providedBPI}BPI on Afterimage d'automne should be worth ${expectedEx}`, + ); + } + + const testCases = [ + AA_TestCase(KAVG_AA, 0), + AA_TestCase(WR_AA, 100), + AA_TestCase(MAX_AA, 244.55), + AA_TestCase(MAX_AA - 1, 222.86), + AA_TestCase(2714, -15), + AA_TestCase(3393, 10.02), + AA_TestCase(3481, 20.09), + AA_TestCase(3535, 30.01), + AA_TestCase(3572, 40.18), + AA_TestCase(3597, 50.1), + AA_TestCase(3615, 60.12), + AA_TestCase(3628, 70.11), + AA_TestCase(3638, 80.62), + AA_TestCase(3645, 90.59), + AA_TestCase(3041, -5), + AA_TestCase(2886, -9.99), + + AFT_TestCase(KAVG_AFT, 0), + AFT_TestCase(WR_AFT, 100), + AFT_TestCase(MAX_AFT, 431.57), + AFT_TestCase(MAX_AFT - 1, 395.73), + AFT_TestCase(2284, -15), + AFT_TestCase(2606, 10), + AFT_TestCase(2675, 20.07), + AFT_TestCase(2727, 30.18), + AFT_TestCase(2767, 40.12), + AFT_TestCase(2799, 50.06), + AFT_TestCase(2826, 60.43), + AFT_TestCase(2847, 70.3), + AFT_TestCase(2865, 80.57), + AFT_TestCase(2879, 90.18), + AFT_TestCase(2423, -4.99), + AFT_TestCase(2354, -10.04), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("InverseBPI Validation Tests", () => { + expectThrowsSnapshot( + () => inverse(100, -1, 110, 120, null), + "Should throw if Kaiden Average is negative.", + ); + + expectThrowsSnapshot(() => inverse(100, 100, -1, 120, null), "Should throw if WR is negative."); + expectThrowsSnapshot( + () => inverse(100, 100, 110, -1, null), + "Should throw if MAX is negative.", + ); + + expectThrowsSnapshot( + () => inverse(100, 0, 110, 120, null), + "Should throw if Kaiden Average is 0.", + ); + + expectThrowsSnapshot(() => inverse(100, 100, 0, 120, null), "Should throw if WR is 0."); + expectThrowsSnapshot(() => inverse(100, 100, 110, 0, null), "Should throw if MAX is 0."); + + expectThrowsSnapshot(() => inverse(100, 100, 130, 120, null), "Should throw if WR > MAX"); + expectThrowsSnapshot(() => inverse(100, 125, 120, 130, null), "Should throw if KAVG > WR"); + expectThrowsSnapshot(() => inverse(100, 120, 120, 130, null), "Should throw if KAVG == WR"); + + expectThrowsSnapshot( + () => inverse(100, 15.5, 110, 120, null), + "Should throw if KAVG is not an integer.", + ); + expectThrowsSnapshot( + () => inverse(100, 100, 15.5, 120, null), + "Should throw if WR is not an integer.", + ); + expectThrowsSnapshot( + () => inverse(100, 100, 110, 15.5, null), + "Should throw if MAX is not an integer.", + ); + + expectThrowsSnapshot( + () => inverse(-16, 1100, 1200, 1300, 1.175), + "BPI less than -15 should throw an error.", + ); +}); + +test("InverseBPI Edge Cases", () => { + expect( + inverse(30.07, 1100, 1200, 1300, 1.175), + "Null as a co-efficient should be identical to 1.175 as a co-efficient", + ).toBe(inverse(30.07, 1100, 1200, 1300, null)); + expect( + inverse(30.07, 1100, 1200, 1300, 1.175), + "-1 as a co-efficient should be identical to 1.175 as a co-efficient", + ).toBe(inverse(30.07, 1100, 1200, 1300, -1)); + + isAprx( + inverse(30.07, 1100, 1300, 1300, 1.175), + 1275, + "WR should be allowed to be equal to MAX.", + ); + + isAprx( + inverse(10_000, 1100, 1200, 1300, 1.175), + 1300, + "BPI way larger than the maximum possible BPI available on the chart should return MAX.", + ); +}); diff --git a/typescript/rg-stats/src/algorithms/poyashi-bpi.ts b/typescript/rg-stats/src/algorithms/poyashi-bpi.ts new file mode 100644 index 000000000..fcf2aa931 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/poyashi-bpi.ts @@ -0,0 +1,223 @@ +import { LogToBase } from "../util/math"; +import { ThrowIf } from "../util/throw-if"; +import { integer } from "../util/types"; + +/** + * Calculates the "Beat Performance Index" of an IIDX score. + * this is a port of Poyashi's implementation. + * https://github.com/potakusan/iidx_score_manager/blob/f21ba6b85fcc0bf8b7ca888fa2239a3951a9c9c2/src/components/bpi/index.tsx#L120 + * + * @param yourEx Your EX score. + * @param kaidenAverage The kaiden average EX score. + * @param worldRecord The world record's EX score. + * @param max The maximum amount of EX achievable on this chart. + * @param powCoef What power the BPI should be raised to. This is arbitrary, and assigned on a per-song basis. Defaults to 1.175. + * If powCoef is set to -1, it will be overrode to 1.175. + * + * @returns A number between -15 and 100. Unless your score is better than + * the world record, in which case returns can be above 100. + * + * @edgecase Even though any score better than the world record *is* now the world record, + * it takes around 6 months for the official data on the world record to be updated. + * Since changing the worldRecord affects all scores on the chart, poyashi's + * implementation just lets BPI exceed 100 until patched. + * + * @edgecase BPI is undefined for the case where the world record is equal to the + * kaiden average. + */ +export function calculate( + yourEx: integer, + kaidenAverage: integer, + worldRecord: integer, + max: integer, + powCoef: number | null, +) { + let powCoefficient = powCoef ?? 1.175; + if (powCoefficient === -1) { + powCoefficient = 1.175; + } + + AssertProvidedEXScores(kaidenAverage, worldRecord, max, powCoefficient); + + ThrowIf.not(Number.isSafeInteger(yourEx), "Provided EX was not an integer.", { yourEx }); + ThrowIf(yourEx > max, "Provided EX was greater than MAX.", { yourEx, max }); + ThrowIf.negative(yourEx, "Provided EX was negative.", { yourEx }); + + ThrowIf( + worldRecord === kaidenAverage, + "BPI is undefined for the case where Kaiden Average equals the World Record", + { kaidenAverage, worldRecord }, + ); + + const yourPGF = PikaGreatFunction(yourEx, max); + const kaidenPGF = PikaGreatFunction(kaidenAverage, max); + const wrPGF = PikaGreatFunction(worldRecord, max); + + // Work out the ratio of your PGF versus the kaiden PGF. + const yourScorePrime = yourPGF / kaidenPGF; + const wrScorePrime = wrPGF / kaidenPGF; + + const isWorseThanKavg = yourEx < kaidenAverage; + + const logExPrime = LogToBase(yourScorePrime, wrScorePrime); + + if (isWorseThanKavg) { + // since logExPrime is negative (because yourScorePrime is between 0 and 1) + // We can't raise it to a power natively, as -0.5 to the power of something + // like 1.175 is imaginary. + + // To fix this, the poyashi implementation negates logExPrime + // and raises it to the powCoef. + const negativeRaisedValue = (-1 * logExPrime) ** powCoefficient; + + // and then negates it again when multiplying by 100. + const bpi = 100 * -1 * negativeRaisedValue; + + if (bpi < -15) { + return -15; + } + + return bpi; + } else { + return 100 * logExPrime ** powCoefficient; + } +} + +/** + * Calculates the "PGF" of an IIDX score. This returns a number that indicates how + * many pgreats you are expected to get for every great. + */ +function PikaGreatFunction(score: integer, max: integer) { + // if score === max, then the subsequent lines will involve a divide by zero + // so we have to avoid that somehow. + if (score === max) { + // The poyashi implementation multiplies by 0.8 here for an unknown reason. + // + // This multiplication essentially asserts that on a perfect score on a chart with + // 1000 notes, it expects every 1600(!!) notes you make a mistake. + return max * 0.8; + } + + const scorePercent = score / max; + + return 0.5 / (1 - scorePercent); +} + +/** + * Returns the EX Score necessary to achieve the provided BPI. + * + * @param bpi - The BPI you wish to find out how much ex is needed for. + * @param kaidenAverage The kaiden average EX score. + * @param worldRecord The world record's EX score. + * @param powCoef What power the BPI should be raised to. This is arbitrary, and assigned on a per-song basis. Defaults to 1.175. + * If powCoef is set to -1, it will be overrode to 1.175. + * + * @returns A number between 0 and `max`. + * + * @edgecase -15BPI is achievable by many possible EXs, since it is the lower bound + * for any given chart. Passing -15 into the function will return the largest + * possible exScore that results in -15BPI. + */ +export function inverse( + bpi: number, + kaidenAverage: integer, + worldRecord: integer, + max: integer, + powCoef: number | null, +) { + let powCoefficient = powCoef ?? 1.175; + if (powCoefficient === -1) { + powCoefficient = 1.175; + } + + AssertProvidedEXScores(kaidenAverage, worldRecord, max, powCoefficient); + + ThrowIf(bpi < -15, "BPI must be greater than or equal to -15.", { bpi }); + + const isWorseThanKavg = bpi < 0; + + let logExPrime; + + if (isWorseThanKavg) { + logExPrime = -1 * (bpi / -100) ** (1 / powCoefficient); + } else { + logExPrime = (bpi / 100) ** (1 / powCoefficient); + } + + const kaidenPGF = PikaGreatFunction(kaidenAverage, max); + const wrPGF = PikaGreatFunction(worldRecord, max); + + const wrScorePrime = wrPGF / kaidenPGF; + const exScorePrime = wrScorePrime ** logExPrime; + + const exScorePGF = exScorePrime * kaidenPGF; + + const exScore = InversePikaGreatFunction(exScorePGF, max); + + // This is because InversePGF can't correctly apply the 0.8x multiplier. + // If your exScore ceil's to MAX, then the ex score needed to get this score is max. + // note that this only applies to the case where bpi > 100. + if (bpi > 100 && Math.ceil(exScore) === max) { + return max; + } + + return Math.round(exScore); +} + +/** + * Inverts the PGF by returning the EXScore necessary to get a given PGF. + * + * @see {PikaGreatFunction} + */ +function InversePikaGreatFunction(pgf: number, max: number) { + return (pgf * max - 0.5 * max) / pgf; +} + +/** + * Apply assertions and constraints into values that BPI recieves. + */ +function AssertProvidedEXScores( + kaidenAverage: integer, + worldRecord: integer, + max: integer, + powCoef: number, +) { + // Is integer? + ThrowIf.not(Number.isSafeInteger(kaidenAverage), "Kaiden Average was not an integer.", { + kaidenAverage, + }); + ThrowIf.not(Number.isSafeInteger(worldRecord), "World Record was not an integer.", { + worldRecord, + }); + ThrowIf.not(Number.isSafeInteger(max), "MAX was not an integer.", { max }); + + // Is bounded correctly? + ThrowIf(kaidenAverage > max, "Kaiden Average was greater than MAX.", { + kaidenAverage, + max, + }); + ThrowIf(worldRecord > max, "World Record was greater than MAX.", { + worldRecord, + max, + }); + ThrowIf(kaidenAverage > worldRecord, "Kaiden Average was greater than WR.", { + kaidenAverage, + worldRecord, + }); + ThrowIf.negativeOrZero(kaidenAverage, "Kaiden Average was negative or zero.", { + kaidenAverage, + }); + ThrowIf.negativeOrZero(max, "MAX was negative or zero.", { max }); + ThrowIf.negativeOrZero(powCoef, "Power Coefficient was not positive.", { powCoef }); + + // This assertion never triggers, because WR has to be greater than KAVG, and KAVG + // is non-negative. + // ThrowIf.negativeOrZero(worldRecord, "World Record was negative or zero.", { worldRecord }); + + // Other edge case -- WR == KAVG breaks BPI immediately. + ThrowIf( + worldRecord === kaidenAverage, + "BPI is undefined for the case where Kaiden Average equals the World Record", + { kaidenAverage, worldRecord }, + ); +} diff --git a/typescript/rg-stats/src/algorithms/volforce.test.ts b/typescript/rg-stats/src/algorithms/volforce.test.ts new file mode 100644 index 000000000..64020d7d9 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/volforce.test.ts @@ -0,0 +1,820 @@ +import { expect, test } from "vitest"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { + calculateVF4, + calculateVF5, + calculateVF6, + inverseVF4, + inverseVF5, + inverseVF6, + SDVXLamps, +} from "./volforce"; + +test("VF4 Tests", () => { + function VF4TestCase(level: number, expectedVF4: number, score: number) { + return { level, expectedVF4, score }; + } + + const VF4ExampleData = [ + VF4TestCase(1, 42, 9_000_000), + VF4TestCase(1, 43, 9_100_000), + VF4TestCase(1, 43, 9_200_000), + VF4TestCase(1, 44, 9_300_000), + VF4TestCase(1, 45, 9_400_000), + VF4TestCase(1, 46, 9_500_000), + VF4TestCase(1, 46, 9_600_000), + VF4TestCase(1, 47, 9_700_000), + VF4TestCase(1, 47, 9_750_000), + VF4TestCase(1, 48, 9_800_000), + VF4TestCase(1, 48, 9_850_000), + VF4TestCase(1, 49, 9_900_000), + VF4TestCase(1, 49, 9_950_000), + VF4TestCase(1, 49, 9_990_000), + VF4TestCase(1, 50, 10_000_000), + VF4TestCase(2, 64, 9_000_000), + VF4TestCase(2, 64, 9_100_000), + VF4TestCase(2, 65, 9_200_000), + VF4TestCase(2, 66, 9_300_000), + VF4TestCase(2, 67, 9_400_000), + VF4TestCase(2, 69, 9_500_000), + VF4TestCase(2, 69, 9_600_000), + VF4TestCase(2, 71, 9_700_000), + VF4TestCase(2, 71, 9_750_000), + VF4TestCase(2, 72, 9_800_000), + VF4TestCase(2, 73, 9_850_000), + VF4TestCase(2, 74, 9_900_000), + VF4TestCase(2, 74, 9_950_000), + VF4TestCase(2, 74, 9_990_000), + VF4TestCase(2, 75, 10_000_000), + VF4TestCase(3, 85, 9_000_000), + VF4TestCase(3, 86, 9_100_000), + VF4TestCase(3, 87, 9_200_000), + VF4TestCase(3, 89, 9_300_000), + VF4TestCase(3, 90, 9_400_000), + VF4TestCase(3, 92, 9_500_000), + VF4TestCase(3, 93, 9_600_000), + VF4TestCase(3, 95, 9_700_000), + VF4TestCase(3, 95, 9_750_000), + VF4TestCase(3, 97, 9_800_000), + VF4TestCase(3, 97, 9_850_000), + VF4TestCase(3, 99, 9_900_000), + VF4TestCase(3, 99, 9_950_000), + VF4TestCase(3, 99, 9_990_000), + VF4TestCase(3, 100, 10_000_000), + VF4TestCase(4, 106, 9_000_000), + VF4TestCase(4, 108, 9_100_000), + VF4TestCase(4, 109, 9_200_000), + VF4TestCase(4, 111, 9_300_000), + VF4TestCase(4, 112, 9_400_000), + VF4TestCase(4, 115, 9_500_000), + VF4TestCase(4, 116, 9_600_000), + VF4TestCase(4, 118, 9_700_000), + VF4TestCase(4, 119, 9_750_000), + VF4TestCase(4, 121, 9_800_000), + VF4TestCase(4, 121, 9_850_000), + VF4TestCase(4, 123, 9_900_000), + VF4TestCase(4, 124, 9_950_000), + VF4TestCase(4, 124, 9_990_000), + VF4TestCase(4, 125, 10_000_000), + VF4TestCase(5, 128, 9_000_000), + VF4TestCase(5, 129, 9_100_000), + VF4TestCase(5, 131, 9_200_000), + VF4TestCase(5, 133, 9_300_000), + VF4TestCase(5, 135, 9_400_000), + VF4TestCase(5, 138, 9_500_000), + VF4TestCase(5, 139, 9_600_000), + VF4TestCase(5, 142, 9_700_000), + VF4TestCase(5, 143, 9_750_000), + VF4TestCase(5, 145, 9_800_000), + VF4TestCase(5, 146, 9_850_000), + VF4TestCase(5, 148, 9_900_000), + VF4TestCase(5, 149, 9_950_000), + VF4TestCase(5, 149, 9_990_000), + VF4TestCase(5, 150, 10_000_000), + VF4TestCase(6, 149, 9_000_000), + VF4TestCase(6, 151, 9_100_000), + VF4TestCase(6, 152, 9_200_000), + VF4TestCase(6, 156, 9_300_000), + VF4TestCase(6, 157, 9_400_000), + VF4TestCase(6, 161, 9_500_000), + VF4TestCase(6, 162, 9_600_000), + VF4TestCase(6, 166, 9_700_000), + VF4TestCase(6, 167, 9_750_000), + VF4TestCase(6, 169, 9_800_000), + VF4TestCase(6, 170, 9_850_000), + VF4TestCase(6, 173, 9_900_000), + VF4TestCase(6, 174, 9_950_000), + VF4TestCase(6, 174, 9_990_000), + VF4TestCase(6, 175, 10_000_000), + VF4TestCase(7, 171, 9_000_000), + VF4TestCase(7, 172, 9_100_000), + VF4TestCase(7, 174, 9_200_000), + VF4TestCase(7, 178, 9_300_000), + VF4TestCase(7, 180, 9_400_000), + VF4TestCase(7, 184, 9_500_000), + VF4TestCase(7, 186, 9_600_000), + VF4TestCase(7, 190, 9_700_000), + VF4TestCase(7, 191, 9_750_000), + VF4TestCase(7, 194, 9_800_000), + VF4TestCase(7, 195, 9_850_000), + VF4TestCase(7, 198, 9_900_000), + VF4TestCase(7, 199, 9_950_000), + VF4TestCase(7, 199, 9_990_000), + VF4TestCase(7, 200, 10_000_000), + VF4TestCase(8, 192, 9_000_000), + VF4TestCase(8, 194, 9_100_000), + VF4TestCase(8, 196, 9_200_000), + VF4TestCase(8, 200, 9_300_000), + VF4TestCase(8, 203, 9_400_000), + VF4TestCase(8, 207, 9_500_000), + VF4TestCase(8, 209, 9_600_000), + VF4TestCase(8, 213, 9_700_000), + VF4TestCase(8, 214, 9_750_000), + VF4TestCase(8, 218, 9_800_000), + VF4TestCase(8, 219, 9_850_000), + VF4TestCase(8, 222, 9_900_000), + VF4TestCase(8, 223, 9_950_000), + VF4TestCase(8, 224, 9_990_000), + VF4TestCase(8, 225, 10_000_000), + VF4TestCase(9, 213, 9_000_000), + VF4TestCase(9, 216, 9_100_000), + VF4TestCase(9, 218, 9_200_000), + VF4TestCase(9, 223, 9_300_000), + VF4TestCase(9, 225, 9_400_000), + VF4TestCase(9, 230, 9_500_000), + VF4TestCase(9, 232, 9_600_000), + VF4TestCase(9, 237, 9_700_000), + VF4TestCase(9, 238, 9_750_000), + VF4TestCase(9, 242, 9_800_000), + VF4TestCase(9, 243, 9_850_000), + VF4TestCase(9, 247, 9_900_000), + VF4TestCase(9, 248, 9_950_000), + VF4TestCase(9, 249, 9_990_000), + VF4TestCase(9, 250, 10_000_000), + VF4TestCase(10, 235, 9_000_000), + VF4TestCase(10, 237, 9_100_000), + VF4TestCase(10, 240, 9_200_000), + VF4TestCase(10, 245, 9_300_000), + VF4TestCase(10, 248, 9_400_000), + VF4TestCase(10, 253, 9_500_000), + VF4TestCase(10, 256, 9_600_000), + VF4TestCase(10, 261, 9_700_000), + VF4TestCase(10, 262, 9_750_000), + VF4TestCase(10, 266, 9_800_000), + VF4TestCase(10, 268, 9_850_000), + VF4TestCase(10, 272, 9_900_000), + VF4TestCase(10, 273, 9_950_000), + VF4TestCase(10, 274, 9_990_000), + VF4TestCase(10, 275, 10_000_000), + VF4TestCase(11, 256, 9_000_000), + VF4TestCase(11, 259, 9_100_000), + VF4TestCase(11, 262, 9_200_000), + VF4TestCase(11, 267, 9_300_000), + VF4TestCase(11, 270, 9_400_000), + VF4TestCase(11, 276, 9_500_000), + VF4TestCase(11, 279, 9_600_000), + VF4TestCase(11, 285, 9_700_000), + VF4TestCase(11, 286, 9_750_000), + VF4TestCase(11, 291, 9_800_000), + VF4TestCase(11, 292, 9_850_000), + VF4TestCase(11, 297, 9_900_000), + VF4TestCase(11, 298, 9_950_000), + VF4TestCase(11, 299, 9_990_000), + VF4TestCase(11, 300, 10_000_000), + VF4TestCase(12, 277, 9_000_000), + VF4TestCase(12, 280, 9_100_000), + VF4TestCase(12, 284, 9_200_000), + VF4TestCase(12, 290, 9_300_000), + VF4TestCase(12, 293, 9_400_000), + VF4TestCase(12, 299, 9_500_000), + VF4TestCase(12, 302, 9_600_000), + VF4TestCase(12, 308, 9_700_000), + VF4TestCase(12, 310, 9_750_000), + VF4TestCase(12, 315, 9_800_000), + VF4TestCase(12, 316, 9_850_000), + VF4TestCase(12, 321, 9_900_000), + VF4TestCase(12, 323, 9_950_000), + VF4TestCase(12, 324, 9_990_000), + VF4TestCase(12, 325, 10_000_000), + VF4TestCase(13, 299, 9_000_000), + VF4TestCase(13, 302, 9_100_000), + VF4TestCase(13, 305, 9_200_000), + VF4TestCase(13, 312, 9_300_000), + VF4TestCase(13, 315, 9_400_000), + VF4TestCase(13, 322, 9_500_000), + VF4TestCase(13, 325, 9_600_000), + VF4TestCase(13, 332, 9_700_000), + VF4TestCase(13, 334, 9_750_000), + VF4TestCase(13, 339, 9_800_000), + VF4TestCase(13, 341, 9_850_000), + VF4TestCase(13, 346, 9_900_000), + VF4TestCase(13, 348, 9_950_000), + VF4TestCase(13, 349, 9_990_000), + VF4TestCase(13, 350, 10_000_000), + VF4TestCase(14, 320, 9_000_000), + VF4TestCase(14, 324, 9_100_000), + VF4TestCase(14, 327, 9_200_000), + VF4TestCase(14, 334, 9_300_000), + VF4TestCase(14, 338, 9_400_000), + VF4TestCase(14, 345, 9_500_000), + VF4TestCase(14, 349, 9_600_000), + VF4TestCase(14, 356, 9_700_000), + VF4TestCase(14, 358, 9_750_000), + VF4TestCase(14, 363, 9_800_000), + VF4TestCase(14, 365, 9_850_000), + VF4TestCase(14, 371, 9_900_000), + VF4TestCase(14, 373, 9_950_000), + VF4TestCase(14, 374, 9_990_000), + VF4TestCase(14, 375, 10_000_000), + VF4TestCase(15, 342, 9_000_000), + VF4TestCase(15, 345, 9_100_000), + VF4TestCase(15, 349, 9_200_000), + VF4TestCase(15, 357, 9_300_000), + VF4TestCase(15, 360, 9_400_000), + VF4TestCase(15, 368, 9_500_000), + VF4TestCase(15, 372, 9_600_000), + VF4TestCase(15, 380, 9_700_000), + VF4TestCase(15, 382, 9_750_000), + VF4TestCase(15, 388, 9_800_000), + VF4TestCase(15, 390, 9_850_000), + VF4TestCase(15, 396, 9_900_000), + VF4TestCase(15, 398, 9_950_000), + VF4TestCase(15, 399, 9_990_000), + VF4TestCase(15, 400, 10_000_000), + VF4TestCase(16, 363, 9_000_000), + VF4TestCase(16, 367, 9_100_000), + VF4TestCase(16, 371, 9_200_000), + VF4TestCase(16, 379, 9_300_000), + VF4TestCase(16, 383, 9_400_000), + VF4TestCase(16, 391, 9_500_000), + VF4TestCase(16, 395, 9_600_000), + VF4TestCase(16, 404, 9_700_000), + VF4TestCase(16, 406, 9_750_000), + VF4TestCase(16, 412, 9_800_000), + VF4TestCase(16, 414, 9_850_000), + VF4TestCase(16, 420, 9_900_000), + VF4TestCase(16, 422, 9_950_000), + VF4TestCase(16, 424, 9_990_000), + VF4TestCase(16, 425, 10_000_000), + VF4TestCase(17, 384, 9_000_000), + VF4TestCase(17, 389, 9_100_000), + VF4TestCase(17, 393, 9_200_000), + VF4TestCase(17, 401, 9_300_000), + VF4TestCase(17, 406, 9_400_000), + VF4TestCase(17, 414, 9_500_000), + VF4TestCase(17, 419, 9_600_000), + VF4TestCase(17, 427, 9_700_000), + VF4TestCase(17, 429, 9_750_000), + VF4TestCase(17, 436, 9_800_000), + VF4TestCase(17, 438, 9_850_000), + VF4TestCase(17, 445, 9_900_000), + VF4TestCase(17, 447, 9_950_000), + VF4TestCase(17, 449, 9_990_000), + VF4TestCase(17, 450, 10_000_000), + VF4TestCase(18, 406, 9_000_000), + VF4TestCase(18, 410, 9_100_000), + VF4TestCase(18, 415, 9_200_000), + VF4TestCase(18, 424, 9_300_000), + VF4TestCase(18, 428, 9_400_000), + VF4TestCase(18, 437, 9_500_000), + VF4TestCase(18, 442, 9_600_000), + VF4TestCase(18, 451, 9_700_000), + VF4TestCase(18, 453, 9_750_000), + VF4TestCase(18, 460, 9_800_000), + VF4TestCase(18, 463, 9_850_000), + VF4TestCase(18, 470, 9_900_000), + VF4TestCase(18, 472, 9_950_000), + VF4TestCase(18, 474, 9_990_000), + VF4TestCase(18, 475, 10_000_000), + VF4TestCase(19, 427, 9_000_000), + VF4TestCase(19, 432, 9_100_000), + VF4TestCase(19, 437, 9_200_000), + VF4TestCase(19, 446, 9_300_000), + VF4TestCase(19, 451, 9_400_000), + VF4TestCase(19, 460, 9_500_000), + VF4TestCase(19, 465, 9_600_000), + VF4TestCase(19, 475, 9_700_000), + VF4TestCase(19, 477, 9_750_000), + VF4TestCase(19, 485, 9_800_000), + VF4TestCase(19, 487, 9_850_000), + VF4TestCase(19, 495, 9_900_000), + VF4TestCase(19, 497, 9_950_000), + VF4TestCase(19, 499, 9_990_000), + VF4TestCase(19, 500, 10_000_000), + VF4TestCase(20, 448, 9_000_000), + VF4TestCase(20, 453, 9_100_000), + VF4TestCase(20, 458, 9_200_000), + VF4TestCase(20, 468, 9_300_000), + VF4TestCase(20, 473, 9_400_000), + VF4TestCase(20, 483, 9_500_000), + VF4TestCase(20, 488, 9_600_000), + VF4TestCase(20, 499, 9_700_000), + VF4TestCase(20, 501, 9_750_000), + VF4TestCase(20, 509, 9_800_000), + VF4TestCase(20, 511, 9_850_000), + VF4TestCase(20, 519, 9_900_000), + VF4TestCase(20, 522, 9_950_000), + VF4TestCase(20, 524, 9_990_000), + VF4TestCase(20, 525, 10_000_000), + ]; + + for (const data of VF4ExampleData) { + expect( + calculateVF4(data.score, data.level), + `${data.score} on a level ${data.level} chart should be worth ${data.expectedVF4} VF4.`, + ).toBe(data.expectedVF4); + } +}); + +test("VF4 Validation Tests", () => { + expectThrowsSnapshot( + () => calculateVF4(10_000_001, 20), + "Should throw if score is greater than 10 million.", + ); + + expectThrowsSnapshot(() => calculateVF4(-1, 20), "Should throw if score is negative."); + + expectThrowsSnapshot( + () => inverseVF4(9_000, 20), + "Should throw if the VF4 requested as an inversion is impossible.", + ); +}); + +test("InverseVF4 Tests", () => { + const testData = [ + { vf4: 42, level: 1, expectedScore: 8936170 }, + { vf4: 43, level: 1, expectedScore: 9052632 }, + { vf4: 44, level: 1, expectedScore: 9263158 }, + { vf4: 45, level: 1, expectedScore: 9375000 }, + { vf4: 46, level: 1, expectedScore: 9500000 }, + { vf4: 47, level: 1, expectedScore: 9690722 }, + { vf4: 48, level: 1, expectedScore: 9795918 }, + { vf4: 49, level: 1, expectedScore: 9898990 }, + { vf4: 50, level: 1, expectedScore: 10000000 }, + { vf4: 64, level: 2, expectedScore: 9000000 }, + { vf4: 65, level: 2, expectedScore: 9122807 }, + { vf4: 66, level: 2, expectedScore: 9263158 }, + { vf4: 67, level: 2, expectedScore: 9305556 }, + { vf4: 69, level: 2, expectedScore: 9500000 }, + { vf4: 71, level: 2, expectedScore: 9700000 }, + { vf4: 71, level: 2, expectedScore: 9700000 }, + { vf4: 72, level: 2, expectedScore: 9795918 }, + { vf4: 73, level: 2, expectedScore: 9831650 }, + { vf4: 74, level: 2, expectedScore: 9900000 }, + { vf4: 75, level: 2, expectedScore: 10000000 }, + { vf4: 85, level: 3, expectedScore: 9000000 }, + { vf4: 86, level: 3, expectedScore: 9052632 }, + { vf4: 87, level: 3, expectedScore: 9157895 }, + { vf4: 89, level: 3, expectedScore: 9300000 }, + { vf4: 90, level: 3, expectedScore: 9375000 }, + { vf4: 92, level: 3, expectedScore: 9500000 }, + { vf4: 93, level: 3, expectedScore: 9587629 }, + { vf4: 95, level: 3, expectedScore: 9700000 }, + { vf4: 97, level: 3, expectedScore: 9800000 }, + { vf4: 97, level: 3, expectedScore: 9800000 }, + { vf4: 99, level: 3, expectedScore: 9900000 }, + { vf4: 100, level: 3, expectedScore: 10000000 }, + { vf4: 106, level: 4, expectedScore: 9000000 }, + { vf4: 108, level: 4, expectedScore: 9094737 }, + { vf4: 109, level: 4, expectedScore: 9178947 }, + { vf4: 111, level: 4, expectedScore: 9300000 }, + { vf4: 112, level: 4, expectedScore: 9333333 }, + { vf4: 115, level: 4, expectedScore: 9500000 }, + { vf4: 116, level: 4, expectedScore: 9567010 }, + { vf4: 118, level: 4, expectedScore: 9700000 }, + { vf4: 119, level: 4, expectedScore: 9714286 }, + { vf4: 121, level: 4, expectedScore: 9800000 }, + { vf4: 121, level: 4, expectedScore: 9800000 }, + { vf4: 123, level: 4, expectedScore: 9900000 }, + { vf4: 124, level: 4, expectedScore: 9920000 }, + { vf4: 125, level: 4, expectedScore: 10000000 }, + { vf4: 128, level: 5, expectedScore: 9000000 }, + { vf4: 129, level: 5, expectedScore: 9052632 }, + { vf4: 131, level: 5, expectedScore: 9192982 }, + { vf4: 133, level: 5, expectedScore: 9300000 }, + { vf4: 135, level: 5, expectedScore: 9375000 }, + { vf4: 138, level: 5, expectedScore: 9500000 }, + { vf4: 139, level: 5, expectedScore: 9553265 }, + { vf4: 142, level: 5, expectedScore: 9700000 }, + { vf4: 143, level: 5, expectedScore: 9727891 }, + { vf4: 145, level: 5, expectedScore: 9800000 }, + { vf4: 146, level: 5, expectedScore: 9831650 }, + { vf4: 148, level: 5, expectedScore: 9900000 }, + { vf4: 149, level: 5, expectedScore: 9933333 }, + { vf4: 150, level: 5, expectedScore: 10000000 }, + { vf4: 149, level: 6, expectedScore: 9000000 }, + { vf4: 151, level: 6, expectedScore: 9082707 }, + { vf4: 152, level: 6, expectedScore: 9142857 }, + { vf4: 156, level: 6, expectedScore: 9300000 }, + { vf4: 157, level: 6, expectedScore: 9345238 }, + { vf4: 161, level: 6, expectedScore: 9500000 }, + { vf4: 162, level: 6, expectedScore: 9543446 }, + { vf4: 166, level: 6, expectedScore: 9700000 }, + { vf4: 167, level: 6, expectedScore: 9737609 }, + { vf4: 169, level: 6, expectedScore: 9800000 }, + { vf4: 170, level: 6, expectedScore: 9812410 }, + { vf4: 173, level: 6, expectedScore: 9900000 }, + { vf4: 174, level: 6, expectedScore: 9942857 }, + { vf4: 175, level: 6, expectedScore: 10000000 }, + { vf4: 171, level: 7, expectedScore: 9000000 }, + { vf4: 172, level: 7, expectedScore: 9052632 }, + { vf4: 174, level: 7, expectedScore: 9157895 }, + { vf4: 178, level: 7, expectedScore: 9300000 }, + { vf4: 180, level: 7, expectedScore: 9375000 }, + { vf4: 184, level: 7, expectedScore: 9500000 }, + { vf4: 186, level: 7, expectedScore: 9587629 }, + { vf4: 190, level: 7, expectedScore: 9700000 }, + { vf4: 191, level: 7, expectedScore: 9744898 }, + { vf4: 194, level: 7, expectedScore: 9800000 }, + { vf4: 195, level: 7, expectedScore: 9848485 }, + { vf4: 198, level: 7, expectedScore: 9900000 }, + { vf4: 199, level: 7, expectedScore: 9950000 }, + { vf4: 200, level: 7, expectedScore: 10000000 }, + { vf4: 192, level: 8, expectedScore: 9000000 }, + { vf4: 194, level: 8, expectedScore: 9076023 }, + { vf4: 196, level: 8, expectedScore: 9169591 }, + { vf4: 200, level: 8, expectedScore: 9300000 }, + { vf4: 203, level: 8, expectedScore: 9398148 }, + { vf4: 207, level: 8, expectedScore: 9500000 }, + { vf4: 209, level: 8, expectedScore: 9576174 }, + { vf4: 213, level: 8, expectedScore: 9700000 }, + { vf4: 214, level: 8, expectedScore: 9705215 }, + { vf4: 218, level: 8, expectedScore: 9800000 }, + { vf4: 219, level: 8, expectedScore: 9831650 }, + { vf4: 222, level: 8, expectedScore: 9900000 }, + { vf4: 223, level: 8, expectedScore: 9911111 }, + { vf4: 224, level: 8, expectedScore: 9955556 }, + { vf4: 225, level: 8, expectedScore: 10000000 }, + { vf4: 213, level: 9, expectedScore: 9000000 }, + { vf4: 216, level: 9, expectedScore: 9094737 }, + { vf4: 218, level: 9, expectedScore: 9178947 }, + { vf4: 223, level: 9, expectedScore: 9300000 }, + { vf4: 225, level: 9, expectedScore: 9375000 }, + { vf4: 230, level: 9, expectedScore: 9500000 }, + { vf4: 232, level: 9, expectedScore: 9567010 }, + { vf4: 237, level: 9, expectedScore: 9700000 }, + { vf4: 238, level: 9, expectedScore: 9714286 }, + { vf4: 242, level: 9, expectedScore: 9800000 }, + { vf4: 243, level: 9, expectedScore: 9818182 }, + { vf4: 247, level: 9, expectedScore: 9900000 }, + { vf4: 248, level: 9, expectedScore: 9920000 }, + { vf4: 249, level: 9, expectedScore: 9960000 }, + { vf4: 250, level: 9, expectedScore: 10000000 }, + { vf4: 235, level: 10, expectedScore: 9000000 }, + { vf4: 237, level: 10, expectedScore: 9071770 }, + { vf4: 240, level: 10, expectedScore: 9186603 }, + { vf4: 245, level: 10, expectedScore: 9300000 }, + { vf4: 248, level: 10, expectedScore: 9393939 }, + { vf4: 253, level: 10, expectedScore: 9500000 }, + { vf4: 256, level: 10, expectedScore: 9597001 }, + { vf4: 261, level: 10, expectedScore: 9700000 }, + { vf4: 262, level: 10, expectedScore: 9721707 }, + { vf4: 266, level: 10, expectedScore: 9800000 }, + { vf4: 268, level: 10, expectedScore: 9843893 }, + { vf4: 272, level: 10, expectedScore: 9900000 }, + { vf4: 273, level: 10, expectedScore: 9927273 }, + { vf4: 274, level: 10, expectedScore: 9963636 }, + { vf4: 275, level: 10, expectedScore: 10000000 }, + { vf4: 256, level: 11, expectedScore: 9000000 }, + { vf4: 259, level: 11, expectedScore: 9087719 }, + { vf4: 262, level: 11, expectedScore: 9192982 }, + { vf4: 267, level: 11, expectedScore: 9300000 }, + { vf4: 270, level: 11, expectedScore: 9375000 }, + { vf4: 276, level: 11, expectedScore: 9500000 }, + { vf4: 279, level: 11, expectedScore: 9587629 }, + { vf4: 285, level: 11, expectedScore: 9700000 }, + { vf4: 286, level: 11, expectedScore: 9727891 }, + { vf4: 291, level: 11, expectedScore: 9800000 }, + { vf4: 292, level: 11, expectedScore: 9831650 }, + { vf4: 297, level: 11, expectedScore: 9900000 }, + { vf4: 298, level: 11, expectedScore: 9933333 }, + { vf4: 299, level: 11, expectedScore: 9966667 }, + { vf4: 300, level: 11, expectedScore: 10000000 }, + { vf4: 277, level: 12, expectedScore: 9000000 }, + { vf4: 280, level: 12, expectedScore: 9068826 }, + { vf4: 284, level: 12, expectedScore: 9198381 }, + { vf4: 290, level: 12, expectedScore: 9300000 }, + { vf4: 293, level: 12, expectedScore: 9391026 }, + { vf4: 299, level: 12, expectedScore: 9500000 }, + { vf4: 302, level: 12, expectedScore: 9579699 }, + { vf4: 308, level: 12, expectedScore: 9700000 }, + { vf4: 310, level: 12, expectedScore: 9733124 }, + { vf4: 315, level: 12, expectedScore: 9800000 }, + { vf4: 316, level: 12, expectedScore: 9821290 }, + { vf4: 321, level: 12, expectedScore: 9900000 }, + { vf4: 323, level: 12, expectedScore: 9938462 }, + { vf4: 324, level: 12, expectedScore: 9969231 }, + { vf4: 325, level: 12, expectedScore: 10000000 }, + { vf4: 299, level: 13, expectedScore: 9000000 }, + { vf4: 302, level: 13, expectedScore: 9082707 }, + { vf4: 305, level: 13, expectedScore: 9172932 }, + { vf4: 312, level: 13, expectedScore: 9300000 }, + { vf4: 315, level: 13, expectedScore: 9375000 }, + { vf4: 322, level: 13, expectedScore: 9500000 }, + { vf4: 325, level: 13, expectedScore: 9572901 }, + { vf4: 332, level: 13, expectedScore: 9700000 }, + { vf4: 334, level: 13, expectedScore: 9737609 }, + { vf4: 339, level: 13, expectedScore: 9800000 }, + { vf4: 341, level: 13, expectedScore: 9841270 }, + { vf4: 346, level: 13, expectedScore: 9900000 }, + { vf4: 348, level: 13, expectedScore: 9942857 }, + { vf4: 349, level: 13, expectedScore: 9971429 }, + { vf4: 350, level: 13, expectedScore: 10000000 }, + { vf4: 320, level: 14, expectedScore: 9000000 }, + { vf4: 324, level: 14, expectedScore: 9094737 }, + { vf4: 327, level: 14, expectedScore: 9178947 }, + { vf4: 334, level: 14, expectedScore: 9300000 }, + { vf4: 338, level: 14, expectedScore: 9388889 }, + { vf4: 345, level: 14, expectedScore: 9500000 }, + { vf4: 349, level: 14, expectedScore: 9594502 }, + { vf4: 356, level: 14, expectedScore: 9700000 }, + { vf4: 358, level: 14, expectedScore: 9741497 }, + { vf4: 363, level: 14, expectedScore: 9800000 }, + { vf4: 365, level: 14, expectedScore: 9831650 }, + { vf4: 371, level: 14, expectedScore: 9900000 }, + { vf4: 373, level: 14, expectedScore: 9946667 }, + { vf4: 374, level: 14, expectedScore: 9973333 }, + { vf4: 375, level: 14, expectedScore: 10000000 }, + { vf4: 342, level: 15, expectedScore: 9000000 }, + { vf4: 345, level: 15, expectedScore: 9078947 }, + { vf4: 349, level: 15, expectedScore: 9184211 }, + { vf4: 357, level: 15, expectedScore: 9300000 }, + { vf4: 360, level: 15, expectedScore: 9375000 }, + { vf4: 368, level: 15, expectedScore: 9500000 }, + { vf4: 372, level: 15, expectedScore: 9587629 }, + { vf4: 380, level: 15, expectedScore: 9700000 }, + { vf4: 382, level: 15, expectedScore: 9744898 }, + { vf4: 388, level: 15, expectedScore: 9800000 }, + { vf4: 390, level: 15, expectedScore: 9848485 }, + { vf4: 396, level: 15, expectedScore: 9900000 }, + { vf4: 398, level: 15, expectedScore: 9950000 }, + { vf4: 399, level: 15, expectedScore: 9975000 }, + { vf4: 400, level: 15, expectedScore: 10000000 }, + { vf4: 363, level: 16, expectedScore: 9000000 }, + { vf4: 367, level: 16, expectedScore: 9089783 }, + { vf4: 371, level: 16, expectedScore: 9188854 }, + { vf4: 379, level: 16, expectedScore: 9300000 }, + { vf4: 383, level: 16, expectedScore: 9387255 }, + { vf4: 391, level: 16, expectedScore: 9500000 }, + { vf4: 395, level: 16, expectedScore: 9581565 }, + { vf4: 404, level: 16, expectedScore: 9700000 }, + { vf4: 406, level: 16, expectedScore: 9747899 }, + { vf4: 412, level: 16, expectedScore: 9800000 }, + { vf4: 414, level: 16, expectedScore: 9839572 }, + { vf4: 420, level: 16, expectedScore: 9900000 }, + { vf4: 422, level: 16, expectedScore: 9929412 }, + { vf4: 424, level: 16, expectedScore: 9976471 }, + { vf4: 425, level: 16, expectedScore: 10000000 }, + { vf4: 384, level: 17, expectedScore: 9000000 }, + { vf4: 389, level: 17, expectedScore: 9099415 }, + { vf4: 393, level: 17, expectedScore: 9192982 }, + { vf4: 401, level: 17, expectedScore: 9300000 }, + { vf4: 406, level: 17, expectedScore: 9398148 }, + { vf4: 414, level: 17, expectedScore: 9500000 }, + { vf4: 419, level: 17, expectedScore: 9599084 }, + { vf4: 427, level: 17, expectedScore: 9700000 }, + { vf4: 429, level: 17, expectedScore: 9727891 }, + { vf4: 436, level: 17, expectedScore: 9800000 }, + { vf4: 438, level: 17, expectedScore: 9831650 }, + { vf4: 445, level: 17, expectedScore: 9900000 }, + { vf4: 447, level: 17, expectedScore: 9933333 }, + { vf4: 449, level: 17, expectedScore: 9977778 }, + { vf4: 450, level: 17, expectedScore: 10000000 }, + { vf4: 406, level: 18, expectedScore: 9000000 }, + { vf4: 410, level: 18, expectedScore: 9085873 }, + { vf4: 415, level: 18, expectedScore: 9196676 }, + { vf4: 424, level: 18, expectedScore: 9300000 }, + { vf4: 428, level: 18, expectedScore: 9385965 }, + { vf4: 437, level: 18, expectedScore: 9500000 }, + { vf4: 442, level: 18, expectedScore: 9593055 }, + { vf4: 451, level: 18, expectedScore: 9700000 }, + { vf4: 453, level: 18, expectedScore: 9731472 }, + { vf4: 460, level: 18, expectedScore: 9800000 }, + { vf4: 463, level: 18, expectedScore: 9845827 }, + { vf4: 470, level: 18, expectedScore: 9900000 }, + { vf4: 472, level: 18, expectedScore: 9936842 }, + { vf4: 474, level: 18, expectedScore: 9978947 }, + { vf4: 475, level: 18, expectedScore: 10000000 }, + { vf4: 427, level: 19, expectedScore: 9000000 }, + { vf4: 432, level: 19, expectedScore: 9094737 }, + { vf4: 437, level: 19, expectedScore: 9200000 }, + { vf4: 446, level: 19, expectedScore: 9300000 }, + { vf4: 451, level: 19, expectedScore: 9395833 }, + { vf4: 460, level: 19, expectedScore: 9500000 }, + { vf4: 465, level: 19, expectedScore: 9587629 }, + { vf4: 475, level: 19, expectedScore: 9700000 }, + { vf4: 477, level: 19, expectedScore: 9734694 }, + { vf4: 485, level: 19, expectedScore: 9800000 }, + { vf4: 487, level: 19, expectedScore: 9838384 }, + { vf4: 495, level: 19, expectedScore: 9900000 }, + { vf4: 497, level: 19, expectedScore: 9940000 }, + { vf4: 499, level: 19, expectedScore: 9980000 }, + { vf4: 500, level: 19, expectedScore: 10000000 }, + { vf4: 448, level: 20, expectedScore: 9000000 }, + { vf4: 453, level: 20, expectedScore: 9082707 }, + { vf4: 458, level: 20, expectedScore: 9182957 }, + { vf4: 468, level: 20, expectedScore: 9300000 }, + { vf4: 473, level: 20, expectedScore: 9384921 }, + { vf4: 483, level: 20, expectedScore: 9500000 }, + { vf4: 488, level: 20, expectedScore: 9582720 }, + { vf4: 499, level: 20, expectedScore: 9700000 }, + { vf4: 501, level: 20, expectedScore: 9737609 }, + { vf4: 509, level: 20, expectedScore: 9800000 }, + { vf4: 511, level: 20, expectedScore: 9831650 }, + { vf4: 519, level: 20, expectedScore: 9900000 }, + { vf4: 522, level: 20, expectedScore: 9942857 }, + { vf4: 524, level: 20, expectedScore: 9980952 }, + { vf4: 525, level: 20, expectedScore: 10000000 }, + ]; + + for (const data of testData) { + expect( + inverseVF4(data.vf4, data.level), + `${data.vf4} VF4 on a level ${data.level} chart should be given by ${data.expectedScore} score.`, + ).toBe(data.expectedScore); + } +}); + +test("VF5 Tests", () => { + function VF5TestCase( + level: number, + score: number, + lamp: SDVXLamps, + expectedVF5: number, + ): TestCase { + return () => { + const vf5 = calculateVF5(score, lamp, level); + + expect( + vf5, + `${score} on a level ${level} chart with lamp ${lamp} should be worth ${expectedVF5} VF5. (Got ${vf5})`, + ).toBe(expectedVF5); + }; + } + + const VF5ExampleData = [ + VF5TestCase(20, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.46), + VF5TestCase(19, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.43), + VF5TestCase(18, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.41), + VF5TestCase(17, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.39), + VF5TestCase(16, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.36), + VF5TestCase(15, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.34), + VF5TestCase(14, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.32), + VF5TestCase(13, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.3), + VF5TestCase(12, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.27), + VF5TestCase(11, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.25), + VF5TestCase(10, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.23), + VF5TestCase(9, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.2), + VF5TestCase(8, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.18), + VF5TestCase(7, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.16), + VF5TestCase(6, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.13), + VF5TestCase(5, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.11), + VF5TestCase(4, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.09), + VF5TestCase(3, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.06), + VF5TestCase(2, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.04), + VF5TestCase(1, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.02), + + VF5TestCase(18, 9_700_000, "ULTIMATE CHAIN", 0.36), + VF5TestCase(13, 9_700_000, "ULTIMATE CHAIN", 0.26), + + VF5TestCase(19, 9_500_000, "EXCESSIVE CLEAR", 0.35), + VF5TestCase(19, 9_500_000, "CLEAR", 0.35), + VF5TestCase(19, 9_500_000, "FAILED", 0.17), + + VF5TestCase(16, 9_500_000, "CLEAR", 0.29), + ]; + + for (const testCase of VF5ExampleData) { + testCase(); + } +}); + +test("InverseVF5 Tests", () => { + function InvVF5TestCase( + level: number, + vf5: number, + lamp: Exclude, + expectedScore: number, + ): TestCase { + return () => { + const score = inverseVF5(vf5, lamp, level); + + expect( + score, + `${vf5} VF5 on a level ${level} chart with lamp ${lamp} should be inverted to ${expectedScore}. (Got ${score})`, + ).toBe(expectedScore); + }; + } + + const testCases = [ + InvVF5TestCase(20, 0.4, "CLEAR", 9_803_922), + InvVF5TestCase(20, 0.4, "EXCESSIVE CLEAR", 9_800_000), + InvVF5TestCase(13, 0.12, "CLEAR", 5_769_231), + InvVF5TestCase(13, 0.12, "FAILED", 9_516_257), + InvVF5TestCase(18, 0.31, "ULTIMATE CHAIN", 9_012_152), + ]; + + for (const testCase of testCases) { + testCase(); + } + + expectThrowsSnapshot( + () => inverseVF5(900, "FAILED", 1), + "Should throw if the volforce is impossible to achieve with this lamp/level.", + ); +}); + +test("VF6 Tests", () => { + function VF6TestCase( + level: number, + score: number, + lamp: SDVXLamps, + expectedVF6: number, + ): TestCase { + return () => { + const vf6 = calculateVF6(score, lamp, level); + + expect( + vf6, + `${score} on a level ${level} chart with lamp ${lamp} should be worth ${expectedVF6} VF6. (Got ${vf6})`, + ).toBe(expectedVF6); + }; + } + + const VF5ExampleData = [ + VF6TestCase(20, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.462), + VF6TestCase(19, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.438), + VF6TestCase(18, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.415), + VF6TestCase(17, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.392), + VF6TestCase(16, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.369), + VF6TestCase(15, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.346), + VF6TestCase(14, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.323), + VF6TestCase(13, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.3), + VF6TestCase(12, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.277), + VF6TestCase(11, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.254), + VF6TestCase(10, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.231), + VF6TestCase(9, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.207), + VF6TestCase(8, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.184), + VF6TestCase(7, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.161), + VF6TestCase(6, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.138), + VF6TestCase(5, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.115), + VF6TestCase(4, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.092), + VF6TestCase(3, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.069), + VF6TestCase(2, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.046), + VF6TestCase(1, 10_000_000, "PERFECT ULTIMATE CHAIN", 0.023), + + VF6TestCase(18, 9_700_000, "ULTIMATE CHAIN", 0.366), + VF6TestCase(13, 9_700_000, "ULTIMATE CHAIN", 0.264), + + VF6TestCase(19, 9_500_000, "EXCESSIVE CLEAR", 0.357), + VF6TestCase(18, 9_923_042, "MAXXIVE CLEAR", 0.39), + VF6TestCase(19, 9_500_000, "CLEAR", 0.35), + VF6TestCase(19, 9_500_000, "FAILED", 0.175), + + VF6TestCase(20, 5_000_000, "FAILED", 0.08), + VF6TestCase(20, 7_000_000, "FAILED", 0.114), + VF6TestCase(20, 8_000_000, "FAILED", 0.136), + VF6TestCase(20, 8_700_000, "FAILED", 0.153), + ]; + + for (const testCase of VF5ExampleData) { + testCase(); + } +}); + +test("InverseVF6 Tests", () => { + function InvVF6TestCase( + level: number, + vf6: number, + lamp: Exclude, + expectedScore: number, + ): TestCase { + return () => { + const score = inverseVF6(vf6, lamp, level); + + expect( + score, + `${vf6} VF6 on a level ${level} chart with lamp ${lamp} should be inverted to ${expectedScore}. (Got ${score})`, + ).toBe(expectedScore); + }; + } + + const testCases = [ + InvVF6TestCase(20, 0.413, "CLEAR", 9_900_000), + InvVF6TestCase(20, 0.232, "MAXXIVE CLEAR", 6_971_154), + InvVF6TestCase(20, 0.421, "EXCESSIVE CLEAR", 9_900_000), + InvVF6TestCase(13, 0.121, "CLEAR", 5_817_308), + InvVF6TestCase(13, 0.121, "FAILED", 9_595_559), + InvVF6TestCase(18, 0.31, "ULTIMATE CHAIN", 9_012_152), + ]; + + for (const testCase of testCases) { + testCase(); + } + + expectThrowsSnapshot( + () => inverseVF6(900, "FAILED", 1), + "Should throw if the volforce is impossible to achieve with this lamp/level.", + ); +}); diff --git a/typescript/rg-stats/src/algorithms/volforce.ts b/typescript/rg-stats/src/algorithms/volforce.ts new file mode 100644 index 000000000..2abb87a2f --- /dev/null +++ b/typescript/rg-stats/src/algorithms/volforce.ts @@ -0,0 +1,315 @@ +import { GetEntriesAsArray } from "../util/misc"; +import { ThrowIf } from "../util/throw-if"; +import { integer } from "../util/types"; + +export type SDVXGrades = "D" | "C" | "B" | "A" | "A+" | "AA" | "AA+" | "AAA" | "AAA+" | "S"; +export type SDVXLamps = + | "FAILED" + | "CLEAR" + | "EXCESSIVE CLEAR" + | "MAXXIVE CLEAR" + | "ULTIMATE CHAIN" + | "PERFECT ULTIMATE CHAIN"; + +const VF4GradeCoefficients: Record = { + S: 1.0, + "AAA+": 0.99, + AAA: 0.98, + "AA+": 0.97, + AA: 0.96, + "A+": 0.95, + A: 0.94, + B: 0.93, + C: 0.92, + D: 0.91, +}; + +const VF5GradeCoefficients: Record = { + S: 105, + "AAA+": 102, + AAA: 100, + "AA+": 97, + AA: 94, + // everything below this point is marked with a (?) + // in bemaniwiki, so maybe it can't be trusted? + "A+": 91, + A: 88, + B: 85, + C: 82, + D: 80, +}; + +const VF5LampCoefficients: Record = { + "PERFECT ULTIMATE CHAIN": 110, + "ULTIMATE CHAIN": 105, + "MAXXIVE CLEAR": 104, + "EXCESSIVE CLEAR": 102, + CLEAR: 100, + FAILED: 50, +}; + +/** + * Calculate VOLFORCE as it's defined in SDVX4. + * + * @param score - The user's score. Between 0 and 10million. + * @param level - The level of the chart. Between 0 and 20, but this is not enforced. + */ +export function calculateVF4(score: integer, level: integer) { + AssertProvidedScore(score); + + const grade = SDVXScoreToGrade(score); + + const gradeCoefficient = VF4GradeCoefficients[grade]; + + return Math.floor(25 * (level + 1) * (score / 10_000_000) * gradeCoefficient); +} + +/** + * Given a VF4 value and a chart level, return what score is needed to get that + * VF4. + * + * If the score needed is greater than 10million, this function will throw. + ** + * @param vf4 - The VF4 to invert. + * @param level - The level of the chart you're inverting for. + */ +export function inverseVF4(vf4: integer, level: integer) { + const scoreTimesGradeCoef = (10_000_000 * vf4) / (25 * (level + 1)); + + const score = AttemptGradeCoefficientDivide(scoreTimesGradeCoef, VF4GradeCoefficients); + + ThrowIf(score === null, `A VF4 of ${vf4} is not possible on a chart with level ${level}`, { + vf4, + level, + }); + + return score; +} + +/** + * Calculate VOLFORCE as it's defined in SDVX5. + * + * @param score - The user's score. Between 0 and 10million. + * @param level - The level of the chart. Between 0 and 20, but this is not enforced. + */ +export function calculateVF5(score: integer, lamp: SDVXLamps, level: integer) { + AssertProvidedScore(score); + + const unroundedVF5 = CalculateWholeVF5(score, lamp, level); + + return Math.floor(unroundedVF5 / 10000) / 100; +} + +/** + * Given a VF5 value and a chart level, return what score is needed to get that + * VF5. + * + * If the score needed is greater than 10million, this function will throw. + * + * @param vf5 - The VF5 to invert. + * @param lamp - The lamp for this score. This is necessary to know, as lampCoefficient + * plays a part in VF5. + * @param level - The level of the chart you're inverting for. + */ +export function inverseVF5( + vf5: number, + // Exclude PUC as input. It doesn't make sense as input, since the answer would + // always be 10million. + lamp: Exclude, + level: integer, +) { + const score = InvertUnroundedVF5(vf5, lamp, level); + + ThrowIf(score === null, `A VF5 of ${vf5} is not possible on a chart with level ${level}.`, { + vf5, + level, + }); + + return score; +} + +/** + * Calculate VOLFORCE as it's defined in SDVX6. + * + * @param score - The user's score. Between 0 and 10million. + * @param level - The level of the chart. Between 0 and 20, but this is not enforced. + */ +export function calculateVF6(score: integer, lamp: SDVXLamps, level: integer) { + AssertProvidedScore(score); + + const unroundedVF5 = CalculateWholeVF5(score, lamp, level); + + // VF6 is just unroundedVF5 to 3 decimal places instead of 2. + return Math.floor(unroundedVF5 / 1000) / 1000; +} + +/** + * Given a VF6 value and a chart level, return what score is needed to get that + * VF5. + * + * If the score needed is greater than 10million, this function will throw. + ** + * @param vf6 - The VF6 to invert. + * @param lamp - The lamp for this score. This is necessary to know, as lampCoefficient + * plays a part in VF6. Passing "PERFECT ULTIMATE CHAIN" as a lamp is invalid, as inverting + * it into a score makes no sense. + * @param level - The level of the chart you're inverting for. + */ +export function inverseVF6( + vf6: number, + // Exclude PUC as input. It doesn't make sense as input, since the answer would + // always be 10million. + lamp: Exclude, + level: integer, +) { + // note: this function is actually identical to inverseVF5, but with the caveat + // that the error message is different. + + const score = InvertUnroundedVF5(vf6, lamp, level); + + ThrowIf(score === null, `A VF6 of ${vf6} is not possible on a chart with level ${level}.`, { + vf6, + level, + }); + + // guaranteed to not be null + return score!; +} + +/** + * Calculate VF5 without performing any rounding. This is useful because VF5 + * is floored to 2 decimal places, wherease VF6 is floored to 3. This lets us + * reuse the same algorithm. + */ +function CalculateWholeVF5(score: integer, lamp: SDVXLamps, level: integer) { + const grade = SDVXScoreToGrade(score); + + const gradeCoefficient = VF5GradeCoefficients[grade]; + const lampCoefficient = VF5LampCoefficients[lamp]; + + return level * 2 * (score / 10_000_000) * gradeCoefficient * lampCoefficient; +} + +/** + * Attempt to invert VF5 into a score. + * + * @returns The score if it was possible to be achieved. Else, it returns null. + */ +function InvertUnroundedVF5(vf5: number, lamp: SDVXLamps, level: integer) { + // Note: PERFECT ULTIMATE CHAIN is never passed into this function from typescript + // as the calling functions Exclude it from the lamps. + // However, a JS caller may call it like this anyway, so we mayaswell throw. + ThrowIf( + lamp === "PERFECT ULTIMATE CHAIN", + "PERFECT ULTIMATE CHAIN as a lampCoefficient does not make sense for an inversion, since the answer would always be 10million.", + { lamp }, + ); + + const lampCoefficient = VF5LampCoefficients[lamp]; + + const scoreTimesGradeCoef = (1_000_000 * 10_000_000 * vf5) / (2 * level * lampCoefficient); + + const score = AttemptGradeCoefficientDivide(scoreTimesGradeCoef, VF5GradeCoefficients); + + return score; +} + +/** + * Convert a SDVX percent to the grade it represents. + * @param score - The score to convert - between 0 and 10million. + * @returns A string representing a grade. + */ +function SDVXScoreToGrade(score: integer): SDVXGrades { + if (score < 7_000_000) { + return "D"; + } else if (score < 8_000_000) { + return "C"; + } else if (score < 8_700_000) { + return "B"; + } else if (score < 9_000_000) { + return "A"; + } else if (score < 9_300_000) { + return "A+"; + } else if (score < 9_500_000) { + return "AA"; + } else if (score < 9_700_000) { + return "AA+"; + } else if (score < 9_800_000) { + return "AAA"; + } else if (score < 9_900_000) { + return "AAA+"; + } + + return "S"; +} + +/** + * Given a SDVX grade, return the lower and upper bounds for scoring in this grade. + * This is used to invert the gradeCoefficient function in volforce. + * + * Bounds are returned as lower <= k < upper. + */ +function SDVXGetGradeBoundaries(grade: SDVXGrades): { lower: integer; upper: integer } { + if (grade === "S") { + return { lower: 9_900_000, upper: 10_000_000 }; + } else if (grade === "AAA+") { + return { lower: 9_800_000, upper: 9_900_000 }; + } else if (grade === "AAA") { + return { lower: 9_700_000, upper: 9_800_000 }; + } else if (grade === "AA+") { + return { lower: 9_500_000, upper: 9_700_000 }; + } else if (grade === "AA") { + return { lower: 9_300_000, upper: 9_500_000 }; + } else if (grade === "A+") { + return { lower: 9_000_000, upper: 9_300_000 }; + } else if (grade === "A") { + return { lower: 8_700_000, upper: 9_000_000 }; + } else if (grade === "B") { + return { lower: 8_000_000, upper: 8_700_000 }; + } else if (grade === "C") { + return { lower: 7_000_000, upper: 8_000_000 }; + } + + return { lower: 0, upper: 7_000_000 }; +} + +/** + * Assert necessary things about a provided score. + */ +function AssertProvidedScore(score: integer) { + ThrowIf(score > 10_000_000, "Score cannot be greater than 10million", { score }); + ThrowIf.negative(score, "Score cannot be negative", { score }); +} + +/** + * Go through all of the gradeBoundaries for a game and use them as guesses for score + * values. + * + * This means we try dividing by all the gradeCoefficients until we find one + * where the resulting score would have the same grade as the given coefficient. + * + * Used for inverting VF. + * + * @param scoreTimesGradeCoef - The expected score multiplied by the gradeCoefficient. + * @param coefficients - A record of SDVXGrade -> gradeCoefficient + * @returns The score divided by the gradeCoefficient. If not possible, this returns + * null. + */ +function AttemptGradeCoefficientDivide( + scoreTimesGradeCoef: number, + coefficients: Record, +) { + for (const [grade, gradeCoef] of GetEntriesAsArray(coefficients).reverse()) { + const maybeScore = scoreTimesGradeCoef / gradeCoef; + + const { lower, upper } = SDVXGetGradeBoundaries(grade); + + if (maybeScore <= lower) { + return lower; + } else if (maybeScore < upper || (maybeScore === upper && upper === 10_000_000)) { + return Math.round(maybeScore); + } + } + + return null; +} diff --git a/typescript/rg-stats/src/algorithms/wacca-rate.test.ts b/typescript/rg-stats/src/algorithms/wacca-rate.test.ts new file mode 100644 index 000000000..756ef0b73 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/wacca-rate.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "vitest"; +import { isAprx } from "../test-utils/approx"; +import { TestCase } from "../test-utils/test-case"; +import { expectThrowsSnapshot } from "../test-utils/throw-snapshot"; +import { integer } from "../util/types"; +import { calculate, calculatePlus, inverse, inversePlus } from "./wacca-rate"; + +test("WACCA Rate Tests", () => { + function MakeTestCase(score: integer, level: number, expectedRate: number): TestCase { + return () => + isAprx( + calculate(score, level), + expectedRate, + `A score of ${score} on a chart of level ${level} should be worth ${expectedRate} rate.`, + ); + } + + const testCases = [ + MakeTestCase(990_084, 13.2, 52.8), + MakeTestCase(984_040, 12.9, 48.375), + MakeTestCase(950_326, 12.2, 36.6), + MakeTestCase(997_719, 4, 16), + MakeTestCase(906_440, 13.8, 27.6), + + MakeTestCase(990_000, 10.2, 40.8), + MakeTestCase(980_000, 10.7, 40.125), + MakeTestCase(960_000, 9, 29.25), + MakeTestCase(950_000, 8, 24), + MakeTestCase(940_000, 8, 22), + MakeTestCase(920_000, 8, 20), + MakeTestCase(900_000, 8, 16), + MakeTestCase(850_000, 8, 12), + MakeTestCase(500, 10, 10), + MakeTestCase(0, 10, 10), + MakeTestCase(849_999, 10, 10), + ]; + + for (const testCase of testCases) { + testCase(); + } + + expect(calculate(975_000, 10), "970K and 975K should give identical rates.").toBe( + calculate(970_000, 10), + ); + + expect(calculate(1_000_000, 10), "990K and 1m should give identical rates.").toBe( + calculate(990_000, 10), + ); +}); + +test("WACCA Plus Rate Tests", () => { + function MakeTestCase(score: integer, level: number, expectedRate: number): TestCase { + return () => + isAprx( + calculatePlus(score, level), + expectedRate, + `A score of ${score} on a chart of level ${level} should be worth ${expectedRate} rate.`, + ); + } + + const testCases = [ + MakeTestCase(997_719, 4, 16.2), + MakeTestCase(993_779, 13.2, 53.196), + MakeTestCase(985_040, 12.9, 49.9875), + MakeTestCase(968_374, 13.6, 45.9), + MakeTestCase(950_326, 12.2, 36.6), + MakeTestCase(906_440, 13.8, 27.6), + + MakeTestCase(995_000, 10.2, 41.31), + MakeTestCase(994_000, 10.2, 41.208), + MakeTestCase(993_000, 10.2, 41.106), + MakeTestCase(992_000, 10.2, 41.004), + MakeTestCase(991_000, 10.2, 40.902), + MakeTestCase(990_000, 10.2, 40.8), + MakeTestCase(985_000, 10.7, 41.4625), + MakeTestCase(980_000, 10.7, 40.125), + MakeTestCase(960_000, 9, 29.25), + MakeTestCase(955_000, 8, 25), + MakeTestCase(950_000, 8, 24), + MakeTestCase(940_000, 8, 22), + MakeTestCase(920_000, 8, 20), + MakeTestCase(900_000, 8, 16), + MakeTestCase(850_000, 8, 12), + MakeTestCase(849_999, 10, 10), + MakeTestCase(500, 10, 10), + MakeTestCase(0, 10, 10), + ]; + + for (const testCase of testCases) { + testCase(); + } + + expect(calculatePlus(975_000, 10), "970K and 975K should NOT give identical rates.").not.toBe( + calculatePlus(970_000, 10), + ); + + expect(calculatePlus(1_000_000, 10), "990K and 1m should NOT give identical rates.").not.toBe( + calculatePlus(990_000, 10), + ); +}); + +test("WACCA Rate Validation Tests", () => { + expectThrowsSnapshot(() => calculate(-1, 10), "Should throw if score is negative."); + expectThrowsSnapshot( + () => calculate(1_000_001, 10), + "Should throw if score is greater than 1million.", + ); + expectThrowsSnapshot(() => calculate(900_000, -1), "Should throw if level is negative."); + expectThrowsSnapshot(() => calculatePlus(-1, 10), "Should throw if score is negative."); + expectThrowsSnapshot( + () => calculatePlus(1_000_001, 10), + "Should throw if score is greater than 1million.", + ); + expectThrowsSnapshot(() => calculatePlus(900_000, -1), "Should throw if level is negative."); +}); + +test("WACCA Inverse Rate Tests", () => { + function MakeTestCase(expectedScore: integer, level: number, rate: number): TestCase { + return () => + isAprx( + inverse(rate, level), + expectedScore, + `A rate of ${rate} on a chart of level ${level} should invert to ${expectedScore} rate.`, + ); + } + + const testCases = [ + MakeTestCase(990_000, 13.2, 52.8), + MakeTestCase(980_000, 12.9, 48.375), + MakeTestCase(950_000, 12.2, 36.6), + MakeTestCase(970_000, 12.2, 40), + MakeTestCase(990_000, 4, 16), + MakeTestCase(900_000, 13.8, 27.6), + MakeTestCase(990_000, 10.2, 40.8), + MakeTestCase(980_000, 10.7, 40.125), + MakeTestCase(960_000, 9, 29.25), + MakeTestCase(950_000, 8, 24), + MakeTestCase(940_000, 8, 22), + MakeTestCase(920_000, 8, 20), + MakeTestCase(900_000, 8, 16), + MakeTestCase(850_000, 8, 12), + MakeTestCase(0, 10, 10), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("WACCA Plus Inverse Rate Tests", () => { + function MakeTestCase(expectedScore: integer, level: number, rate: number): TestCase { + return () => + isAprx( + inversePlus(rate, level), + expectedScore, + `A rate of ${rate} on a chart of level ${level} should invert to ${expectedScore} rate.`, + ); + } + + const testCases = [ + MakeTestCase(995_000, 4, 16.2), + MakeTestCase(994_000, 4, 16.16), + MakeTestCase(993_000, 4, 16.12), + MakeTestCase(993_000, 13.2, 53.196), + MakeTestCase(992_000, 4, 16.08), + MakeTestCase(991_000, 4, 16.04), + MakeTestCase(990_000, 4, 16), + MakeTestCase(990_000, 13.2, 52.8), + MakeTestCase(990_000, 10.2, 40.8), + MakeTestCase(985_000, 12.9, 49.9875), + MakeTestCase(985_000, 10.7, 41.4625), + MakeTestCase(980_000, 12.9, 48.375), + MakeTestCase(980_000, 10.7, 40.125), + MakeTestCase(975_000, 8, 28.5), + MakeTestCase(970_000, 8, 27.5), + MakeTestCase(965_000, 13.6, 45.9), + MakeTestCase(965_000, 12.2, 40), + MakeTestCase(960_000, 9, 29.25), + MakeTestCase(955_000, 8, 25), + MakeTestCase(950_000, 8, 24), + MakeTestCase(950_000, 12.2, 36.6), + MakeTestCase(950_000, 12.2, 36.6), + MakeTestCase(940_000, 8, 22), + MakeTestCase(920_000, 8, 20), + MakeTestCase(900_000, 8, 16), + MakeTestCase(900_000, 13.8, 27.6), + MakeTestCase(900_000, 13.8, 27.6), + MakeTestCase(850_000, 8, 12), + MakeTestCase(0, 10, 10), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("WACCA Inverse Rate Validation Tests", () => { + expectThrowsSnapshot(() => inverse(50, -1), "Should throw if level is negative."); + expectThrowsSnapshot(() => inversePlus(50, -1), "Should throw if level is negative."); + expectThrowsSnapshot(() => inverse(100, 1), "Should throw if rate is impossible."); + expectThrowsSnapshot(() => inversePlus(100, 1), "Should throw if rate is impossible."); +}); diff --git a/typescript/rg-stats/src/algorithms/wacca-rate.ts b/typescript/rg-stats/src/algorithms/wacca-rate.ts new file mode 100644 index 000000000..aec4d2dc8 --- /dev/null +++ b/typescript/rg-stats/src/algorithms/wacca-rate.ts @@ -0,0 +1,219 @@ +import { RoundToNDP } from "../util/math"; +import { ThrowIf } from "../util/throw-if"; +import { integer } from "../util/types"; + +/** + * Calculates WACCAs rate for a score. + * This is accurate as of Reverse. + * + * @param score - The score to calculate the rate for. + * @param internalChartLevel - The internal decimal level of the chart the score was achieved on. + */ +export function calculate(score: integer, internalChartLevel: number) { + ThrowIf(score > 1_000_000, "Score cannot be greater than 1million.", { score }); + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { + level: internalChartLevel, + }); + + let scoreCoef = 1; + + // Yes, this is really how it works. + if (score >= 990_000) { + scoreCoef = 4; + } else if (score >= 980_000) { + scoreCoef = 3.75; + } else if (score >= 970_000) { + scoreCoef = 3.5; + } else if (score >= 960_000) { + scoreCoef = 3.25; + } else if (score >= 950_000) { + scoreCoef = 3; + } else if (score >= 940_000) { + scoreCoef = 2.75; + } else if (score >= 920_000) { + scoreCoef = 2.5; + } else if (score >= 900_000) { + scoreCoef = 2; + } else if (score >= 850_000) { + scoreCoef = 1.5; + } + + return RoundToNDP(scoreCoef * internalChartLevel, 3); +} + +/** + * Calculates WACCAs rate for a score. + * This is accurate for WACCA Plus as of the August 2025 update. + * + * @param score - The score to calculate the rate for. + * @param internalChartLevel - The internal decimal level of the chart the score was achieved on. + */ +export function calculatePlus(score: integer, internalChartLevel: number) { + ThrowIf(score > 1_000_000, "Score cannot be greater than 1million.", { score }); + ThrowIf.negative(score, "Score cannot be negative.", { score }); + ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { + level: internalChartLevel, + }); + + let scoreCoef = 1; + + // Yes, this is really how it works. + if (score >= 995_000) { + scoreCoef = 4.05; + } else if (score >= 994_000) { + scoreCoef = 4.04; + } else if (score >= 993_000) { + scoreCoef = 4.03; + } else if (score >= 992_000) { + scoreCoef = 4.02; + } else if (score >= 991_000) { + scoreCoef = 4.01; + } else if (score >= 990_000) { + scoreCoef = 4; + } else if (score >= 985_000) { + scoreCoef = 3.875; + } else if (score >= 980_000) { + scoreCoef = 3.75; + } else if (score >= 975_000) { + scoreCoef = 3.625; + } else if (score >= 970_000) { + scoreCoef = 3.5; + } else if (score >= 965_000) { + scoreCoef = 3.375; + } else if (score >= 960_000) { + scoreCoef = 3.25; + } else if (score >= 955_000) { + scoreCoef = 3.125; + } else if (score >= 950_000) { + scoreCoef = 3; + } else if (score >= 940_000) { + scoreCoef = 2.75; + } else if (score >= 920_000) { + scoreCoef = 2.5; + } else if (score >= 900_000) { + scoreCoef = 2; + } else if (score >= 850_000) { + scoreCoef = 1.5; + } + + return RoundToNDP(scoreCoef * internalChartLevel, 3); +} + +/** + * Given a WACCA rate and a chart level, return the minimum score necessary to get + * that rate. + * This is accurate as of Reverse. + * + * @param rate - The rate to inverse + * @param internalChartLevel - The internal decimal level of the chart the rate was on. + */ +export function inverse(rate: number, internalChartLevel: number) { + ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { + level: internalChartLevel, + }); + + // I know it seems arbitrary to round this to 4dp, but the issue is that + // obviously correct things like 36.6/12.2 end up as 3.0000[...]4, which + // causes this alg to fail. + const scoreCoef = RoundToNDP(rate / internalChartLevel, 4); + + ThrowIf( + scoreCoef > 4, + `A rate of ${rate} is not possible on a chart of level ${internalChartLevel}.`, + { + rate, + level: internalChartLevel, + }, + ); + + if (scoreCoef > 3.75) { + return 990_000; + } else if (scoreCoef > 3.5) { + return 980_000; + } else if (scoreCoef > 3.25) { + return 970_000; + } else if (scoreCoef > 3) { + return 960_000; + } else if (scoreCoef > 2.75) { + return 950_000; + } else if (scoreCoef > 2.5) { + return 940_000; + } else if (scoreCoef > 2) { + return 920_000; + } else if (scoreCoef > 1.5) { + return 900_000; + } else if (scoreCoef > 1) { + return 850_000; + } + + return 0; +} + +/** + * Given a WACCA rate and a chart level, return the minimum score necessary to get + * that rate. + * This is accurate for WACCA Plus as of the August 2025 update. + * + * @param rate - The rate to inverse + * @param internalChartLevel - The internal decimal level of the chart the rate was on. + */ +export function inversePlus(rate: number, internalChartLevel: number) { + ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { + level: internalChartLevel, + }); + + // I know it seems arbitrary to round this to 4dp, but the issue is that + // obviously correct things like 36.6/12.2 end up as 3.0000[...]4, which + // causes this alg to fail. + const scoreCoef = RoundToNDP(rate / internalChartLevel, 4); + + ThrowIf( + scoreCoef > 4.05, + `A rate of ${rate} is not possible on a chart of level ${internalChartLevel}.`, + { + rate, + level: internalChartLevel, + }, + ); + + if (scoreCoef > 4.04) { + return 995_000; + } else if (scoreCoef > 4.03) { + return 994_000; + } else if (scoreCoef > 4.02) { + return 993_000; + } else if (scoreCoef > 4.01) { + return 992_000; + } else if (scoreCoef > 4) { + return 991_000; + } else if (scoreCoef > 3.875) { + return 990_000; + } else if (scoreCoef > 3.75) { + return 985_000; + } else if (scoreCoef > 3.625) { + return 980_000; + } else if (scoreCoef > 3.5) { + return 975_000; + } else if (scoreCoef > 3.375) { + return 970_000; + } else if (scoreCoef > 3.25) { + return 965_000; + } else if (scoreCoef > 3.125) { + return 960_000; + } else if (scoreCoef > 3) { + return 955_000; + } else if (scoreCoef > 2.75) { + return 950_000; + } else if (scoreCoef > 2.5) { + return 940_000; + } else if (scoreCoef > 2) { + return 920_000; + } else if (scoreCoef > 1.5) { + return 900_000; + } else if (scoreCoef > 1) { + return 850_000; + } + + return 0; +} diff --git a/typescript/rg-stats/src/index.ts b/typescript/rg-stats/src/index.ts new file mode 100644 index 000000000..41e3a3f39 --- /dev/null +++ b/typescript/rg-stats/src/index.ts @@ -0,0 +1,14 @@ +export * as PoyashiBPI from "./algorithms/poyashi-bpi"; +export * as CHUNITHMRating from "./algorithms/chunithm-rating"; +export * as Jubility from "./algorithms/jubility"; +export * as Volforce from "./algorithms/volforce"; +export * as WACCARate from "./algorithms/wacca-rate"; +export * as GITADORASkill from "./algorithms/gitadora-skill"; +export * as PopnClassPoints from "./algorithms/popn-classpoints"; +export * as ITGHighestUnbroken from "./algorithms/itg-highest-unbroken"; +export * as MaimaiDXRate from "./algorithms/maimaidx-rate"; +export * as CuratorSkill from "./algorithms/curator-skill"; +export * as MaimaiRate from "./algorithms/maimai-rate"; +export * as Potential from "./algorithms/potential"; +export * as ONGEKIRating from "./algorithms/ongeki-rating"; +export * as DDRFlare from "./algorithms/ddr-flare"; diff --git a/typescript/rg-stats/src/test-utils/approx.ts b/typescript/rg-stats/src/test-utils/approx.ts new file mode 100644 index 000000000..30bb9e0da --- /dev/null +++ b/typescript/rg-stats/src/test-utils/approx.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ + +import { expect } from "vitest"; + +/** + * Tests whether a number is approximately equal to another number. + */ +export function isAprx(value: number, expected: number, msg: string, decimalPlaces = 2) { + const lim = 1 / 10 ** decimalPlaces; + + const aprx = Math.abs(value - expected) < lim; + + expect(aprx, `${msg} Got ${value}, Expected ${expected}.`).toBe(true); +} diff --git a/typescript/rg-stats/src/test-utils/test-case.ts b/typescript/rg-stats/src/test-utils/test-case.ts new file mode 100644 index 000000000..30cd69599 --- /dev/null +++ b/typescript/rg-stats/src/test-utils/test-case.ts @@ -0,0 +1 @@ +export type TestCase = () => void; diff --git a/typescript/rg-stats/src/test-utils/throw-snapshot.ts b/typescript/rg-stats/src/test-utils/throw-snapshot.ts new file mode 100644 index 000000000..eebc13488 --- /dev/null +++ b/typescript/rg-stats/src/test-utils/throw-snapshot.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ + +import { expect } from "vitest"; + +export function expectThrowsSnapshot(fn: () => unknown, message: string) { + try { + fn(); + + expect.fail(`DID NOT THROW: ${message}`); + } catch (e: unknown) { + const err = e as Error; + expect(err.message).toMatchSnapshot(message); + } +} diff --git a/typescript/rg-stats/src/util/math.ts b/typescript/rg-stats/src/util/math.ts new file mode 100644 index 000000000..388394be5 --- /dev/null +++ b/typescript/rg-stats/src/util/math.ts @@ -0,0 +1,28 @@ +import { integer } from "./types"; + +/** + * Logarithm a number to any base. + * @param number - The number to logarithm. + * @param base - The base to log to. + * + * Uses this identity: + * loga(n) = logb(n) / logb(a) + * we use logb = ln, just because it's right there. + */ +export function LogToBase(number: number, base: number) { + return Math.log(number) / Math.log(base); +} + +/** + * Round a number to N decimal places. + * + * @example `RoundToNDP(1.594, 1) -> 1.6` + * @example `RoundToNDP(1.591, 2) -> 1.69` + * + * @param number - The number to round. + * @param dp - The amount of decimal places to round to. + */ +export function RoundToNDP(number: number, dp: integer) { + const mul = 10 ** dp; + return Math.round(number * mul) / mul; +} diff --git a/typescript/rg-stats/src/util/misc.ts b/typescript/rg-stats/src/util/misc.ts new file mode 100644 index 000000000..5ad9418e3 --- /dev/null +++ b/typescript/rg-stats/src/util/misc.ts @@ -0,0 +1,18 @@ +export function* GetEntries(r: Record): Generator<[K, V]> { + for (const key in r) { + yield [key, r[key]]; + } +} + +export function GetEntriesAsArray(r: Record): [K, V][] { + return [...GetEntries(r)]; +} + +export function RepeatNTimes(value: T, n: number): Array { + const arr = []; + for (let i = 0; i < n; i++) { + arr.push(value); + } + + return arr; +} diff --git a/typescript/rg-stats/src/util/options.ts b/typescript/rg-stats/src/util/options.ts new file mode 100644 index 000000000..b4c6603e5 --- /dev/null +++ b/typescript/rg-stats/src/util/options.ts @@ -0,0 +1,14 @@ +/** + * Merge provided options with a set of defaults. + * @param userOptions - The user given options. + * @param defaultOptions - Defaults to set if the user hasn't provided a value for + * that option. + * + * @returns An options object that has no missing keys. + */ +export function MergeOptionsWithDefaults( + userOptions: Partial, + defaultOptions: T, +): T { + return Object.assign({}, defaultOptions, userOptions); +} diff --git a/typescript/rg-stats/src/util/throw-if.test.ts b/typescript/rg-stats/src/util/throw-if.test.ts new file mode 100644 index 000000000..d07db7991 --- /dev/null +++ b/typescript/rg-stats/src/util/throw-if.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "vitest"; +import { TestCase } from "../test-utils/test-case"; +import { ErrVars, ThrowIf } from "./throw-if"; + +type MockThrowFn = (condition: C, errMsg: string, errVars: ErrVars) => void; + +test("ThrowIf Tests", () => { + function ShouldThrow(throwIfFn: MockThrowFn, condition: C): TestCase { + return () => { + expect( + () => throwIfFn(condition, "Some Error Message.", { foo: 2, bar: "baz" }), + `${throwIfFn.name} (cond:${condition}) Should throw an error with a pretty message.`, + ).toThrow(/^Invalid input, Some Error Message\. foo=2, bar="baz"\.$/u); + expect( + () => throwIfFn(condition, "Some Error Message", { foo: 2, bar: "baz" }), + `${throwIfFn.name} (cond:${condition}) Should add a full stop after the error message if one was accidentally forgotten.`, + ).toThrow(/^Invalid input, Some Error Message\. foo=2, bar="baz"\.$/u); + }; + } + + function ShouldNotThrow(throwIfFn: MockThrowFn, condition: C): TestCase { + return () => { + expect( + () => throwIfFn(condition, "Some Error Message.", { foo: 2, bar: "baz" }), + `${throwIfFn.name} (cond:${condition}) Should not throw an error.`, + ).not.toThrow(); + }; + } + + const testCases: TestCase[] = [ + ShouldThrow(ThrowIf, true), + ShouldThrow(ThrowIf.not, false), + ShouldThrow(ThrowIf.negative, -1), + ShouldThrow(ThrowIf.positive, 1), + ShouldThrow(ThrowIf.positiveOrZero, 1), + ShouldThrow(ThrowIf.positiveOrZero, 0), + ShouldThrow(ThrowIf.negativeOrZero, -1), + ShouldThrow(ThrowIf.negativeOrZero, 0), + ShouldThrow(ThrowIf.zero, 0), + ShouldNotThrow(ThrowIf, false), + ShouldNotThrow(ThrowIf.not, true), + ShouldNotThrow(ThrowIf.positive, -1), + ShouldNotThrow(ThrowIf.positive, 0), + ShouldNotThrow(ThrowIf.negative, 1), + ShouldNotThrow(ThrowIf.negative, 0), + ShouldNotThrow(ThrowIf.positiveOrZero, -1), + ShouldNotThrow(ThrowIf.negativeOrZero, 1), + ShouldNotThrow(ThrowIf.zero, 1), + ShouldNotThrow(ThrowIf.zero, -1), + ]; + + for (const testCase of testCases) { + testCase(); + } +}); + +test("ThrowIf Formatting Edge Cases", () => { + expect( + () => + ThrowIf(true, "msg", { + foo: "1", + bar: 1, + }), + "Should differentiate strings from numbers.", + ).toThrow(/^Invalid input, msg\. foo="1", bar=1\.$/u); + + expect( + () => + ThrowIf(true, "msg", { + foo: "1, bar=1", + }), + "Should keep string values inside strings.", + ).toThrow(/^Invalid input, msg\. foo="1, bar=1"\.$/u); + + expect( + () => + ThrowIf(true, "msg", { + foo: '1", bar=1', + }), + "Should escape quotes to not allow breaking out.", + ).toThrow(/^Invalid input, msg\. foo="1\\", bar=1"\.$/u); +}); diff --git a/typescript/rg-stats/src/util/throw-if.ts b/typescript/rg-stats/src/util/throw-if.ts new file mode 100644 index 000000000..d549ffe36 --- /dev/null +++ b/typescript/rg-stats/src/util/throw-if.ts @@ -0,0 +1,59 @@ +/** + * An object of variables to print if the predicate fails. + */ +export type ErrVars = Record; + +/** + * Formats the errVars argument into a pretty string. + */ +function FormatErrVars(errVars: ErrVars) { + const vars = []; + + for (const [key, value] of Object.entries(errVars)) { + if (typeof value === "string") { + // We have to replace "'s here with \" so that the output + // doesn't get scrambled for certain maliciously crafted + // strings. + vars.push(`${key}="${value.replace(/"/gu, '\\"')}"`); + } else { + vars.push(`${key}=${value}`); + } + } + + return vars.join(", "); +} + +/** + * Throw if the condition is not true. + * + * @param condition - If false, throw a prettified error, + * @param errMsg - The error message to display. + * @param errVars - Variables to format and print out on failure. + */ +export function ThrowIf(condition: boolean, errMsg: string, errVars: ErrVars) { + if (condition) { + // Add a full stop incase we forget one. + if (errMsg[errMsg.length - 1] !== ".") { + errMsg += "."; + } + throw new Error(`Invalid input, ${errMsg} ${FormatErrVars(errVars)}.`); + } +} + +ThrowIf.not = (value: boolean, errMsg: string, errVars: ErrVars) => + ThrowIf(!value, errMsg, errVars); + +ThrowIf.negative = (value: number, errMsg: string, errVars: ErrVars) => + ThrowIf(value < 0, errMsg, errVars); + +ThrowIf.positive = (value: number, errMsg: string, errVars: ErrVars) => + ThrowIf(value > 0, errMsg, errVars); + +ThrowIf.positiveOrZero = (value: number, errMsg: string, errVars: ErrVars) => + ThrowIf(value >= 0, errMsg, errVars); + +ThrowIf.negativeOrZero = (value: number, errMsg: string, errVars: ErrVars) => + ThrowIf(value <= 0, errMsg, errVars); + +ThrowIf.zero = (value: number, errMsg: string, errVars: ErrVars) => + ThrowIf(value === 0, errMsg, errVars); diff --git a/typescript/rg-stats/src/util/types.ts b/typescript/rg-stats/src/util/types.ts new file mode 100644 index 000000000..c8a30a581 --- /dev/null +++ b/typescript/rg-stats/src/util/types.ts @@ -0,0 +1,4 @@ +// An alias for number that makes the code self-documenting. This doesn't actually do anything. + +// But it does look neater. +export type integer = number; diff --git a/typescript/rg-stats/test-data/ongeki-rating.json b/typescript/rg-stats/test-data/ongeki-rating.json new file mode 100644 index 000000000..0cf703f32 --- /dev/null +++ b/typescript/rg-stats/test-data/ongeki-rating.json @@ -0,0 +1,956 @@ +{ + "ratingRefresh": [ + { + "technicalScore": 980222, + "internalChartLevel": 11.4, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.783 + }, + { + "technicalScore": 1002264, + "internalChartLevel": 10, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.6 + }, + { + "technicalScore": 1004423, + "internalChartLevel": 9.7, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 11.594 + }, + { + "technicalScore": 1003820, + "internalChartLevel": 9.7, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 11.554 + }, + { + "technicalScore": 984237, + "internalChartLevel": 11, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.533 + }, + { + "technicalScore": 971413, + "internalChartLevel": 11.4, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.502 + }, + { + "technicalScore": 1002809, + "internalChartLevel": 9.7, + "noteLamp": "FULL COMBO", + "fullBell": false, + "expectedRating": 11.437 + }, + { + "technicalScore": 992482, + "internalChartLevel": 10.4, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.424 + }, + { + "technicalScore": 992903, + "internalChartLevel": 10.4, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.395 + }, + { + "technicalScore": 960709, + "internalChartLevel": 11.9, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.369 + }, + { + "technicalScore": 992390, + "internalChartLevel": 10.4, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.369 + }, + { + "technicalScore": 974221, + "internalChartLevel": 11.2, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.358 + }, + { + "technicalScore": 1003051, + "internalChartLevel": 9.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.353 + }, + { + "technicalScore": 1000644, + "internalChartLevel": 9.7, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 11.342 + }, + { + "technicalScore": 1000092, + "internalChartLevel": 9.7, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 11.306 + }, + { + "technicalScore": 1007462, + "internalChartLevel": 9, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 11.297 + }, + { + "technicalScore": 1001326, + "internalChartLevel": 9.7, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.288 + }, + { + "technicalScore": 990337, + "internalChartLevel": 10.4, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.266 + }, + { + "technicalScore": 1000990, + "internalChartLevel": 9.7, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.266 + }, + { + "technicalScore": 976624, + "internalChartLevel": 11, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.248 + }, + { + "technicalScore": 1000651, + "internalChartLevel": 9.7, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.243 + }, + { + "technicalScore": 1006617, + "internalChartLevel": 9, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 11.241 + }, + { + "technicalScore": 966927, + "internalChartLevel": 11.4, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.224 + }, + { + "technicalScore": 983431, + "internalChartLevel": 10.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.203 + }, + { + "technicalScore": 996527, + "internalChartLevel": 10, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.176 + }, + { + "technicalScore": 1003522, + "internalChartLevel": 9.4, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.134 + }, + { + "technicalScore": 989383, + "internalChartLevel": 10.4, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.126 + }, + { + "technicalScore": 981071, + "internalChartLevel": 10.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.115 + }, + { + "technicalScore": 947421, + "internalChartLevel": 12.4, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.109 + }, + { + "technicalScore": 987120, + "internalChartLevel": 10.4, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.092 + }, + { + "technicalScore": 1001221, + "internalChartLevel": 9.4, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 11.081 + }, + { + "technicalScore": 994489, + "internalChartLevel": 10, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.074 + }, + { + "technicalScore": 993276, + "internalChartLevel": 10, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.063 + }, + { + "technicalScore": 999451, + "internalChartLevel": 9.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 11.022 + }, + { + "technicalScore": 969473, + "internalChartLevel": 11, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.019 + }, + { + "technicalScore": 1001567, + "internalChartLevel": 9.4, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11.004 + }, + { + "technicalScore": 992019, + "internalChartLevel": 10, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 11 + }, + { + "technicalScore": 977810, + "internalChartLevel": 10.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 10.992 + }, + { + "technicalScore": 1000351, + "internalChartLevel": 9.4, + "noteLamp": "FULL COMBO", + "fullBell": false, + "expectedRating": 10.973 + }, + { + "technicalScore": 992267, + "internalChartLevel": 10, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 10.963 + }, + { + "technicalScore": 997824, + "internalChartLevel": 9.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 10.941 + }, + { + "technicalScore": 975952, + "internalChartLevel": 10.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 10.923 + }, + { + "technicalScore": 997408, + "internalChartLevel": 9.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 10.92 + }, + { + "technicalScore": 968530, + "internalChartLevel": 11, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 10.916 + }, + { + "technicalScore": 990245, + "internalChartLevel": 10, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 10.912 + }, + { + "technicalScore": 999864, + "internalChartLevel": 9.4, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 10.893 + }, + { + "technicalScore": 999736, + "internalChartLevel": 9.4, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 10.886 + }, + { + "technicalScore": 990642, + "internalChartLevel": 10, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 10.882 + }, + { + "technicalScore": 1004980, + "internalChartLevel": 9, + "noteLamp": "FULL COMBO", + "fullBell": false, + "expectedRating": 10.882 + }, + { + "technicalScore": 996328, + "internalChartLevel": 9.7, + "noteLamp": "CLEAR", + "fullBell": false, + "expectedRating": 10.866 + }, + { + "technicalScore": 1009980, + "internalChartLevel": 14.7, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 17.348 + }, + { + "technicalScore": 1008800, + "internalChartLevel": 14.7, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 17.23 + }, + { + "technicalScore": 1007777, + "internalChartLevel": 15, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 17.127 + }, + { + "technicalScore": 1007880, + "internalChartLevel": 14.4, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.838 + }, + { + "technicalScore": 1007869, + "internalChartLevel": 14.3, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.736 + }, + { + "technicalScore": 1008463, + "internalChartLevel": 14.2, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.696 + }, + { + "technicalScore": 1000246, + "internalChartLevel": 15.1, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.616 + }, + { + "technicalScore": 1007656, + "internalChartLevel": 14.4, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.615 + }, + { + "technicalScore": 1007413, + "internalChartLevel": 14.3, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.594 + }, + { + "technicalScore": 1007075, + "internalChartLevel": 14.5, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.571 + }, + { + "technicalScore": 1008110, + "internalChartLevel": 14.1, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.561 + }, + { + "technicalScore": 1008009, + "internalChartLevel": 14.1, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.55 + }, + { + "technicalScore": 1006747, + "internalChartLevel": 14.5, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.549 + }, + { + "technicalScore": 1002172, + "internalChartLevel": 14.9, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.544 + }, + { + "technicalScore": 1005089, + "internalChartLevel": 14.6, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.539 + }, + { + "technicalScore": 1003464, + "internalChartLevel": 14.8, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.53 + }, + { + "technicalScore": 1006444, + "internalChartLevel": 14.5, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.529 + }, + { + "technicalScore": 1007774, + "internalChartLevel": 14.3, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.527 + }, + { + "technicalScore": 1008633, + "internalChartLevel": 14, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.513 + }, + { + "technicalScore": 1003156, + "internalChartLevel": 14.7, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.51 + }, + { + "technicalScore": 1005940, + "internalChartLevel": 14.5, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.496 + }, + { + "technicalScore": 1005487, + "internalChartLevel": 14.6, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.465 + }, + { + "technicalScore": 1003727, + "internalChartLevel": 14.6, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.448 + }, + { + "technicalScore": 1003733, + "internalChartLevel": 14.7, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.448 + }, + { + "technicalScore": 1006570, + "internalChartLevel": 14.5, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.438 + }, + { + "technicalScore": 1007887, + "internalChartLevel": 14.2, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.438 + }, + { + "technicalScore": 1008777, + "internalChartLevel": 13.9, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.427 + }, + { + "technicalScore": 1001877, + "internalChartLevel": 14.8, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.425 + }, + { + "technicalScore": 1001888, + "internalChartLevel": 14.7, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.425 + }, + { + "technicalScore": 1003302, + "internalChartLevel": 14.7, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.42 + }, + { + "technicalScore": 1003102, + "internalChartLevel": 14.7, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.406 + }, + { + "technicalScore": 1004599, + "internalChartLevel": 14.6, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.406 + }, + { + "technicalScore": 1006028, + "internalChartLevel": 14.5, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.401 + }, + { + "technicalScore": 1008346, + "internalChartLevel": 13.9, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.384 + }, + { + "technicalScore": 1004172, + "internalChartLevel": 14.6, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.378 + }, + { + "technicalScore": 993361, + "internalChartLevel": 15.3, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.368 + }, + { + "technicalScore": 1002406, + "internalChartLevel": 14.7, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.36 + }, + { + "technicalScore": 1003877, + "internalChartLevel": 14.5, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.358 + }, + { + "technicalScore": 1008069, + "internalChartLevel": 13.9, + "noteLamp": "ALL BREAK", + "fullBell": true, + "expectedRating": 16.356 + }, + { + "technicalScore": 1009056, + "internalChartLevel": 14, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.355 + }, + { + "technicalScore": 1002252, + "internalChartLevel": 14.6, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.35 + }, + { + "technicalScore": 1006711, + "internalChartLevel": 14.3, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.347 + }, + { + "technicalScore": 1007840, + "internalChartLevel": 14.1, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.334 + }, + { + "technicalScore": 1003489, + "internalChartLevel": 14.5, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.332 + }, + { + "technicalScore": 1007809, + "internalChartLevel": 14.1, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.33 + }, + { + "technicalScore": 1006379, + "internalChartLevel": 14.4, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.325 + }, + { + "technicalScore": 1004803, + "internalChartLevel": 14.5, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.32 + }, + { + "technicalScore": 1004767, + "internalChartLevel": 14.4, + "noteLamp": "FULL COMBO", + "fullBell": true, + "expectedRating": 16.317 + }, + { + "technicalScore": 1003181, + "internalChartLevel": 14.6, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.312 + }, + { + "technicalScore": 1003186, + "internalChartLevel": 14.6, + "noteLamp": "CLEAR", + "fullBell": true, + "expectedRating": 16.312 + } + ], + "ratingPlatinum": [ + { + "internalChartLevel": 14.7, + "stars": 6, + "expectedRating": 1.08 + }, + { + "internalChartLevel": 12.8, + "stars": 5, + "expectedRating": 0.819 + }, + { + "internalChartLevel": 11.9, + "stars": 5, + "expectedRating": 0.708 + }, + { + "internalChartLevel": 13.3, + "stars": 4, + "expectedRating": 0.707 + }, + { + "internalChartLevel": 13, + "stars": 4, + "expectedRating": 0.676 + }, + { + "internalChartLevel": 13, + "stars": 4, + "expectedRating": 0.676 + }, + { + "internalChartLevel": 12.9, + "stars": 4, + "expectedRating": 0.665 + }, + { + "internalChartLevel": 12.8, + "stars": 4, + "expectedRating": 0.655 + }, + { + "internalChartLevel": 14.7, + "stars": 3, + "expectedRating": 0.648 + }, + { + "internalChartLevel": 12.7, + "stars": 4, + "expectedRating": 0.645 + }, + { + "internalChartLevel": 12.7, + "stars": 4, + "expectedRating": 0.645 + }, + { + "internalChartLevel": 12.7, + "stars": 4, + "expectedRating": 0.645 + }, + { + "internalChartLevel": 12.6, + "stars": 4, + "expectedRating": 0.635 + }, + { + "internalChartLevel": 12.5, + "stars": 4, + "expectedRating": 0.625 + }, + { + "internalChartLevel": 14, + "stars": 3, + "expectedRating": 0.588 + }, + { + "internalChartLevel": 13.4, + "stars": 3, + "expectedRating": 0.538 + }, + { + "internalChartLevel": 13.4, + "stars": 3, + "expectedRating": 0.538 + }, + { + "internalChartLevel": 13.1, + "stars": 3, + "expectedRating": 0.514 + }, + { + "internalChartLevel": 13.1, + "stars": 3, + "expectedRating": 0.514 + }, + { + "internalChartLevel": 13, + "stars": 3, + "expectedRating": 0.507 + }, + { + "internalChartLevel": 13, + "stars": 3, + "expectedRating": 0.507 + }, + { + "internalChartLevel": 13, + "stars": 3, + "expectedRating": 0.507 + }, + { + "internalChartLevel": 11.2, + "stars": 4, + "expectedRating": 0.501 + }, + { + "internalChartLevel": 12.9, + "stars": 3, + "expectedRating": 0.499 + }, + { + "internalChartLevel": 12.9, + "stars": 3, + "expectedRating": 0.499 + }, + { + "internalChartLevel": 12.9, + "stars": 3, + "expectedRating": 0.499 + }, + { + "internalChartLevel": 12.9, + "stars": 3, + "expectedRating": 0.499 + }, + { + "internalChartLevel": 12.9, + "stars": 3, + "expectedRating": 0.499 + }, + { + "internalChartLevel": 12.9, + "stars": 3, + "expectedRating": 0.499 + }, + { + "internalChartLevel": 12.9, + "stars": 3, + "expectedRating": 0.499 + }, + { + "internalChartLevel": 12.8, + "stars": 3, + "expectedRating": 0.491 + }, + { + "internalChartLevel": 12.8, + "stars": 3, + "expectedRating": 0.491 + }, + { + "internalChartLevel": 12.8, + "stars": 3, + "expectedRating": 0.491 + }, + { + "internalChartLevel": 12.8, + "stars": 3, + "expectedRating": 0.491 + }, + { + "internalChartLevel": 12.8, + "stars": 3, + "expectedRating": 0.491 + }, + { + "internalChartLevel": 12.8, + "stars": 3, + "expectedRating": 0.491 + }, + { + "internalChartLevel": 12.8, + "stars": 3, + "expectedRating": 0.491 + }, + { + "internalChartLevel": 12.7, + "stars": 3, + "expectedRating": 0.483 + }, + { + "internalChartLevel": 12.7, + "stars": 3, + "expectedRating": 0.483 + }, + { + "internalChartLevel": 12.7, + "stars": 3, + "expectedRating": 0.483 + }, + { + "internalChartLevel": 12.6, + "stars": 3, + "expectedRating": 0.476 + }, + { + "internalChartLevel": 12.6, + "stars": 3, + "expectedRating": 0.476 + }, + { + "internalChartLevel": 12.6, + "stars": 3, + "expectedRating": 0.476 + }, + { + "internalChartLevel": 12.6, + "stars": 3, + "expectedRating": 0.476 + }, + { + "internalChartLevel": 12.5, + "stars": 3, + "expectedRating": 0.468 + }, + { + "internalChartLevel": 12.5, + "stars": 3, + "expectedRating": 0.468 + }, + { + "internalChartLevel": 12.5, + "stars": 3, + "expectedRating": 0.468 + }, + { + "internalChartLevel": 12.4, + "stars": 3, + "expectedRating": 0.461 + }, + { + "internalChartLevel": 12.4, + "stars": 3, + "expectedRating": 0.461 + }, + { + "internalChartLevel": 12.4, + "stars": 3, + "expectedRating": 0.461 + } + ] +} diff --git a/typescript/rg-stats/tsconfig.build.json b/typescript/rg-stats/tsconfig.build.json new file mode 100644 index 000000000..259dbf49e --- /dev/null +++ b/typescript/rg-stats/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": false, + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "exclude": ["node_modules", "src/test-utils", "src/**/*.test.ts"] +} diff --git a/typescript/rg-stats/tsconfig.json b/typescript/rg-stats/tsconfig.json new file mode 100644 index 000000000..0021da68b --- /dev/null +++ b/typescript/rg-stats/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "Preserve", + "moduleResolution": "bundler" + }, + "include": ["src", "eslint.config.mjs", "vitest.config.ts"], + "exclude": ["node_modules"] +} diff --git a/typescript/rg-stats/vitest.config.ts b/typescript/rg-stats/vitest.config.ts new file mode 100644 index 000000000..6f48a0175 --- /dev/null +++ b/typescript/rg-stats/vitest.config.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + env: { + NODE_ENV: "test", + }, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: [ + "src/**/*.test.ts", + "src/test-utils/**", + // Barrel only re-exports algorithms — no executable lines to cover. + "src/index.ts", + // Type-only / unused by current test surface (Tap nyc also did not require these). + "src/util/types.ts", + "src/util/options.ts", + ], + thresholds: { + lines: 100, + statements: 100, + functions: 100, + branches: 100, + }, + }, + }, + resolve: { + alias: { + "rg-stats": path.resolve(__dirname, "src/index.ts"), + }, + }, +}); diff --git a/typescript/seeds-scripts/package.json b/typescript/seeds-scripts/package.json index 19f443f1d..6f28213db 100644 --- a/typescript/seeds-scripts/package.json +++ b/typescript/seeds-scripts/package.json @@ -29,17 +29,12 @@ "cheerio": "catalog:", "commander": "catalog:", "csv-parse": "catalog:", - "deepmerge": "catalog:", "encoding-japanese": "catalog:", "fast-json-stable-hash": "catalog:", "fast-xml-parser": "catalog:", - "glob": "catalog:", - "html-entities": "catalog:", "iconv-lite": "catalog:", "lodash.get": "catalog:", - "monk": "catalog:", "node-fetch": "catalog:", - "prudence": "catalog:", "tachi-common": "workspace:*", "zod": "catalog:" }, diff --git a/typescript/server/.env b/typescript/server/.env index ee59ff14e..e49619c97 100644 --- a/typescript/server/.env +++ b/typescript/server/.env @@ -5,7 +5,7 @@ PORT=8080 LOG_LEVEL="debug" MSGPACKR_NATIVE_ACCELERATION_DISABLED=true -POSTGRES_URL="postgresql://tachi:tachi@tachi-postgres/tachi_dev" +POSTGRES_URL="postgresql://tachi:tachi@tachi-postgres/kamai" MIGRATIONS_DIR="/tachi/db/migrations" REDIS_URL=tachi-redis @@ -14,8 +14,7 @@ PORT=8080 MIGRATIONS_DIR=/tachi/db/migrations LOG_LEVEL=debug -TACHI_CLIENT_DEV_SERVER=http://127.0.0.1:3000 -TACHI_ENABLE_SERVER_HTTPS=true +TACHI_CLIENT_DEV_SERVER=http://localhost:3000 TACHI_MONGO_DATABASE_NAME=tachi-dev TACHI_CAPTCHA_SECRET_KEY=unused TACHI_SESSION_SECRET=unused @@ -31,7 +30,7 @@ TACHI_EAG_OAUTH2_CLIENT_SECRET=DUMMY_CLIENT_SECRET TACHI_EAG_OAUTH2_REDIRECT_URI=https://example.com TACHI_ARC_AUTH_TOKEN=unused TACHI_MYT_AUTH_TOKEN=unused -TACHI_OUR_URL=https://127.0.0.1:8080 +TACHI_OUR_URL=http://localhost:3000 ; TACHI_INVITE_CODE_BATCH_SIZE=2 ; TACHI_INVITE_CODE_INVITE_CAP=100 ; TACHI_INVITE_CODE_BETA_USER_BONUS=5 diff --git a/typescript/server/.env.test b/typescript/server/.env.test index 05167ddc2..3c2e98571 100644 --- a/typescript/server/.env.test +++ b/typescript/server/.env.test @@ -1,4 +1,5 @@ # Vitest: NODE_ENV=test is set by vitest.config.ts before imports; POSTGRES_URL is set per-worker in vitest.setup.ts. +TACHI_ENABLE_METRICS=false REDIS_URL=tachi-redis MONGO_URL=tachi-mongo PORT=8080 @@ -22,7 +23,6 @@ TACHI_EAG_OAUTH2_CLIENT_SECRET=DUMMY_CLIENT_SECRET TACHI_EAG_OAUTH2_REDIRECT_URI=https://example.com TACHI_ARC_AUTH_TOKEN=unused TACHI_MYT_AUTH_TOKEN=unused -TACHI_ENABLE_SERVER_HTTPS=false TACHI_OUR_URL=https://example.com TACHI_INVITE_CODE_BATCH_SIZE=2 TACHI_INVITE_CODE_INVITE_CAP=100 diff --git a/typescript/server/.gitignore b/typescript/server/.gitignore index 903107fba..475958279 100644 --- a/typescript/server/.gitignore +++ b/typescript/server/.gitignore @@ -2,9 +2,6 @@ .setup_env failed-tests.txt -# HTTPS local certs -cert - # Error Files scripts/validate-database-errs/* diff --git a/typescript/server/package.json b/typescript/server/package.json index 0c91428d4..fffdd7603 100644 --- a/typescript/server/package.json +++ b/typescript/server/package.json @@ -16,36 +16,25 @@ "author": "zk", "license": "AGPL3", "devDependencies": { - "@grpc/grpc-js": "catalog:", - "@types/google-protobuf": "catalog:", "@types/pg": "catalog:", "@types/supertest": "catalog:", "@typescript/native-preview": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-config-tachi": "workspace:*", - "glob": "catalog:", - "google-protobuf": "catalog:", - "grpc-tools": "catalog:", "node-mocks-http": "catalog:", - "nodemon": "catalog:", "supertest": "catalog:", - "ts-protoc-gen": "catalog:", "tsconfig-paths": "catalog:", "vitest": "catalog:" }, "dependencies": { "@aws-sdk/client-s3": "catalog:", - "@aws-sdk/signature-v4-crt": "catalog:", "@types/bcryptjs": "catalog:", - "@types/bson": "catalog:", "@types/bunyan": "catalog:", "@types/connect-redis": "catalog:", "@types/express": "catalog:", "@types/express-rate-limit": "catalog:", "@types/express-serve-static-core": "catalog:", "@types/express-session": "catalog:", - "@types/glob": "catalog:", - "@types/mkdirp": "catalog:", "@types/mongodb": "catalog:", "@types/multer": "catalog:", "@types/node": "catalog:", @@ -53,10 +42,6 @@ "@types/nodemailer": "catalog:", "@types/rate-limit-redis": "catalog:", "@types/redis": "catalog:", - "@types/rimraf": "catalog:", - "@types/safe-json-stringify": "catalog:", - "@types/semver": "catalog:", - "@valuabletouch/winston-seq": "catalog:", "bcryptjs": "catalog:", "bliss": "workspace:*", "bms-table-loader": "catalog:", @@ -76,10 +61,8 @@ "fast-xml-parser": "catalog:", "helmet": "catalog:", "html-entities": "catalog:", - "json5": "catalog:", "kysely": "catalog:", "lodash": "catalog:", - "mkdirp": "catalog:", "mongodb": "catalog:", "monk": "catalog:", "multer": "catalog:", @@ -88,22 +71,16 @@ "node-html-parser": "catalog:", "nodemailer": "catalog:", "pg": "catalog:", + "prom-client": "catalog:", "prudence": "catalog:", "rate-limit-redis": "catalog:", "redis": "catalog:", "rg-stats": "catalog:", - "rimraf": "catalog:", - "safe-json-stringify": "catalog:", - "semver": "catalog:", "tachi-common": "workspace:*", "tachi-db": "workspace:*", "tachi-db-migration-engine": "workspace:*", "ts-node": "catalog:", "typescript": "catalog:", - "uuidv7": "catalog:", - "winston": "catalog:", - "winston-loki": "catalog:", - "winston-transport": "catalog:", "zod": "catalog:" }, "nyc": { diff --git a/typescript/server/src/actions/create-api-client.test.ts b/typescript/server/src/actions/create-api-client.test.ts index 80df1aa8d..a761dff83 100644 --- a/typescript/server/src/actions/create-api-client.test.ts +++ b/typescript/server/src/actions/create-api-client.test.ts @@ -48,6 +48,66 @@ describe("ACTION_CreateApiClient", () => { ).rejects.toMatchObject({ code: 400 }); }); + it("throws 400 when webhookUri uses http instead of https", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: "http://example.com/webhook", + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("throws 400 when webhookUri targets localhost", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: "https://localhost/webhook", + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("throws 400 when webhookUri targets a private IP address", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: "https://10.0.0.1/webhook", + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("throws 400 when webhookUri targets a link-local address", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: "https://169.254.169.254/latest/meta-data/", + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }), + ).rejects.toMatchObject({ code: 400 }); + }); + it("throws 400 when apiKeyTemplate does not contain %%TACHI_KEY%%", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; diff --git a/typescript/server/src/actions/create-api-client.ts b/typescript/server/src/actions/create-api-client.ts index 8e82d828a..6502265d8 100644 --- a/typescript/server/src/actions/create-api-client.ts +++ b/typescript/server/src/actions/create-api-client.ts @@ -3,6 +3,7 @@ import { ServerConfig } from "#lib/setup/config.js"; import DB from "#services/pg/db.js"; import { DedupeArr, Random20Hex } from "#utils/misc.js"; import { IsUserAdmin } from "#utils/user.js"; +import { validateWebhookUri } from "#utils/validate-webhook-uri.js"; import { ExpectedErr } from "bliss"; import { ALL_PERMISSIONS, type APIPermissions } from "tachi-common"; @@ -39,6 +40,14 @@ export const ACTION_CreateApiClient = MakeAction( throw new ExpectedErr(400, "Must require at least one permission."); } + if (webhookUri !== null) { + const rejection = validateWebhookUri(webhookUri); + + if (rejection) { + throw new ExpectedErr(400, rejection); + } + } + if (apiKeyTemplate !== null && !apiKeyTemplate.includes("%%TACHI_KEY%%")) { throw new ExpectedErr(400, "apiKeyTemplate must contain %%TACHI_KEY%%."); } diff --git a/typescript/server/src/actions/patch-ugpt-settings.test.ts b/typescript/server/src/actions/patch-ugpt-settings.test.ts index 6b5f53935..f2e19d105 100644 --- a/typescript/server/src/actions/patch-ugpt-settings.test.ts +++ b/typescript/server/src/actions/patch-ugpt-settings.test.ts @@ -1,4 +1,5 @@ -import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings.js"; +import { SELECT_ACTION } from "#lib/db-formats/action"; +import { GetUGPTSettingsDocument, SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings.js"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; @@ -42,9 +43,9 @@ describe("ACTION_PatchUGPTSettings", () => { expect(settings?.preferences.preferredScoreAlg).toBe("ktLampRating"); const row = await DB.selectFrom("game_settings") - .selectAll() - .where("user_id", "=", userId) - .where("game", "=", "iidx-sp") + .select(SELECT_GAME_SETTINGS) + .where("game_settings.user_id", "=", userId) + .where("game_settings.game", "=", "iidx-sp") .executeTakeFirstOrThrow(); expect(row.pf_preferred_score_alg).toBe("ktLampRating"); @@ -61,8 +62,8 @@ describe("ACTION_PatchUGPTSettings", () => { }); const actionRow = await DB.selectFrom("action") - .selectAll() - .where("kind", "=", "PATCH_UGPT_SETTINGS") + .select(SELECT_ACTION) + .where("action.kind", "=", "PATCH_UGPT_SETTINGS") .executeTakeFirstOrThrow(); expect(actionRow).toMatchObject({ result: "GOOD", ip: "10.0.0.2", user_id: userId }); diff --git a/typescript/server/src/actions/patch-ugpt-settings.ts b/typescript/server/src/actions/patch-ugpt-settings.ts index 6516271f7..3efeaa062 100644 --- a/typescript/server/src/actions/patch-ugpt-settings.ts +++ b/typescript/server/src/actions/patch-ugpt-settings.ts @@ -1,4 +1,5 @@ import { MakeAction } from "#lib/actions/actions.js"; +import { SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings"; import DB from "#services/pg/db.js"; import { IsUserAdmin } from "#utils/user.js"; import { ExpectedErr } from "bliss"; @@ -57,9 +58,9 @@ export const ACTION_PatchUGPTSettings = MakeAction("PATCH_UGPT_SETTINGS", async } const row = await DB.selectFrom("game_settings") - .selectAll() - .where("user_id", "=", userID) - .where("game", "=", v3Game) + .select(SELECT_GAME_SETTINGS) + .where("game_settings.user_id", "=", userID) + .where("game_settings.game", "=", v3Game) .executeTakeFirst(); if (!row) { diff --git a/typescript/server/src/actions/remove-goal-subscription.ts b/typescript/server/src/actions/remove-goal-subscription.ts index 090eb8ea5..f14cdb12d 100644 --- a/typescript/server/src/actions/remove-goal-subscription.ts +++ b/typescript/server/src/actions/remove-goal-subscription.ts @@ -1,12 +1,12 @@ import { MakeAction } from "#lib/actions/actions.js"; +import { SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents.js"; import { UnsubscribeFromGoal } from "#lib/targets/goals.js"; import DB from "#services/pg/db.js"; import { staticAssertUnreachable } from "#utils/misc.js"; import { IsUserAdmin } from "#utils/user.js"; import { ExpectedErr } from "bliss"; -import type { Game } from "tachi-db"; -import { GamePTToV3, type GameGroup, type Playtype } from "tachi-common"; +import { type GameGroup, GamePTToV3, type Playtype } from "tachi-common"; export const ACTION_RemoveGoalSubscription = MakeAction( "REMOVE_GOAL_SUBSCRIPTION", @@ -23,8 +23,7 @@ export const ACTION_RemoveGoalSubscription = MakeAction( const row = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) .where("goal_sub.goal_id", "=", goalID) .where("goal.game", "=", v3Game) @@ -34,10 +33,7 @@ export const ACTION_RemoveGoalSubscription = MakeAction( throw new ExpectedErr(404, "You are not subscribed to this goal."); } - const goalSub = ToGoalSubscriptionDocument({ - ...row, - goal_game: row.goal_game as Game, - }); + const goalSub = ToGoalSubscriptionDocument(row); const fail = await UnsubscribeFromGoal(goalSub, false); diff --git a/typescript/server/src/actions/test-utils/api-tokens.ts b/typescript/server/src/actions/test-utils/api-tokens.ts index 7e67ae191..8aa0d2405 100644 --- a/typescript/server/src/actions/test-utils/api-tokens.ts +++ b/typescript/server/src/actions/test-utils/api-tokens.ts @@ -1,3 +1,4 @@ +import { SELECT_API_TOKEN } from "#lib/db-formats/api-token"; import DB from "#services/pg/db"; // ─── seedApiClient ──────────────────────────────────────────────────────────── @@ -74,7 +75,7 @@ export async function seedApiToken(opts: SeedApiTokenOpts) { export async function getApiToken(token: string) { return DB.selectFrom("priv_api_token") - .selectAll() - .where("token", "=", token) + .select(SELECT_API_TOKEN) + .where("priv_api_token.token", "=", token) .executeTakeFirst(); } diff --git a/typescript/server/src/actions/test-utils/notifications.ts b/typescript/server/src/actions/test-utils/notifications.ts index ab6045f94..aae30fd24 100644 --- a/typescript/server/src/actions/test-utils/notifications.ts +++ b/typescript/server/src/actions/test-utils/notifications.ts @@ -1,4 +1,5 @@ import { ONE_SECOND } from "#lib/constants/time"; +import { SELECT_NOTIFICATION } from "#lib/db-formats/notification"; import DB from "#services/pg/db"; export interface SeedNotifOpts { @@ -29,7 +30,10 @@ export async function seedNotification(opts: SeedNotifOpts) { } export function getNotification(rowId: string) { - return DB.selectFrom("notification").selectAll().where("row_id", "=", rowId).executeTakeFirst(); + return DB.selectFrom("notification") + .select(SELECT_NOTIFICATION) + .where("notification.row_id", "=", rowId) + .executeTakeFirst(); } export async function countNotificationsForUser(userId: number) { diff --git a/typescript/server/src/actions/update-api-client.test.ts b/typescript/server/src/actions/update-api-client.test.ts index 97d0f053f..daf776797 100644 --- a/typescript/server/src/actions/update-api-client.test.ts +++ b/typescript/server/src/actions/update-api-client.test.ts @@ -61,6 +61,50 @@ describe("ACTION_UpdateApiClient", () => { ).rejects.toMatchObject({ code: 400 }); }); + it("throws 400 when webhookUri uses http instead of https", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_UpdateApiClient(taker, { + clientID: clientId, + webhookUri: "http://example.com/webhook", + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("throws 400 when webhookUri targets a private IP address", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_UpdateApiClient(taker, { + clientID: clientId, + webhookUri: "https://10.0.0.1:8080/internal", + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("throws 400 when webhookUri targets localhost", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_UpdateApiClient(taker, { + clientID: clientId, + webhookUri: "https://localhost/webhook", + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("allows setting webhookUri to null", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_UpdateApiClient(taker, { + clientID: clientId, + webhookUri: null, + }); + + expect(result.webhookUri).toBeNull(); + }); + // ── Happy path ──────────────────────────────────────────────────────────── it("updates the name field", async () => { diff --git a/typescript/server/src/actions/update-api-client.ts b/typescript/server/src/actions/update-api-client.ts index 8f202c47a..526780320 100644 --- a/typescript/server/src/actions/update-api-client.ts +++ b/typescript/server/src/actions/update-api-client.ts @@ -1,6 +1,7 @@ import { MakeAction } from "#lib/actions/actions.js"; import DB from "#services/pg/db.js"; import { GetClientByID } from "#utils/queries/api-clients.js"; +import { validateWebhookUri } from "#utils/validate-webhook-uri.js"; import { ExpectedErr } from "bliss"; export const ACTION_UpdateApiClient = MakeAction( @@ -36,6 +37,14 @@ export const ACTION_UpdateApiClient = MakeAction( } if (webhookUri !== undefined) { + if (webhookUri !== null) { + const rejection = validateWebhookUri(webhookUri); + + if (rejection) { + throw new ExpectedErr(400, rejection); + } + } + updates.webhook_uri = webhookUri; } diff --git a/typescript/server/src/actions/update-bpi-data.ts b/typescript/server/src/actions/update-bpi-data.ts index 1c28d78f7..d98095b8d 100644 --- a/typescript/server/src/actions/update-bpi-data.ts +++ b/typescript/server/src/actions/update-bpi-data.ts @@ -1,10 +1,10 @@ /* eslint-disable no-await-in-loop */ import type { Difficulties, integer, MONGO_ChartDocument, Playtypes } from "tachi-common"; +import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum"; import { MakeAction } from "#lib/actions/actions"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; -import { RecalcAllScores } from "#utils/calculations/recalc-scores"; import fetch from "#utils/fetch"; import { FindChartWithPTDFVersion } from "#utils/queries/charts"; import { FindSongOnTitle } from "#utils/queries/songs"; @@ -120,18 +120,25 @@ export async function updateBpiDataCore() { worldRecord: newWR, }; + const gpt = `iidx:${playtype}` as const; + const updatedChart = { ...tachiChart, data: mergedData } as MONGO_ChartDocument; + const checksum = computeDerivationChecksumForGPT(gpt, updatedChart); + await DB.updateTable("chart") - .set({ data: mergedData as object }) + .set({ + data: mergedData as object, + derivation_checksum: checksum, + }) .where("id", "=", tachiChart.chartID) .execute(); } } if (updatedChartIDs.length !== 0) { - log.info(`Finished applying BPI changes in Postgres. Recalcing scores.`); - // TODO(zk): We don't want to recalc _everything_ on changes like this? - await RecalcAllScores(); - log.info(`Finished recalcing scores.`); + log.info( + `Finished applying BPI changes in Postgres (${updatedChartIDs.length} charts). ` + + `Score re-derivation will be handled by the score_rederive queue.`, + ); } return { chartsUpdated: updatedChartIDs.length }; diff --git a/typescript/server/src/actions/update-dp-tiers.ts b/typescript/server/src/actions/update-dp-tiers.ts index 90410d072..077409138 100644 --- a/typescript/server/src/actions/update-dp-tiers.ts +++ b/typescript/server/src/actions/update-dp-tiers.ts @@ -1,10 +1,10 @@ import type { MONGO_ChartDocument } from "tachi-common"; /* eslint-disable no-await-in-loop */ +import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum"; import { MakeAction } from "#lib/actions/actions"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; -import { RecalcAllScores } from "#utils/calculations/recalc-scores"; import fetch from "#utils/fetch"; import { FindChartWithPTDFVersion } from "#utils/queries/charts"; import { FindSongOnTitle } from "#utils/queries/songs"; @@ -89,8 +89,14 @@ export async function updateDpTiersCore() { dpTier: nextTier, }; + const updatedChart = { ...chart, data: mergedData } as MONGO_ChartDocument; + const checksum = computeDerivationChecksumForGPT("iidx:DP", updatedChart); + await DB.updateTable("chart") - .set({ data: mergedData as object }) + .set({ + data: mergedData as object, + derivation_checksum: checksum, + }) .where("id", "=", chart.chartID) .execute(); @@ -99,12 +105,10 @@ export async function updateDpTiersCore() { } if (updatedSongIDs.size !== 0) { - log.info(`${updatedSongIDs.size} songs were changed. Recalcing the relevant scores now.`); - - // TODO(zk): We don't want to recalc _everything_ on changes like this? - await RecalcAllScores(); - - log.info(`Recalced those scores.`); + log.info( + `${updatedSongIDs.size} songs were changed. ` + + `Score re-derivation will be handled by the score_rederive queue.`, + ); } log.info("Done."); diff --git a/typescript/server/src/actions/update-sp12-data.ts b/typescript/server/src/actions/update-sp12-data.ts index aaeacf83b..bc146e33f 100644 --- a/typescript/server/src/actions/update-sp12-data.ts +++ b/typescript/server/src/actions/update-sp12-data.ts @@ -1,10 +1,10 @@ import type { Difficulties, integer, MONGO_ChartDocument } from "tachi-common"; /* eslint-disable no-await-in-loop */ +import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum"; import { MakeAction } from "#lib/actions/actions"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; -import { RecalcAllScores } from "#utils/calculations/recalc-scores"; import fetch from "#utils/fetch"; import { FindChartWithPTDF } from "#utils/queries/charts"; import { FindSongOnTitle } from "#utils/queries/songs"; @@ -179,8 +179,14 @@ export async function updateSp12DataCore() { }, }; + const updatedChart = { ...chart, data: chartData } as MONGO_ChartDocument; + const checksum = computeDerivationChecksumForGPT("iidx:SP", updatedChart); + await DB.updateTable("chart") - .set({ data: chartData as object }) + .set({ + data: chartData as object, + derivation_checksum: checksum, + }) .where("id", "=", chart.chartID) .execute(); @@ -189,10 +195,10 @@ export async function updateSp12DataCore() { } if (updatedChartIDs.length !== 0) { - log.info(`Finished applying SP12 changes. Recalcing.`); - // TODO(zk): We don't want to recalc _everything_ on changes like this? - await RecalcAllScores(); - log.info(`Finished recalcing scores.`); + log.info( + `Finished applying SP12 changes (${updatedChartIDs.length} charts). ` + + `Score re-derivation will be handled by the score_rederive queue.`, + ); } return { chartsUpdated: updatedChartIDs.length }; diff --git a/typescript/server/src/actions/update-user-settings.test.ts b/typescript/server/src/actions/update-user-settings.test.ts index b0ff6b366..7620f4c29 100644 --- a/typescript/server/src/actions/update-user-settings.test.ts +++ b/typescript/server/src/actions/update-user-settings.test.ts @@ -1,3 +1,5 @@ +import { SELECT_ACTION } from "#lib/db-formats/action"; +import { SELECT_USER_SETTINGS } from "#lib/db-formats/user-settings"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; @@ -6,8 +8,8 @@ import { ACTION_UpdateUserSettings } from "./update-user-settings"; async function getSettings(userId: number) { return DB.selectFrom("account_settings") - .selectAll() - .where("user_id", "=", userId) + .select(SELECT_USER_SETTINGS) + .where("account_settings.user_id", "=", userId) .executeTakeFirstOrThrow(); } @@ -135,8 +137,8 @@ describe("ACTION_UpdateUserSettings", () => { await ACTION_UpdateUserSettings(taker, { invisible: true }); const action = await DB.selectFrom("action") - .selectAll() - .where("kind", "=", "UPDATE_USER_SETTINGS") + .select(SELECT_ACTION) + .where("action.kind", "=", "UPDATE_USER_SETTINGS") .executeTakeFirstOrThrow(); expect(action).toMatchObject({ diff --git a/typescript/server/src/anon-actions/oauth-token-exchange.ts b/typescript/server/src/anon-actions/oauth-token-exchange.ts index 09b55afea..17358648f 100644 --- a/typescript/server/src/anon-actions/oauth-token-exchange.ts +++ b/typescript/server/src/anon-actions/oauth-token-exchange.ts @@ -3,6 +3,7 @@ import { SELECT_API_TOKEN, ToAPITokenDocument } from "#lib/db-formats/api-token" import DB from "#services/pg/db"; import { Random20Hex } from "#utils/misc"; import { ExpectedErr } from "bliss"; +import crypto from "node:crypto"; const SELECT_OAUTH_CLIENT = [ "priv_api_client.client_id", @@ -31,7 +32,13 @@ export const ANON_ACTION_OAuthTokenExchange = MakeAnonAction( throw new ExpectedErr(404, `This client does not exist.`); } - if (client.client_secret !== input.client_secret) { + const expectedBuf = Buffer.from(client.client_secret, "utf8"); + const receivedBuf = Buffer.from(String(input.client_secret), "utf8"); + + if ( + expectedBuf.length !== receivedBuf.length || + !crypto.timingSafeEqual(expectedBuf, receivedBuf) + ) { throw new ExpectedErr(403, `Invalid secret.`); } diff --git a/typescript/server/src/anon-actions/reset-password.test.ts b/typescript/server/src/anon-actions/reset-password.test.ts index 66cf0e6bc..1855bac36 100644 --- a/typescript/server/src/anon-actions/reset-password.test.ts +++ b/typescript/server/src/anon-actions/reset-password.test.ts @@ -173,7 +173,7 @@ describe("ANON_ACTION_ResetPassword", () => { expect(await PasswordCompare(user.password, cred.password)).toBe(true); }); - it("writes a THROW action row when the code does not exist (dead executeTakeFirstOrThrow)", async () => { + it("writes a BAD action row when the code does not exist", async () => { await expect( ANON_ACTION_ResetPassword(taker, { code: "NONEXISTENT_TOKEN", @@ -186,9 +186,48 @@ describe("ANON_ACTION_ResetPassword", () => { .where("kind", "=", "RESET_PASSWORD") .executeTakeFirstOrThrow(); - // executeTakeFirstOrThrow raises a raw NoResultError (not an ExpectedErr), - // so the audit framework marks it THROW rather than BAD. - expect(action.result).toBe("THROW"); + expect(action.result).toBe("BAD"); + }); + + // ── Token invalidation ──────────────────────────────────────────────────── + + it("deletes the token from priv_password_reset_token after use", async () => { + await seedResetToken(user.id); + + await ANON_ACTION_ResetPassword(taker, { + code: "VALID_RESET_TOKEN", + "!password": "new_secure_password", + }); + + const remaining = await DB.selectFrom("priv_password_reset_token") + .select("token") + .where("token", "=", "VALID_RESET_TOKEN") + .executeTakeFirst(); + + expect(remaining).toBeUndefined(); + }); + + it("rejects a second use of the same reset code", async () => { + await seedResetToken(user.id); + + await ANON_ACTION_ResetPassword(taker, { + code: "VALID_RESET_TOKEN", + "!password": "new_secure_password", + }); + + await expect( + ANON_ACTION_ResetPassword(taker, { + code: "VALID_RESET_TOKEN", + "!password": "attacker_password", + }), + ).rejects.toMatchObject({ code: 404 }); + + const cred = await DB.selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", user.id) + .executeTakeFirstOrThrow(); + + expect(await PasswordCompare("new_secure_password", cred.password)).toBe(true); }); // ── Token isolation ──────────────────────────────────────────────────────── diff --git a/typescript/server/src/anon-actions/reset-password.ts b/typescript/server/src/anon-actions/reset-password.ts index 6623c8245..505f3a57b 100644 --- a/typescript/server/src/anon-actions/reset-password.ts +++ b/typescript/server/src/anon-actions/reset-password.ts @@ -8,16 +8,16 @@ import { ExpectedErr, log } from "bliss"; export const ANON_ACTION_ResetPassword = MakeAnonAction( "RESET_PASSWORD", async (_taker, { "!password": password, code }) => { - const codeInfo = await DB.selectFrom("priv_password_reset_token") - .select(["user_id", "created_on"]) + const deleted = await DB.deleteFrom("priv_password_reset_token") .where("token", "=", code) - .executeTakeFirstOrThrow(); + .returning(["user_id", "created_on"]) + .executeTakeFirst(); - if (!codeInfo) { + if (!deleted) { throw new ExpectedErr(404, "Invalid reset code."); } - if (ISO8601ToUnixMilliseconds(codeInfo.created_on) < GetTimeXHoursAgo(24)) { + if (ISO8601ToUnixMilliseconds(deleted.created_on) < GetTimeXHoursAgo(24)) { throw new ExpectedErr(400, "Reset code has expired. Please request a new one."); } @@ -27,13 +27,13 @@ export const ANON_ACTION_ResetPassword = MakeAnonAction( .set({ password: hashedPassword, }) - .where("user_id", "=", codeInfo.user_id) + .where("user_id", "=", deleted.user_id) .execute(); - log.info(`User ${codeInfo.user_id} reset their password.`); + log.info(`User ${deleted.user_id} reset their password.`); return { - userID: codeInfo.user_id, + userID: deleted.user_id, }; }, ); diff --git a/typescript/server/src/game-implementations/games/arcaea.ts b/typescript/server/src/game-implementations/games/arcaea.ts index 2bee8d2de..2bb9bd564 100644 --- a/typescript/server/src/game-implementations/games/arcaea.ts +++ b/typescript/server/src/game-implementations/games/arcaea.ts @@ -96,6 +96,7 @@ export const ARCAEA_IMPL: GPTServerImplementation = { ), ], defaultMergeRefName: "Best Score", + derivationRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if (s.scoreData.lamp === "PURE MEMORY" && s.scoreData.score < 10_000_000) { diff --git a/typescript/server/src/game-implementations/games/bms-pms.ts b/typescript/server/src/game-implementations/games/bms-pms.ts index 7de635476..93226b177 100644 --- a/typescript/server/src/game-implementations/games/bms-pms.ts +++ b/typescript/server/src/game-implementations/games/bms-pms.ts @@ -84,6 +84,7 @@ const BMS_IMPL: GPTServerImplementation = { pbMergeFunctions: BMS_PMS_MERGERS, defaultMergeRefName: "Best Score", scoreValidators: IIDXLIKE_SCORE_VALIDATORS, + derivationRelevantFields: ["data.notecount", "data.sglEC", "data.sglHC"], }; export const BMS_14K_IMPL: GPTServerImplementation<"bms:14K"> = BMS_IMPL; diff --git a/typescript/server/src/game-implementations/games/chunithm.ts b/typescript/server/src/game-implementations/games/chunithm.ts index 7621d49e4..2530af2c3 100644 --- a/typescript/server/src/game-implementations/games/chunithm.ts +++ b/typescript/server/src/game-implementations/games/chunithm.ts @@ -109,6 +109,7 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = { ), ], defaultMergeRefName: "Best Score", + derivationRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if ( diff --git a/typescript/server/src/game-implementations/games/ddr.ts b/typescript/server/src/game-implementations/games/ddr.ts index 4933d1f56..1ad65a2e4 100644 --- a/typescript/server/src/game-implementations/games/ddr.ts +++ b/typescript/server/src/game-implementations/games/ddr.ts @@ -351,4 +351,5 @@ export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = { }), profileCalcs: DDR_PROFILE_CALCS, scoreValidators: DDR_SCORE_VALIDATORS, + derivationRelevantFields: ["levelNum"], }; diff --git a/typescript/server/src/game-implementations/games/gitadora.ts b/typescript/server/src/game-implementations/games/gitadora.ts index 05f6ebf12..f569f8055 100644 --- a/typescript/server/src/game-implementations/games/gitadora.ts +++ b/typescript/server/src/game-implementations/games/gitadora.ts @@ -104,6 +104,7 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita"> ], defaultMergeRefName: "Best Percent", scoreValidators: [], + derivationRelevantFields: ["levelNum"], }; export const GITADORA_GITA_IMPL: GPTServerImplementation<"gitadora:Gita"> = GITADORA_IMPL; diff --git a/typescript/server/src/game-implementations/games/iidx.ts b/typescript/server/src/game-implementations/games/iidx.ts index 162d76dfa..c9a811d00 100644 --- a/typescript/server/src/game-implementations/games/iidx.ts +++ b/typescript/server/src/game-implementations/games/iidx.ts @@ -162,6 +162,16 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = { pbMergeFunctions: IIDX_MERGERS, defaultMergeRefName: "Best Score", scoreValidators: IIDXLIKE_SCORE_VALIDATORS, + derivationRelevantFields: [ + "levelNum", + "data.notecount", + "data.kaidenAverage", + "data.worldRecord", + "data.bpiCoefficient", + "data.ncTier", + "data.hcTier", + "data.exhcTier", + ], }; export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = { @@ -209,4 +219,12 @@ export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = { pbMergeFunctions: IIDX_MERGERS, defaultMergeRefName: "Best Score", scoreValidators: IIDXLIKE_SCORE_VALIDATORS, + derivationRelevantFields: [ + "levelNum", + "data.notecount", + "data.kaidenAverage", + "data.worldRecord", + "data.bpiCoefficient", + "data.dpTier", + ], }; diff --git a/typescript/server/src/game-implementations/games/itg.ts b/typescript/server/src/game-implementations/games/itg.ts index 475590f9c..e609ed516 100644 --- a/typescript/server/src/game-implementations/games/itg.ts +++ b/typescript/server/src/game-implementations/games/itg.ts @@ -125,6 +125,7 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = { // this name sucks, what should we do instead? TODO. defaultMergeRefName: "Best Result", + derivationRelevantFields: ["data.rankedLevel", "data.notesPerMeasure", "data.npsPerMeasure"], scoreValidators: [ (s) => { diff --git a/typescript/server/src/game-implementations/games/jubeat.ts b/typescript/server/src/game-implementations/games/jubeat.ts index eb9a40be3..8bccd81dc 100644 --- a/typescript/server/src/game-implementations/games/jubeat.ts +++ b/typescript/server/src/game-implementations/games/jubeat.ts @@ -269,6 +269,7 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = { ), ], defaultMergeRefName: "Best Music Rate", + derivationRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if (s.scoreData.lamp === "EXCELLENT" && s.scoreData.score !== 1_000_000) { diff --git a/typescript/server/src/game-implementations/games/maimai.ts b/typescript/server/src/game-implementations/games/maimai.ts index f0d0d3523..07efd2bd1 100644 --- a/typescript/server/src/game-implementations/games/maimai.ts +++ b/typescript/server/src/game-implementations/games/maimai.ts @@ -125,6 +125,7 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = { ), ], defaultMergeRefName: "Best Percent", + derivationRelevantFields: ["levelNum", "data.maxPercent"], scoreValidators: [ (s) => { if (s.scoreData.percent > 104) { diff --git a/typescript/server/src/game-implementations/games/maimaidx.ts b/typescript/server/src/game-implementations/games/maimaidx.ts index cdd50b2c6..3b84465dc 100644 --- a/typescript/server/src/game-implementations/games/maimaidx.ts +++ b/typescript/server/src/game-implementations/games/maimaidx.ts @@ -91,6 +91,7 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = { ), ], defaultMergeRefName: "Best Percent", + derivationRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if (s.scoreData.lamp === "ALL PERFECT+" && s.scoreData.percent !== 101) { diff --git a/typescript/server/src/game-implementations/games/museca.ts b/typescript/server/src/game-implementations/games/museca.ts index 529a2129d..3819f1701 100644 --- a/typescript/server/src/game-implementations/games/museca.ts +++ b/typescript/server/src/game-implementations/games/museca.ts @@ -59,6 +59,7 @@ export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = { ), ], defaultMergeRefName: "Best Score", + derivationRelevantFields: ["levelNum"], scoreValidators: [ (s) => { if (s.scoreData.lamp === "PERFECT CONNECT ALL" && s.scoreData.score !== 1_000_000) { diff --git a/typescript/server/src/game-implementations/games/ongeki.ts b/typescript/server/src/game-implementations/games/ongeki.ts index 7492e09bb..886b897e6 100644 --- a/typescript/server/src/game-implementations/games/ongeki.ts +++ b/typescript/server/src/game-implementations/games/ongeki.ts @@ -191,6 +191,7 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { ), ], defaultMergeRefName: "Best Score", + derivationRelevantFields: ["levelNum", "data.maxPlatScore", "data.inGameID"], scoreValidators: [ (s, chart) => { let { hit, miss } = s.scoreData.judgements; diff --git a/typescript/server/src/game-implementations/games/popn.ts b/typescript/server/src/game-implementations/games/popn.ts index fb6c5eaa5..91bf6befb 100644 --- a/typescript/server/src/game-implementations/games/popn.ts +++ b/typescript/server/src/game-implementations/games/popn.ts @@ -119,6 +119,7 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = { ), ], defaultMergeRefName: "Best Score", + derivationRelevantFields: ["levelNum"], scoreValidators: [ (s) => { const { bad, good } = s.scoreData.judgements; diff --git a/typescript/server/src/game-implementations/games/sdvx.ts b/typescript/server/src/game-implementations/games/sdvx.ts index ac8a1d98a..5e93a85e6 100644 --- a/typescript/server/src/game-implementations/games/sdvx.ts +++ b/typescript/server/src/game-implementations/games/sdvx.ts @@ -66,4 +66,5 @@ export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = { ], defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME, scoreValidators: SDVXLIKE_SCORE_VALIDATORS, + derivationRelevantFields: ["levelNum"], }; diff --git a/typescript/server/src/game-implementations/games/usc.ts b/typescript/server/src/game-implementations/games/usc.ts index 9362b230b..60f9538f4 100644 --- a/typescript/server/src/game-implementations/games/usc.ts +++ b/typescript/server/src/game-implementations/games/usc.ts @@ -36,6 +36,7 @@ const USC_IMPL: GPTServerImplementation = { pbMergeFunctions: SDVXLIKE_PB_MERGERS, defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME, scoreValidators: SDVXLIKE_SCORE_VALIDATORS, + derivationRelevantFields: ["levelNum"], }; export const USC_KEYBOARD_IMPL: GPTServerImplementation<"usc:Keyboard"> = USC_IMPL; diff --git a/typescript/server/src/game-implementations/games/wacca.ts b/typescript/server/src/game-implementations/games/wacca.ts index 2c7787d7b..f526da113 100644 --- a/typescript/server/src/game-implementations/games/wacca.ts +++ b/typescript/server/src/game-implementations/games/wacca.ts @@ -86,6 +86,7 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = { ), ], defaultMergeRefName: "Best Score", + derivationRelevantFields: ["levelNum"], scoreValidators: [ (s) => { diff --git a/typescript/server/src/game-implementations/types.ts b/typescript/server/src/game-implementations/types.ts index bc4d56121..64f9e1ebb 100644 --- a/typescript/server/src/game-implementations/types.ts +++ b/typescript/server/src/game-implementations/types.ts @@ -270,6 +270,15 @@ export interface GPTServerImplementation { * could be (which might be chart dependent, etc), don't bother. */ scoreValidators: Array>; + + /** + * Dot-path keys into MONGO_ChartDocument whose values feed into + * `scoreDeriver` or `scoreCalcs`. When any of these change on a chart, + * all scores on that chart must be re-derived. + * + * Used to build a stable derivation checksum stored on `chart.derivation_checksum`. + */ + derivationRelevantFields: Array; } export type GPTImplementations = { diff --git a/typescript/server/src/game-implementations/utils/derivation-checksum.ts b/typescript/server/src/game-implementations/utils/derivation-checksum.ts new file mode 100644 index 000000000..6ec0334db --- /dev/null +++ b/typescript/server/src/game-implementations/utils/derivation-checksum.ts @@ -0,0 +1,83 @@ +import type { GPTString, MONGO_ChartDocument } from "tachi-common"; + +import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import crypto from "crypto"; + +/** + * Walk a dot-path like "data.notecount" into an object, returning the value + * at that path or `undefined` if any segment is missing. + */ +function getAtPath(obj: Record, path: string): unknown { + let cur: unknown = obj; + + for (const segment of path.split(".")) { + if (cur === null || cur === undefined || typeof cur !== "object") { + return undefined; + } + + cur = (cur as Record)[segment]; + } + + return cur; +} + +/** + * Recursively sorts object keys so JSON.stringify produces a deterministic + * string regardless of insertion order. + */ +function sortDeep(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + return value.map(sortDeep); + } + + if (typeof value === "object") { + const sorted: Record = {}; + + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = sortDeep((value as Record)[key]); + } + + return sorted; + } + + return value; +} + +/** + * Compute a stable SHA-256 hex digest over the subset of chart fields that + * affect score derivation for a given GPT. + * + * The checksum is deterministic: same field values always produce the same + * hash, regardless of object key ordering. + */ +export function computeDerivationChecksum( + chart: MONGO_ChartDocument, + fields: Array, +): string { + const extracted: Record = {}; + + for (const field of [...fields].sort()) { + extracted[field] = sortDeep(getAtPath(chart as unknown as Record, field)); + } + + const payload = JSON.stringify(extracted); + + return crypto.createHash("sha256").update(payload).digest("hex"); +} + +/** + * Compute the derivation checksum for a chart using the game's declared + * `derivationRelevantFields`. + */ +export function computeDerivationChecksumForGPT( + gpt: GPTString, + chart: MONGO_ChartDocument, +): string { + const impl = GPT_SERVER_IMPLEMENTATIONS[gpt]; + + return computeDerivationChecksum(chart, impl.derivationRelevantFields); +} diff --git a/typescript/server/src/lib/admin/admin-queries.ts b/typescript/server/src/lib/admin/admin-queries.ts index 21d2e4d3b..98e6fd411 100644 --- a/typescript/server/src/lib/admin/admin-queries.ts +++ b/typescript/server/src/lib/admin/admin-queries.ts @@ -1,5 +1,10 @@ import type { Action, CronTask, CronTaskExecution, JobQueue } from "tachi-db"; +import { + SELECT_CRON_TASK, + SELECT_CRON_TASK_EXECUTION, + SELECT_JOB_QUEUE, +} from "#lib/db-formats/admin-jobs"; import DB from "#services/pg/db"; export const ADMIN_PAGE_SIZE = 50; @@ -19,9 +24,9 @@ export interface PaginatedResult { export function GetActiveJobs(): Promise> { return DB.selectFrom("job_queue") - .selectAll() - .where("status", "=", 1) - .orderBy("scheduled_for", "asc") + .select(SELECT_JOB_QUEUE) + .where("job_queue.status", "=", 1) + .orderBy("job_queue.scheduled_for", "asc") .execute(); } @@ -51,8 +56,8 @@ export async function GetJobQueue({ const [items, countRow] = await Promise.all([ base - .selectAll() - .orderBy("created_at", "desc") + .select(SELECT_JOB_QUEUE) + .orderBy("job_queue.created_at", "desc") .limit(limit) .offset(page * limit) .execute(), @@ -129,13 +134,16 @@ export async function GetActions({ } export function GetCronTasks(): Promise> { - return DB.selectFrom("cron_task").selectAll().orderBy("id", "asc").execute(); + return DB.selectFrom("cron_task") + .select(SELECT_CRON_TASK) + .orderBy("cron_task.id", "asc") + .execute(); } export function GetCronTaskExecutions(limit = 100): Promise> { return DB.selectFrom("cron_task_execution") - .selectAll() - .orderBy("scheduled_at", "desc") + .select(SELECT_CRON_TASK_EXECUTION) + .orderBy("cron_task_execution.scheduled_at", "desc") .limit(limit) .execute(); } diff --git a/typescript/server/src/lib/db-formats/action.ts b/typescript/server/src/lib/db-formats/action.ts new file mode 100644 index 000000000..23cbe5ef0 --- /dev/null +++ b/typescript/server/src/lib/db-formats/action.ts @@ -0,0 +1,12 @@ +export const SELECT_ACTION = [ + "action.row_id", + "action.user_id", + "action.ip", + "action.app", + "action.kind", + "action.result", + "action.input", + "action.output", + "action.ts_start", + "action.ts_end", +] as const; diff --git a/typescript/server/src/lib/db-formats/admin-jobs.ts b/typescript/server/src/lib/db-formats/admin-jobs.ts new file mode 100644 index 000000000..e0e1bf081 --- /dev/null +++ b/typescript/server/src/lib/db-formats/admin-jobs.ts @@ -0,0 +1,33 @@ +/** Column lists for admin dashboard job / cron queries. */ + +export const SELECT_JOB_QUEUE = [ + "job_queue.row_id", + "job_queue.created_at", + "job_queue.updated_at", + "job_queue.scheduled_for", + "job_queue.failed_attempts", + "job_queue.status", + "job_queue.scope", + "job_queue.job_kind", + "job_queue.payload", +] as const; + +export const SELECT_CRON_TASK = [ + "cron_task.id", + "cron_task.schedule", + "cron_task.description", + "cron_task.created_at", + "cron_task.updated_at", + "cron_task.last_scheduled_at", +] as const; + +export const SELECT_CRON_TASK_EXECUTION = [ + "cron_task_execution.id", + "cron_task_execution.task_id", + "cron_task_execution.scheduled_at", + "cron_task_execution.started_at", + "cron_task_execution.completed_at", + "cron_task_execution.status", + "cron_task_execution.output", + "cron_task_execution.error", +] as const; diff --git a/typescript/server/src/lib/db-formats/goal.ts b/typescript/server/src/lib/db-formats/goal.ts new file mode 100644 index 000000000..e7293a207 --- /dev/null +++ b/typescript/server/src/lib/db-formats/goal.ts @@ -0,0 +1,38 @@ +import { type Selection } from "kysely"; +import { type Database } from "tachi-db"; + +export const SELECT_GOAL = [ + "goal.id", + "goal.game", + "goal.name", + "goal.charts", + "goal.criteria", +] as const; + +export const SELECT_GOAL_SUB = [ + "goal_sub.goal_id", + "goal_sub.user_id", + "goal_sub.last_interaction", + "goal_sub.progress", + "goal_sub.progress_human", + "goal_sub.out_of", + "goal_sub.out_of_human", + "goal_sub.achieved", + "goal_sub.time_achieved", + "goal_sub.was_instantly_achieved", + "goal_sub.was_assigned_standalone", +] as const; + +/** `goal_sub` joined with `goal` for `goal.game` as `goal_game`. */ +export const SELECT_GOAL_SUB_WITH_GOAL_GAME = [ + ...SELECT_GOAL_SUB, + "goal.game as goal_game", +] as const; + +export type GoalRow = Selection; +export type GoalSubRow = Selection; +export type GoalSubWithGoalGameRow = Selection< + Database, + "goal" | "goal_sub", + (typeof SELECT_GOAL_SUB_WITH_GOAL_GAME)[number] +>; diff --git a/typescript/server/src/lib/db-formats/import-document.ts b/typescript/server/src/lib/db-formats/import-document.ts index c7b584e7d..6b518c2f7 100644 --- a/typescript/server/src/lib/db-formats/import-document.ts +++ b/typescript/server/src/lib/db-formats/import-document.ts @@ -1,6 +1,26 @@ import type { Game, Import, ImportTracker } from "tachi-db"; import DB from "#services/pg/db"; + +export const SELECT_IMPORT = [ + "import.id", + "import.user_id", + "import.time_started", + "import.time_finished", + "import.game_group", + "import.import_type", + "import.user_intent", + "import.service", +] as const; + +export const SELECT_IMPORT_TRACKER = [ + "import_tracker.import_id", + "import_tracker.user_id", + "import_tracker.import_type", + "import_tracker.user_intent", + "import_tracker.time_started", + "import_tracker.error", +] as const; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type ClassDelta, @@ -128,8 +148,8 @@ export async function LoadImportDocumentById( importID: string, ): Promise { const base = await DB.selectFrom("import") - .selectAll() - .where("id", "=", importID) + .select(SELECT_IMPORT) + .where("import.id", "=", importID) .executeTakeFirst(); if (!base) { @@ -169,21 +189,21 @@ export async function ListRecentImportDocuments(opts: { userId?: number; userIntent?: boolean; }): Promise { - let q = DB.selectFrom("import").selectAll(); + let q = DB.selectFrom("import").select(SELECT_IMPORT); if (opts.userId !== undefined) { - q = q.where("user_id", "=", opts.userId); + q = q.where("import.user_id", "=", opts.userId); } if (opts.importType !== undefined) { - q = q.where("import_type", "=", opts.importType); + q = q.where("import.import_type", "=", opts.importType); } if (opts.userIntent !== undefined) { - q = q.where("user_intent", "=", opts.userIntent); + q = q.where("import.user_intent", "=", opts.userIntent); } - const bases = await q.orderBy("time_finished", "desc").limit(opts.limit).execute(); + const bases = await q.orderBy("import.time_finished", "desc").limit(opts.limit).execute(); if (bases.length === 0) { return []; @@ -283,21 +303,23 @@ export async function ListFailedImportTrackers(opts: { userId?: number; userIntent?: boolean; }): Promise { - let q = DB.selectFrom("import_tracker").selectAll().where("error", "is not", null); + let q = DB.selectFrom("import_tracker") + .select(SELECT_IMPORT_TRACKER) + .where("import_tracker.error", "is not", null); if (opts.userId !== undefined) { - q = q.where("user_id", "=", opts.userId); + q = q.where("import_tracker.user_id", "=", opts.userId); } if (opts.importType !== undefined) { - q = q.where("import_type", "=", opts.importType); + q = q.where("import_tracker.import_type", "=", opts.importType); } if (opts.userIntent !== undefined) { - q = q.where("user_intent", "=", opts.userIntent); + q = q.where("import_tracker.user_intent", "=", opts.userIntent); } - const rows = await q.orderBy("time_started", "desc").limit(opts.limit).execute(); + const rows = await q.orderBy("import_tracker.time_started", "desc").limit(opts.limit).execute(); return rows.map((row) => ToImportTrackerDocument(row)); } @@ -306,8 +328,8 @@ export async function GetImportTrackerByImportId( importID: string, ): Promise { const row = await DB.selectFrom("import_tracker") - .selectAll() - .where("import_id", "=", importID) + .select(SELECT_IMPORT_TRACKER) + .where("import_tracker.import_id", "=", importID) .executeTakeFirst(); if (!row) { diff --git a/typescript/server/src/lib/db-formats/import-lock.ts b/typescript/server/src/lib/db-formats/import-lock.ts new file mode 100644 index 000000000..0b03b84db --- /dev/null +++ b/typescript/server/src/lib/db-formats/import-lock.ts @@ -0,0 +1,5 @@ +export const SELECT_IMPORT_LOCK = [ + "import_lock.user_id", + "import_lock.locked", + "import_lock.locked_at", +] as const; diff --git a/typescript/server/src/lib/db-formats/notification.ts b/typescript/server/src/lib/db-formats/notification.ts index 89c0062f7..3da92608b 100644 --- a/typescript/server/src/lib/db-formats/notification.ts +++ b/typescript/server/src/lib/db-formats/notification.ts @@ -1,9 +1,22 @@ import type { MONGO_NotificationDocument, NotificationBody } from "tachi-common"; -import type { Notification } from "tachi-db"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; +import { type Selection } from "kysely"; +import { type Database } from "tachi-db"; -export function ToNotificationDocument(row: Notification): MONGO_NotificationDocument { +export const SELECT_NOTIFICATION = [ + "notification.row_id", + "notification.title", + "notification.sent_to", + "notification.sent_at", + "notification.read", + "notification.kind", + "notification.payload", +] as const; + +export function ToNotificationDocument( + row: Selection, +): MONGO_NotificationDocument { const body = row.payload as NotificationBody; return { diff --git a/typescript/server/src/lib/db-formats/orphan-score.ts b/typescript/server/src/lib/db-formats/orphan-score.ts new file mode 100644 index 000000000..4067d804d --- /dev/null +++ b/typescript/server/src/lib/db-formats/orphan-score.ts @@ -0,0 +1,12 @@ +export const SELECT_ORPHAN_SCORE = [ + "orphan_score.row_id", + "orphan_score.user_id", + "orphan_score.import_id", + "orphan_score.orphan_id", + "orphan_score.import_type", + "orphan_score.game_group", + "orphan_score.data", + "orphan_score.context", + "orphan_score.time_inserted", + "orphan_score.error_message", +] as const; diff --git a/typescript/server/src/lib/db-formats/pb.ts b/typescript/server/src/lib/db-formats/pb.ts index c6046fc41..12e5ca1ef 100644 --- a/typescript/server/src/lib/db-formats/pb.ts +++ b/typescript/server/src/lib/db-formats/pb.ts @@ -39,6 +39,26 @@ export interface PbDocumentJoinRow { leaderboard_out_of: number; } +/** All columns from `pb` for single-table reads (e.g. updating `calculated_data`). */ +export const SELECT_PB_ROW = [ + "pb.row_id", + "pb.user_id", + "pb.chart_id", + "pb.lens", + "pb.data", + "pb.derived_data", + "pb.calculated_data", + "pb.judgements", + "pb.ranking_value", + "pb.ranking_value_tb1", + "pb.ranking_value_tb2", + "pb.ranking_value_tb3", + "pb.ranking_value_tb4", + "pb.ranking_value_tb5", + "pb.highlight", + "pb.time_achieved", +] as const; + /** Columns for `pb` joined with `chart` and `song` (same shape as {@link PbDocumentJoinRow}). */ export const SELECT_PB_DOCUMENT_JOIN = [ "pb.row_id", diff --git a/typescript/server/src/lib/db-formats/priv-account-credential.ts b/typescript/server/src/lib/db-formats/priv-account-credential.ts new file mode 100644 index 000000000..1ae3db2e9 --- /dev/null +++ b/typescript/server/src/lib/db-formats/priv-account-credential.ts @@ -0,0 +1,5 @@ +export const SELECT_PRIV_ACCOUNT_CREDENTIAL = [ + "priv_account_credential.user_id", + "priv_account_credential.password", + "priv_account_credential.email", +] as const; diff --git a/typescript/server/src/lib/db-formats/quest.ts b/typescript/server/src/lib/db-formats/quest.ts new file mode 100644 index 000000000..2ec46208b --- /dev/null +++ b/typescript/server/src/lib/db-formats/quest.ts @@ -0,0 +1,34 @@ +import { type Selection } from "kysely"; +import { type Database } from "tachi-db"; + +export const SELECT_QUEST = [ + "quest.id", + "quest.game", + "quest.name", + "quest.description", + "quest.quest_data", +] as const; + +export const SELECT_QUEST_SUB = [ + "quest_sub.quest_id", + "quest_sub.user_id", + "quest_sub.progress", + "quest_sub.last_interaction", + "quest_sub.achieved", + "quest_sub.time_achieved", + "quest_sub.was_instantly_achieved", +] as const; + +/** `quest_sub` joined with `quest` for `quest.game` as `quest_game`. */ +export const SELECT_QUEST_SUB_WITH_QUEST_GAME = [ + ...SELECT_QUEST_SUB, + "quest.game as quest_game", +] as const; + +export type QuestRow = Selection; +export type QuestSubRow = Selection; +export type QuestSubWithQuestGameRow = Selection< + Database, + "quest" | "quest_sub", + (typeof SELECT_QUEST_SUB_WITH_QUEST_GAME)[number] +>; diff --git a/typescript/server/src/lib/db-formats/questline.ts b/typescript/server/src/lib/db-formats/questline.ts index 2ca92fd1c..f0ba9320e 100644 --- a/typescript/server/src/lib/db-formats/questline.ts +++ b/typescript/server/src/lib/db-formats/questline.ts @@ -1,6 +1,5 @@ -import type { MONGO_QuestlineDocument } from "tachi-common"; -import { V3ToGamePT } from "tachi-common"; import { type Selection } from "kysely"; +import { type MONGO_QuestlineDocument, V3ToGamePT } from "tachi-common"; import { type Database } from "tachi-db"; export const SELECT_QUESTLINE = [ @@ -10,6 +9,14 @@ export const SELECT_QUESTLINE = [ "questline.description as questline_description", ] as const; +/** Unaliased columns for joins where the API maps `questline.*` fields directly. */ +export const SELECT_QUESTLINE_ROW = [ + "questline.id", + "questline.game", + "questline.name", + "questline.description", +] as const; + export type QuestlineRow = Selection; export function ToQuestlineDocument( diff --git a/typescript/server/src/lib/db-formats/song.ts b/typescript/server/src/lib/db-formats/song.ts index 50401a905..ed7ca5827 100644 --- a/typescript/server/src/lib/db-formats/song.ts +++ b/typescript/server/src/lib/db-formats/song.ts @@ -15,6 +15,22 @@ export const SELECT_SONG_DOCUMENT = [ "song.game_group as song_game_group", ] as const; +/** Full `song` row for single-table queries (e.g. title search). */ +export const SELECT_SONG_ROW = [ + "song.id", + "song.legacy_id", + "song.game_group", + "song.title", + "song.artist", + "song.search_terms", + "song.alt_titles", + "song.fts_document", + "song.textsearch", + "song.data", +] as const; + +export type SongRow = Selection; + /** * Fetches a song by its legacy numeric ID (from the URL / Mongo era), including * `search_terms` / `alt_titles` arrays, and returns a fully-formed MONGO_SongDocument @@ -107,3 +123,14 @@ export function ToSongDocument( data: row.song_data as SongDocumentData[typeof row.song_game_group], }; } + +export function ToSongDocumentFromRow(row: SongRow): MONGO_SongDocument { + return { + id: row.legacy_id, + title: row.title, + artist: row.artist, + searchTerms: row.search_terms, + altTitles: row.alt_titles, + data: row.data as SongDocumentData[typeof row.game_group], + }; +} diff --git a/typescript/server/src/lib/db-formats/target-documents.ts b/typescript/server/src/lib/db-formats/target-documents.ts index ae4fd4f2c..28331442b 100644 --- a/typescript/server/src/lib/db-formats/target-documents.ts +++ b/typescript/server/src/lib/db-formats/target-documents.ts @@ -6,9 +6,12 @@ import { type MONGO_QuestSubscriptionDocument, V3ToGamePT, } from "tachi-common"; -import { type Game, type Goal, type GoalSub, type Quest, type QuestSub } from "tachi-db"; +import { type Game } from "tachi-db"; -export function ToGoalDocument(row: Goal): MONGO_GoalDocument { +import { type GoalRow, type GoalSubWithGoalGameRow } from "./goal"; +import { type QuestRow, type QuestSubWithQuestGameRow } from "./quest"; + +export function ToGoalDocument(row: GoalRow): MONGO_GoalDocument { const { game, playtype } = V3ToGamePT(row.game); return { @@ -22,9 +25,9 @@ export function ToGoalDocument(row: Goal): MONGO_GoalDocument { } export function ToGoalSubscriptionDocument( - row: { goal_game: Game } & GoalSub, + row: GoalSubWithGoalGameRow, ): MONGO_GoalSubscriptionDocument { - const { game, playtype } = V3ToGamePT(row.goal_game); + const { game, playtype } = V3ToGamePT(row.goal_game as Game); const base = { game, @@ -53,7 +56,7 @@ export function ToGoalSubscriptionDocument( }; } -export function ToQuestDocument(row: Quest): MONGO_QuestDocument { +export function ToQuestDocument(row: QuestRow): MONGO_QuestDocument { const { game, playtype } = V3ToGamePT(row.game); return { @@ -67,9 +70,9 @@ export function ToQuestDocument(row: Quest): MONGO_QuestDocument { } export function ToQuestSubscriptionDocument( - row: { quest_game: Game } & QuestSub, + row: QuestSubWithQuestGameRow, ): MONGO_QuestSubscriptionDocument { - const { game, playtype } = V3ToGamePT(row.quest_game); + const { game, playtype } = V3ToGamePT(row.quest_game as Game); const base = { game, diff --git a/typescript/server/src/lib/db-formats/ugpt-settings.ts b/typescript/server/src/lib/db-formats/ugpt-settings.ts index e826819ce..6820743c8 100644 --- a/typescript/server/src/lib/db-formats/ugpt-settings.ts +++ b/typescript/server/src/lib/db-formats/ugpt-settings.ts @@ -1,5 +1,5 @@ import DB from "#services/pg/db"; -import { type Selectable } from "kysely"; +import { type Selection } from "kysely"; import { type GameGroup, GamePTToV3, @@ -11,7 +11,23 @@ import { } from "tachi-common"; import { type Database } from "tachi-db"; -export type GameSettingsRow = Selectable; +export const SELECT_GAME_SETTINGS = [ + "game_settings.user_id", + "game_settings.game", + "game_settings.pf_preferred_score_alg", + "game_settings.pf_preferred_session_alg", + "game_settings.pf_preferred_profile_alg", + "game_settings.pf_preferred_default_enum", + "game_settings.pf_default_table", + "game_settings.pf_preferred_ranking", + "game_settings.data", +] as const; + +export type GameSettingsRow = Selection< + Database, + "game_settings", + (typeof SELECT_GAME_SETTINGS)[number] +>; export function ToUGPTSettingsDocument( row: GameSettingsRow, @@ -54,9 +70,9 @@ export async function GetUGPTSettingsDocument( const v3Game = GamePTToV3(game, playtype); const row = await DB.selectFrom("game_settings") - .selectAll() - .where("user_id", "=", userID) - .where("game", "=", v3Game) + .select(SELECT_GAME_SETTINGS) + .where("game_settings.user_id", "=", userID) + .where("game_settings.game", "=", v3Game) .executeTakeFirst(); if (!row) { diff --git a/typescript/server/src/lib/handlers/sigterm.ts b/typescript/server/src/lib/handlers/sigterm.ts index 6cadc47b1..af9162cca 100644 --- a/typescript/server/src/lib/handlers/sigterm.ts +++ b/typescript/server/src/lib/handlers/sigterm.ts @@ -1,16 +1,27 @@ import type http from "http"; -import type https from "https"; import { log } from "#lib/log/log"; import { CloseScoreImportQueue } from "#lib/score-import/worker/queue"; import { ClosePgConnection } from "#services/pg/db"; import { CloseRedisConnection } from "#services/redis/redis"; -export function HandleSIGTERMGracefully(instance?: http.Server | https.Server) { +export function HandleSIGTERMGracefully(instance?: http.Server, metricsInstance?: http.Server) { log.info({ shutdownInfo: true }, "SIGTERM Received, closing program."); if (instance) { - instance.close(() => CloseEverythingElse()); + instance.close(() => { + if (metricsInstance) { + metricsInstance.close(() => { + void CloseEverythingElse(); + }); + } else { + void CloseEverythingElse(); + } + }); + } else if (metricsInstance) { + metricsInstance.close(() => { + void CloseEverythingElse(); + }); } else { return CloseEverythingElse(); } diff --git a/typescript/server/src/lib/jobs/drain-dirty-queues.ts b/typescript/server/src/lib/jobs/drain-dirty-queues.ts new file mode 100644 index 000000000..141741193 --- /dev/null +++ b/typescript/server/src/lib/jobs/drain-dirty-queues.ts @@ -0,0 +1,121 @@ +import { log } from "#lib/log/log"; +import { ProcessPBs } from "#lib/score-import/framework/pb/process-pbs"; +import { rederiveScoresForChart } from "#lib/score-import/framework/pb/rederive-scores"; +import DB from "#services/pg/db"; +import { type GameGroup, type integer, type Playtype, type V3Game, V3ToGamePT } from "tachi-common"; + +const PB_DIRTY_BATCH = 1000; +const SCORE_REDERIVE_BATCH = 50; + +/** + * Drain the `pb_dirty` queue: group entries by (game, playtype, user_id), + * call ProcessPBs per group, and delete processed rows. + */ +export async function drainPbDirty(): Promise { + const rows = await DB.selectFrom("pb_dirty") + .innerJoin("chart", "chart.id", "pb_dirty.chart_id") + .select(["pb_dirty.user_id", "pb_dirty.chart_id", "chart.game as chart_game"]) + .orderBy("pb_dirty.enqueued_at", "asc") + .limit(PB_DIRTY_BATCH) + .execute(); + + if (rows.length === 0) { + return 0; + } + + const groups = new Map< + string, + { chartIDs: Set; game: GameGroup; playtype: Playtype; userID: integer } + >(); + + for (const row of rows) { + const { game, playtype } = V3ToGamePT(row.chart_game as V3Game); + const key = `${game}:${playtype}:${row.user_id}`; + + let group = groups.get(key); + + if (!group) { + group = { game, playtype, userID: row.user_id, chartIDs: new Set() }; + groups.set(key, group); + } + + group.chartIDs.add(row.chart_id); + } + + for (const group of groups.values()) { + // eslint-disable-next-line no-await-in-loop + await ProcessPBs(group.game, group.playtype, group.userID, group.chartIDs, log); + } + + const processedPairs = rows.map((r) => [r.user_id, r.chart_id] as const); + + for (const [userId, chartId] of processedPairs) { + // eslint-disable-next-line no-await-in-loop + await DB.deleteFrom("pb_dirty") + .where("pb_dirty.user_id", "=", userId) + .where("pb_dirty.chart_id", "=", chartId) + .execute(); + } + + log.info(`Drained ${rows.length} pb_dirty entries across ${groups.size} user/game groups.`); + + return rows.length; +} + +/** + * Drain the `score_rederive` queue: for each chart, re-derive all scores, + * then delete the queue entry. The score UPDATEs will fire `score_pb_dirty` + * triggers, so PB recalculation happens automatically. + */ +export async function drainScoreRederive(): Promise { + const rows = await DB.selectFrom("score_rederive") + .select(["score_rederive.chart_id"]) + .orderBy("score_rederive.enqueued_at", "asc") + .limit(SCORE_REDERIVE_BATCH) + .execute(); + + if (rows.length === 0) { + return 0; + } + + let totalScores = 0; + + for (const row of rows) { + // eslint-disable-next-line no-await-in-loop + const updated = await rederiveScoresForChart(row.chart_id, log); + + totalScores += updated; + + // eslint-disable-next-line no-await-in-loop + await DB.deleteFrom("score_rederive") + .where("score_rederive.chart_id", "=", row.chart_id) + .execute(); + } + + log.info( + `Drained ${rows.length} score_rederive entries, re-derived ${totalScores} total scores.`, + ); + + return rows.length; +} + +/** + * Delete `pb_dirty` rows for the given user + chart IDs. Call this after + * a synchronous `ProcessPBs` to prevent the async worker from redundantly + * reprocessing the same pairs. + */ +export async function clearPbDirtyForUser( + userID: integer, + chartIDs: Iterable, +): Promise { + const ids = [...chartIDs]; + + if (ids.length === 0) { + return; + } + + await DB.deleteFrom("pb_dirty") + .where("pb_dirty.user_id", "=", userID) + .where("pb_dirty.chart_id", "in", ids) + .execute(); +} diff --git a/typescript/server/src/lib/jobs/inline-job-runner/job-runner.ts b/typescript/server/src/lib/jobs/inline-job-runner/job-runner.ts index 5d1af8b7a..11f2d0a07 100644 --- a/typescript/server/src/lib/jobs/inline-job-runner/job-runner.ts +++ b/typescript/server/src/lib/jobs/inline-job-runner/job-runner.ts @@ -11,6 +11,7 @@ import { Queue, Worker } from "bullmq"; import { UpdateAILevels } from "../bms-ai-table-sync"; import { DeorphanScoresMain } from "../deorphan-scores"; +import { drainPbDirty, drainScoreRederive } from "../drain-dirty-queues"; import { RebuildFolderChartLookupJob } from "../rebuild-folder-chart-lookup"; interface Job { @@ -41,6 +42,44 @@ const jobs: Array = [ cronFormat: "1 0 * * *", run: DeorphanScoresMain, }, + { + name: "Drain pb_dirty", + cronFormat: "* * * * *", + run: async () => { + let drained = 0; + + // Keep draining until the queue is empty or we hit a safety cap. + + while (true) { + // eslint-disable-next-line no-await-in-loop + const n = await drainPbDirty(); + + if (n === 0) { + break; + } + + drained += n; + + if (drained >= 10_000) { + break; + } + } + }, + }, + { + name: "Drain score_rederive", + cronFormat: "*/5 * * * *", + run: async () => { + while (true) { + // eslint-disable-next-line no-await-in-loop + const n = await drainScoreRederive(); + + if (n === 0) { + break; + } + } + }, + }, ]; // if kamaitachi or omnitachi diff --git a/typescript/server/src/lib/notifications/notifications.test.ts b/typescript/server/src/lib/notifications/notifications.test.ts index c2a0914c2..4f8ada58c 100644 --- a/typescript/server/src/lib/notifications/notifications.test.ts +++ b/typescript/server/src/lib/notifications/notifications.test.ts @@ -1,3 +1,4 @@ +import { SELECT_NOTIFICATION } from "#lib/db-formats/notification"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; @@ -18,8 +19,8 @@ describe("SendNotification", () => { }); const row = await DB.selectFrom("notification") - .selectAll() - .where("sent_to", "=", userId) + .select(SELECT_NOTIFICATION) + .where("notification.sent_to", "=", userId) .executeTakeFirstOrThrow(); expect(row.title).toBe("Quest updated"); diff --git a/typescript/server/src/lib/score-import/framework/goals/goals.ts b/typescript/server/src/lib/score-import/framework/goals/goals.ts index 1d213070e..db8fd9dfa 100644 --- a/typescript/server/src/lib/score-import/framework/goals/goals.ts +++ b/typescript/server/src/lib/score-import/framework/goals/goals.ts @@ -6,13 +6,13 @@ import type { MONGO_GoalSubscriptionDocument, } from "tachi-common"; +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents"; import { EvaluateGoalForUser, GetRelevantGoals } from "#lib/targets/goals"; import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; import DB from "#services/pg/db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { sql } from "kysely"; -import type { Game } from "tachi-db"; /** * Update a user's progress on all of their set goals. @@ -213,7 +213,7 @@ export async function ProcessGoal( export async function UpdateGoalsInFolder(folderID: string, log: KtLogger) { const goalRows = await DB.selectFrom("goal") - .selectAll() + .select(SELECT_GOAL) .where(sql`goal.charts->>'type'`, "=", "folder") .where(sql`goal.charts->>'data'`, "=", folderID) .execute(); @@ -230,19 +230,13 @@ export async function UpdateGoalsInFolder(folderID: string, log: KtLogger) { const subRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.goal_id", "in", goalIds) .execute(); log.info(`Updating ${subRows.length} goal subs for ${folderID}`); - const goalSubs = subRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); const ugsMap = new Map>(); diff --git a/typescript/server/src/lib/score-import/framework/import-locks/lock.ts b/typescript/server/src/lib/score-import/framework/import-locks/lock.ts index ee69d044d..545bc1ddd 100644 --- a/typescript/server/src/lib/score-import/framework/import-locks/lock.ts +++ b/typescript/server/src/lib/score-import/framework/import-locks/lock.ts @@ -1,6 +1,7 @@ import type { integer } from "tachi-common"; import { ONE_DAY, ONE_HOUR } from "#lib/constants/time"; +import { SELECT_IMPORT_LOCK } from "#lib/db-formats/import-lock"; import { log } from "#lib/log/log"; import DB from "#services/pg/db"; @@ -24,8 +25,8 @@ export function CheckAndSetOngoingImportLock(userID: integer): Promise let row = await trx .selectFrom("import_lock") - .selectAll() - .where("user_id", "=", userID) + .select(SELECT_IMPORT_LOCK) + .where("import_lock.user_id", "=", userID) .forUpdate() .executeTakeFirstOrThrow(); @@ -54,8 +55,8 @@ export function CheckAndSetOngoingImportLock(userID: integer): Promise row = await trx .selectFrom("import_lock") - .selectAll() - .where("user_id", "=", userID) + .select(SELECT_IMPORT_LOCK) + .where("import_lock.user_id", "=", userID) .forUpdate() .executeTakeFirstOrThrow(); } diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts index 0ea665d88..9d4d87efd 100644 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts +++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts @@ -2,6 +2,7 @@ import type { KtLogger } from "#lib/log/log"; import type { GameGroup, ImportTypes, integer } from "tachi-common"; import type { OrphanScore as PgOrphanScoreRow } from "tachi-db"; +import { SELECT_ORPHAN_SCORE } from "#lib/db-formats/orphan-score"; import DB from "#services/pg/db"; import { GetBlacklist } from "#utils/queries/blacklist"; import { GetUserWithID } from "#utils/user"; @@ -226,15 +227,15 @@ export async function ReprocessOrphan( } export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger) { - let q = DB.selectFrom("orphan_score").selectAll(); + let q = DB.selectFrom("orphan_score").select(SELECT_ORPHAN_SCORE); if (filter.userID !== undefined) { - q = q.where("user_id", "=", filter.userID); + q = q.where("orphan_score.user_id", "=", filter.userID); } if (filter.chartSha256 !== undefined) { q = q - .where("import_type", "=", "ir/beatoraja") + .where("orphan_score.import_type", "=", "ir/beatoraja") .where( sql`(orphan_score.context::jsonb->'chart'->>'sha256') = ${filter.chartSha256}`, ); diff --git a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts index ce1e0e59f..dc96deef2 100644 --- a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -2,6 +2,7 @@ import type { KtLogger } from "#lib/log/log"; import type { Game } from "tachi-db"; import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { SELECT_PB_ROW } from "#lib/db-formats/pb"; import { type ScoreDocumentJoinRow, SELECT_SCORE_DOCUMENT, @@ -179,10 +180,10 @@ export async function UpdateChartRanking(game: GameGroup, playtype: Playtype, ch } const pbRow = await DB.selectFrom("pb") - .selectAll() - .where("user_id", "=", userId) - .where("chart_id", "=", chartRow.id) - .where("lens", "is", null) + .select(SELECT_PB_ROW) + .where("pb.user_id", "=", userId) + .where("pb.chart_id", "=", chartRow.id) + .where("pb.lens", "is", null) .executeTakeFirst(); if (!pbRow) { diff --git a/typescript/server/src/lib/score-import/framework/pb/rederive-scores.ts b/typescript/server/src/lib/score-import/framework/pb/rederive-scores.ts new file mode 100644 index 000000000..40dd79dec --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/pb/rederive-scores.ts @@ -0,0 +1,101 @@ +import type { KtLogger } from "#lib/log/log"; + +import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; +import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; +import { + type ScoreDocumentJoinRow, + SELECT_SCORE_DOCUMENT, + ToScoreDocument, +} from "#lib/db-formats/score"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { GetGPTString, type V3Game, V3ToGamePT } from "tachi-common"; + +const BATCH_SIZE = 500; + +/** + * Re-derive `derived_data` and `calculated_data` for every score on the + * given chart (by Postgres `chart.id`). This is called when chart data + * changes in a way that affects score derivation (detected via + * `derivation_checksum`). + * + * Each score UPDATE fires the `score_pb_dirty` trigger, so PB + * recalculation is handled automatically. + */ +export async function rederiveScoresForChart(chartId: string, log: KtLogger): Promise { + const chartRow = await DB.selectFrom("chart") + .innerJoin("song", "song.id", "chart.song_id") + .select(SELECT_CHART) + .where("chart.id", "=", chartId) + .executeTakeFirst(); + + if (!chartRow) { + log.warn({ chartId }, "rederiveScoresForChart: chart not found, skipping."); + return 0; + } + + const chart = ToChartDocument(chartRow); + const { game, playtype } = V3ToGamePT(chartRow.chart_game as V3Game); + const gpt = GetGPTString(game, playtype); + const impl = GPT_SERVER_IMPLEMENTATIONS[gpt]; + + let totalUpdated = 0; + let offset = 0; + + while (true) { + // eslint-disable-next-line no-await-in-loop + const rows = await DB.selectFrom("score") + .innerJoin("chart", "chart.id", "score.chart_id") + .innerJoin("song", "song.id", "chart.song_id") + .leftJoin("import", "import.id", "score.import_id") + .select(SELECT_SCORE_DOCUMENT) + .where("score.chart_id", "=", chartId) + .orderBy("score.id", "asc") + .limit(BATCH_SIZE) + .offset(offset) + .execute(); + + if (rows.length === 0) { + break; + } + + for (const row of rows) { + const score = ToScoreDocument(row as ScoreDocumentJoinRow); + + // GPT-specific union types aren't callable with the generic GPTString, + // so we cast through `any` the same way CreateScoreCalcData does. + const derivedData = (impl.scoreDeriver as any)(score.scoreData, chart); + + const newScoreData = { + ...score.scoreData, + ...derivedData, + }; + + const scoreCalcs = impl.scoreCalcs as any; + const calculatedData = scoreCalcs(newScoreData, derivedData, chart); + + const { derived } = mongoScoreDataToPg(gpt, newScoreData as any); + + // eslint-disable-next-line no-await-in-loop + await DB.updateTable("score") + .set({ + derived_data: JSON.stringify(derived), + calculated_data: JSON.stringify(calculatedData), + }) + .where("score.id", "=", score.scoreID) + .execute(); + + totalUpdated++; + } + + offset += rows.length; + + if (rows.length < BATCH_SIZE) { + break; + } + } + + log.info({ chartId, totalUpdated }, `Re-derived ${totalUpdated} score(s) for chart.`); + + return totalUpdated; +} diff --git a/typescript/server/src/lib/score-import/framework/quests/quests.ts b/typescript/server/src/lib/score-import/framework/quests/quests.ts index e5895d7a4..df60ff88d 100644 --- a/typescript/server/src/lib/score-import/framework/quests/quests.ts +++ b/typescript/server/src/lib/score-import/framework/quests/quests.ts @@ -1,21 +1,21 @@ import type { KtLogger } from "#lib/log/log"; -import type { - GameGroup, - GoalImportInfo, - integer, - MONGO_QuestDocument, - MONGO_QuestSubscriptionDocument, - Playtype, - QuestImportInfo, -} from "tachi-common"; -import { GamePTToV3 } from "tachi-common"; +import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents"; import { EvaluateQuestProgress, GetGoalIDsFromQuest } from "#lib/targets/quests"; import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; import DB from "#services/pg/db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; -import type { Game } from "tachi-db"; +import { + type GameGroup, + GamePTToV3, + type GoalImportInfo, + type integer, + type MONGO_QuestDocument, + type MONGO_QuestSubscriptionDocument, + type Playtype, + type QuestImportInfo, +} from "tachi-common"; export async function UpdateUsersQuests( importGoalInfo: Array, @@ -74,9 +74,9 @@ export async function UpdateQuestsForUser( }; const setPayload: { - progress: number; achieved: boolean; last_interaction?: string; + progress: number; time_achieved?: string | null; } = { progress, @@ -129,27 +129,24 @@ async function GetRelevantQuests( const questSubRows = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", userID) .where("quest.game", "in", v3Games) .execute(); log.debug(`Found ${questSubRows.length} quest-subs.`); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - ...r, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); const questIds = [...new Set(questSubRows.map((r) => r.quest_id))]; const questRows = questIds.length === 0 ? [] - : await DB.selectFrom("quest").selectAll().where("id", "in", questIds).execute(); + : await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "in", questIds) + .execute(); const quests = questRows .map(ToQuestDocument) diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts index 5fbe59291..ef95a3504 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts @@ -2,12 +2,14 @@ import type { KtLogger } from "#lib/log/log"; import type { ScoreImportJob } from "#lib/score-import/worker/types"; import { LoadImportDocumentById } from "#lib/db-formats/import-document"; +import { clearPbDirtyForUser } from "#lib/jobs/drain-dirty-queues"; import { runWithImportContext } from "#lib/score-import/framework/import-run-context"; import { deleteImportRun, ensureImportStub, } from "#lib/score-import/framework/pg/ensure-import-stub"; import { finalizeImportToPostgres } from "#lib/score-import/framework/pg/finalize-import-pg"; +import { observeScoreImportDuration } from "#server/prometheus"; import DB from "#services/pg/db"; import { GetMillisecondsSince } from "#utils/misc"; import { GetUserWithID } from "#utils/user"; @@ -229,6 +231,8 @@ export default async function ScoreImportMain( }); }); + observeScoreImportDuration(importType, Date.now() - timeStarted); + const loaded = await LoadImportDocumentById(importID); if (!loaded) { @@ -307,6 +311,8 @@ export async function HandlePostImportSteps( ), ); + await clearPbDirtyForUser(user.id, chartIDs); + const pbTime = GetMillisecondsSince(pbTimeStart); const pbTimeRel = pbTime / Math.max(1, chartIDs.size); diff --git a/typescript/server/src/lib/score-import/framework/sessions/sessions.ts b/typescript/server/src/lib/score-import/framework/sessions/sessions.ts index 32bb806f9..a7bb529ba 100644 --- a/typescript/server/src/lib/score-import/framework/sessions/sessions.ts +++ b/typescript/server/src/lib/score-import/framework/sessions/sessions.ts @@ -2,7 +2,7 @@ import type { Game } from "tachi-db"; import { ONE_HOUR } from "#lib/constants/time"; import { LoadScoreDocumentById } from "#lib/db-formats/score"; -import { LoadSessionDocumentById } from "#lib/db-formats/session"; +import { LoadSessionDocumentById, SELECT_SESSION_DOCUMENT } from "#lib/db-formats/session"; import { AppendLogCtx, type KtLogger, log } from "#lib/log/log"; import DB from "#services/pg/db"; import { GetChartForIDGuaranteed } from "#utils/db"; @@ -277,18 +277,18 @@ export async function LoadScoresIntoSessions( const rangeEnd = endOfGroup + TWO_HOURS; const nearbySession = await DB.selectFrom("session") - .selectAll() - .where("user_id", "=", userID) - .where("game", "=", v3Game) + .select(SELECT_SESSION_DOCUMENT) + .where("session.user_id", "=", userID) + .where("session.game", "=", v3Game) .where((eb) => eb.or([ eb.and([ - eb("time_started", ">=", UnixMillisecondsToISO8601(rangeStart)), - eb("time_started", "<", UnixMillisecondsToISO8601(rangeEnd)), + eb("session.time_started", ">=", UnixMillisecondsToISO8601(rangeStart)), + eb("session.time_started", "<", UnixMillisecondsToISO8601(rangeEnd)), ]), eb.and([ - eb("time_ended", ">=", UnixMillisecondsToISO8601(rangeStart)), - eb("time_ended", "<", UnixMillisecondsToISO8601(rangeEnd)), + eb("session.time_ended", ">=", UnixMillisecondsToISO8601(rangeStart)), + eb("session.time_ended", "<", UnixMillisecondsToISO8601(rangeEnd)), ]), ]), ) diff --git a/typescript/server/src/lib/score-mutation/delete-scores.ts b/typescript/server/src/lib/score-mutation/delete-scores.ts index 5af7fdaaa..7ba7f92c7 100644 --- a/typescript/server/src/lib/score-mutation/delete-scores.ts +++ b/typescript/server/src/lib/score-mutation/delete-scores.ts @@ -3,6 +3,8 @@ import { SELECT_SCORE_DOCUMENT, ToScoreDocument, } from "#lib/db-formats/score"; +import { SELECT_SESSION_DOCUMENT } from "#lib/db-formats/session"; +import { clearPbDirtyForUser } from "#lib/jobs/drain-dirty-queues"; import { log } from "#lib/log/log"; import { CreateSessionCalcData } from "#lib/score-import/framework/calculated-data/session"; import { GetAndUpdateUsersGoals } from "#lib/score-import/framework/goals/goals"; @@ -69,8 +71,8 @@ export async function DeleteMultipleScores( await DB.deleteFrom("session").where("id", "=", sessionId).execute(); } else { const sessionRow = await DB.selectFrom("session") - .selectAll() - .where("id", "=", sessionId) + .select(SELECT_SESSION_DOCUMENT) + .where("session.id", "=", sessionId) .executeTakeFirst(); if (!sessionRow) { @@ -93,6 +95,7 @@ export async function DeleteMultipleScores( for (const score of scores) { await ProcessPBs(score.game, score.playtype, score.userID, new Set([score.chartID]), log); + await clearPbDirtyForUser(score.userID, [score.chartID]); if (blacklist) { const alreadyBlacklisted = await DB.selectFrom("score_blacklist") diff --git a/typescript/server/src/lib/setup/config.ts b/typescript/server/src/lib/setup/config.ts index 0618afd6b..86ee4f98e 100644 --- a/typescript/server/src/lib/setup/config.ts +++ b/typescript/server/src/lib/setup/config.ts @@ -46,7 +46,6 @@ const configSchema = z.object({ MIN_OAUTH2_INFO: oauth2Schema.optional(), ARC_AUTH_TOKEN: z.string().optional(), MYT_AUTH_TOKEN: z.string().optional(), - ENABLE_SERVER_HTTPS: z.boolean().optional(), CLIENT_DEV_SERVER: z.string().nullable().optional(), RATE_LIMIT: z.number().int().positive().default(500), OAUTH_CLIENT_CAP: z.number().int().positive().default(15), @@ -54,7 +53,7 @@ const configSchema = z.object({ USE_EXTERNAL_SCORE_IMPORT_WORKER: z.boolean().default(false), EXTERNAL_SCORE_IMPORT_WORKER_CONCURRENCY: z.number().int().positive().optional(), ALLOW_RUNNING_OFFLINE: z.boolean().optional(), - ENABLE_METRICS: z.boolean().default(false), + ENABLE_METRICS: z.boolean().default(true), EMAIL_CONFIG: z .object({ FROM: z.string(), @@ -323,7 +322,6 @@ const configFromEnv: unknown = { ...(minOauth !== undefined ? { MIN_OAUTH2_INFO: minOauth } : {}), ARC_AUTH_TOKEN: opt("TACHI_ARC_AUTH_TOKEN"), MYT_AUTH_TOKEN: opt("TACHI_MYT_AUTH_TOKEN"), - ENABLE_SERVER_HTTPS: parseBool("TACHI_ENABLE_SERVER_HTTPS"), ...(clientDev !== undefined ? { CLIENT_DEV_SERVER: clientDev } : {}), RATE_LIMIT: parseIntEnv("TACHI_RATE_LIMIT", 500), OAUTH_CLIENT_CAP: parseIntEnv("TACHI_OAUTH_CLIENT_CAP", 15), @@ -334,7 +332,7 @@ const configFromEnv: unknown = { 10, ), ALLOW_RUNNING_OFFLINE: parseBool("TACHI_ALLOW_RUNNING_OFFLINE"), - ENABLE_METRICS: parseBool("TACHI_ENABLE_METRICS", false) ?? false, + ENABLE_METRICS: parseBool("TACHI_ENABLE_METRICS", true) ?? true, ...(emailCfg !== undefined ? { EMAIL_CONFIG: emailCfg } : {}), USC_QUEUE_SIZE: parseIntEnv("TACHI_USC_QUEUE_SIZE", 3), BEATORAJA_QUEUE_SIZE: parseIntEnv("TACHI_BEATORAJA_QUEUE_SIZE", 3), diff --git a/typescript/server/src/lib/targets/goal-utils.ts b/typescript/server/src/lib/targets/goal-utils.ts index 7f1b1a689..7c34e0cc7 100644 --- a/typescript/server/src/lib/targets/goal-utils.ts +++ b/typescript/server/src/lib/targets/goal-utils.ts @@ -226,7 +226,9 @@ export async function ValidateGoalChartsAndCriteria( ); } - const multiCharts = await Promise.all(charts.data.map((chartID) => GetChartById(v3Game, chartID))); + const multiCharts = await Promise.all( + charts.data.map((chartID) => GetChartById(v3Game, chartID)), + ); if (multiCharts.some((c) => c === undefined)) { throw new Error( diff --git a/typescript/server/src/lib/targets/goals.ts b/typescript/server/src/lib/targets/goals.ts index 67775e12a..e30eade79 100644 --- a/typescript/server/src/lib/targets/goals.ts +++ b/typescript/server/src/lib/targets/goals.ts @@ -2,6 +2,8 @@ import type { GoalCriteriaFormatter } from "#game-implementations/types"; import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { SubscribeFailReasons } from "#lib/constants/err-codes"; +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; +import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { ToGoalDocument, ToGoalSubscriptionDocument, @@ -9,17 +11,16 @@ import { ToQuestSubscriptionDocument, } from "#lib/db-formats/target-documents"; import { GetFolderChartIDs } from "#lib/folders/folders.js"; +import { type KtLogger, log } from "#lib/log/log"; import { getGoalMetricValueFromPb, LoadPbsForUserOnChartsForGoal, pbMeetsGoalThreshold, } from "#lib/targets/goal-pb-queries.js"; -import { type KtLogger, log } from "#lib/log/log"; import DB from "#services/pg/db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import fjsh from "fast-json-stable-hash"; import { sql } from "kysely"; -import type { Game } from "tachi-db"; import { FormatGameGroup, type GameGroup, @@ -35,8 +36,8 @@ import { type MONGO_QuestDocument, type MONGO_QuestSubscriptionDocument, type Playtype, - V3ToGameGroup, v3AllGames, + V3ToGameGroup, } from "tachi-common"; import { CreateGoalTitle as CreateGoalName, ValidateGoalChartsAndCriteria } from "./goal-utils"; @@ -315,7 +316,10 @@ export async function ConstructGoal( async function ensureGoalRow(doc: MONGO_GoalDocument) { const v3Game = GamePTToV3(doc.game, doc.playtype); - const existed = await DB.selectFrom("goal").select("goal.id").where("id", "=", doc.goalID).executeTakeFirst(); + const existed = await DB.selectFrom("goal") + .select("goal.id") + .where("id", "=", doc.goalID) + .executeTakeFirst(); if (!existed) { await DB.insertInto("goal") @@ -352,17 +356,13 @@ export async function SubscribeToGoal( const existingSub = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.goal_id", "=", MONGO_GoalDocument.goalID) .where("goal_sub.user_id", "=", userID) .executeTakeFirst(); if (existingSub) { - const userAlreadySubscribed = ToGoalSubscriptionDocument({ - ...existingSub, - goal_game: existingSub.goal_game as Game, - }); + const userAlreadySubscribed = ToGoalSubscriptionDocument(existingSub); if (!isStandaloneAssignment) { return SubscribeFailReasons.ALREADY_SUBSCRIBED; @@ -380,16 +380,12 @@ export async function SubscribeToGoal( const updated = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.goal_id", "=", MONGO_GoalDocument.goalID) .where("goal_sub.user_id", "=", userID) .executeTakeFirstOrThrow(); - return ToGoalSubscriptionDocument({ - ...updated, - goal_game: updated.goal_game as Game, - }); + return ToGoalSubscriptionDocument(updated); } const result = await EvaluateGoalForUser(MONGO_GoalDocument, userID, log); @@ -450,9 +446,11 @@ export async function SubscribeToGoal( return goalSub; } -export async function GetQuestsThatContainGoal(goalID: string): Promise> { +export async function GetQuestsThatContainGoal( + goalID: string, +): Promise> { const rows = await DB.selectFrom("quest") - .selectAll() + .select(SELECT_QUEST) .where( sql`exists ( select 1 @@ -480,8 +478,7 @@ export async function GetQuestSubsWhichDependOnThisGoalSub( const subRows = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", goalSub.userID) .where("quest.game", "=", v3Game) .where( @@ -499,7 +496,10 @@ export async function GetQuestSubsWhichDependOnThisGoalSub( const questRows = questIds.length === 0 ? [] - : await DB.selectFrom("quest").selectAll().where("id", "in", questIds).execute(); + : await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "in", questIds) + .execute(); const questById = new Map(questRows.map((q) => [q.id, ToQuestDocument(q)])); @@ -512,16 +512,7 @@ export async function GetQuestSubsWhichDependOnThisGoalSub( return { quest, - ...ToQuestSubscriptionDocument({ - quest_id: r.quest_id, - user_id: r.user_id, - progress: r.progress, - last_interaction: r.last_interaction, - achieved: r.achieved, - time_achieved: r.time_achieved, - was_instantly_achieved: r.was_instantly_achieved, - quest_game: r.quest_game as Game, - }), + ...ToQuestSubscriptionDocument(r), }; }); } @@ -608,18 +599,12 @@ export async function UnsubscribeFromOrphanedGoalSubs( const subRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) .where("goal.game", "=", v3Game) .execute(); - const goalSubs = subRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); const maybeToRemove = await Promise.all( goalSubs.map(async (goalSub) => { @@ -675,8 +660,7 @@ export async function GetRelevantGoals( let q = DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) .where("goal.game", "in", v3GamesForGroup); @@ -692,12 +676,7 @@ export async function GetRelevantGoals( return { goals: [], goalSubsMap: new Map() }; } - const goalSubs = subRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); const goalIDs = goalSubs.map((e) => e.goalID); @@ -707,7 +686,10 @@ export async function GetRelevantGoals( chartIDsArr.push(c); } - const goalRows = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute(); + const goalRows = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIDs) + .execute(); const directMulti: Array = []; @@ -757,7 +739,7 @@ export async function GetRelevantFolderGoals(goalIDs: Array, chartIDsArr .innerJoin("folder_chart_lookup", (join) => join.on(sql`folder_chart_lookup.folder_id`, "=", sql`goal.charts->>'data'`), ) - .selectAll("goal") + .select(SELECT_GOAL) .where(sql`goal.charts->>'type'`, "=", "folder") .where("goal.id", "in", goalIDs) .where("folder_chart_lookup.chart_id", "in", chartIDsArr) @@ -781,7 +763,10 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD newGoal.goalID = newGoalID; - await DB.updateTable("goal_sub").set({ goal_id: newGoalID }).where("goal_id", "=", oldGoal.goalID).execute(); + await DB.updateTable("goal_sub") + .set({ goal_id: newGoalID }) + .where("goal_id", "=", oldGoal.goalID) + .execute(); const quests = await GetQuestsThatContainGoal(oldGoal.goalID); diff --git a/typescript/server/src/lib/targets/quests.ts b/typescript/server/src/lib/targets/quests.ts index 20614f7d1..a1ddcfb02 100644 --- a/typescript/server/src/lib/targets/quests.ts +++ b/typescript/server/src/lib/targets/quests.ts @@ -1,14 +1,12 @@ -import type { - GameGroup, - integer, - MONGO_GoalDocument, - MONGO_GoalSubscriptionDocument, - MONGO_QuestDocument, - MONGO_QuestSubscriptionDocument, - Playtype, -} from "tachi-common"; +import type { Game } from "tachi-db"; import { SubscribeFailReasons } from "#lib/constants/err-codes"; +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; +import { + SELECT_QUEST, + SELECT_QUEST_SUB, + SELECT_QUEST_SUB_WITH_QUEST_GAME, +} from "#lib/db-formats/quest"; import { ToGoalDocument, ToGoalSubscriptionDocument, @@ -20,8 +18,17 @@ import { BulkSendNotification } from "#lib/notifications/notifications"; import DB from "#services/pg/db"; import { UnixMillisecondsToISO8601 } from "#utils/time"; import { sql } from "kysely"; -import type { Game } from "tachi-db"; -import { GamePTToV3, V3ToGamePT } from "tachi-common"; +import { + type GameGroup, + GamePTToV3, + type integer, + type MONGO_GoalDocument, + type MONGO_GoalSubscriptionDocument, + type MONGO_QuestDocument, + type MONGO_QuestSubscriptionDocument, + type Playtype, + V3ToGamePT, +} from "tachi-common"; import { type EvaluatedGoalReturn, @@ -45,7 +52,10 @@ export function GetGoalIDsFromQuest(quest: MONGO_QuestDocument) { export async function GetGoalsInQuest(quest: MONGO_QuestDocument) { const goalIDs = GetGoalIDsFromQuest(quest); - const goals = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute(); + const goals = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIDs) + .execute(); if (goals.length !== goalIDs.length) { log.error( @@ -77,7 +87,10 @@ export async function GetGoalsInQuests(quests: Array) { return []; } - const goals = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute(); + const goals = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIDs) + .execute(); return goals.map(ToGoalDocument); } @@ -106,7 +119,7 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD const goals = await GetGoalsInQuest(quest); const isSubscribedToQuest = await DB.selectFrom("quest_sub") - .selectAll() + .select(SELECT_QUEST_SUB) .where("quest_sub.quest_id", "=", quest.questID) .where("quest_sub.user_id", "=", userID) .executeTakeFirst(); @@ -116,8 +129,7 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD if (isSubscribedToQuest) { const goalSubRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) .where( "goal_sub.goal_id", @@ -127,13 +139,7 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD .execute(); for (const row of goalSubRows) { - goalSubMap.set( - row.goal_id, - ToGoalSubscriptionDocument({ - ...row, - goal_game: row.goal_game as Game, - }), - ); + goalSubMap.set(row.goal_id, ToGoalSubscriptionDocument(row)); } } @@ -247,7 +253,7 @@ export async function SubscribeToQuest( | SubscribeFailReasons.ALREADY_SUBSCRIBED > { const isSubscribedToQuest = await DB.selectFrom("quest_sub") - .selectAll() + .select(SELECT_QUEST_SUB) .where("quest_sub.quest_id", "=", quest.questID) .where("quest_sub.user_id", "=", userID) .executeTakeFirst(); @@ -320,12 +326,14 @@ export async function UpdateQuestSubscriptions(questID: string) { const subscriptions = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.quest_id", "=", questID) .execute(); - const maybeQuest = await DB.selectFrom("quest").selectAll().where("quest.id", "=", questID).executeTakeFirst(); + const maybeQuest = await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "=", questID) + .executeTakeFirst(); if (!maybeQuest) { await DB.deleteFrom("quest_sub").where("quest_sub.quest_id", "=", questID).execute(); @@ -345,18 +353,7 @@ export async function UpdateQuestSubscriptions(questID: string) { const quest = ToQuestDocument(maybeQuest); - const mappedSubs = subscriptions.map((e) => - ToQuestSubscriptionDocument({ - quest_id: e.quest_id, - user_id: e.user_id, - progress: e.progress, - last_interaction: e.last_interaction, - achieved: e.achieved, - time_achieved: e.time_achieved, - was_instantly_achieved: e.was_instantly_achieved, - quest_game: e.quest_game as Game, - }), - ); + const mappedSubs = subscriptions.map((e) => ToQuestSubscriptionDocument(e)); await Promise.all(mappedSubs.map((e) => UnsubscribeFromQuest(e, quest))); @@ -395,18 +392,12 @@ export async function UnsubscribeFromQuest( const goalSubRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", questSub.userID) .where("goal_sub.goal_id", "in", goalIDs) .execute(); - const goalSubs = goalSubRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r)); await Promise.all(goalSubs.map((e) => UnsubscribeFromGoal(e, true))); } @@ -442,7 +433,7 @@ export async function GetParentQuests( } const questRows = await DB.selectFrom("quest") - .selectAll() + .select(SELECT_QUEST) .where("quest.id", "in", questSubIDs) .execute(); @@ -464,7 +455,7 @@ export async function FindStandaloneQuests(game: GameGroup, playtype: Playtype) const v3Game = GamePTToV3(game, playtype); const rows = await DB.selectFrom("quest") - .selectAll() + .select(SELECT_QUEST) .where("quest.game", "=", v3Game) .where( sql`not exists ( diff --git a/typescript/server/src/main.ts b/typescript/server/src/main.ts index 93aa9cf9b..a47d33e63 100644 --- a/typescript/server/src/main.ts +++ b/typescript/server/src/main.ts @@ -6,13 +6,12 @@ import { VERSION_PRETTY } from "#lib/constants/version"; import { HandleSIGTERMGracefully } from "#lib/handlers/sigterm"; import { log } from "#lib/log/log"; import { Env, ServerConfig, TachiConfig } from "#lib/setup/config"; -import server from "#server/server"; +import { METRICS_PORT } from "#server/prometheus"; +import server, { metricsApp } from "#server/server"; import DB from "#services/pg/db"; import fetch from "#utils/fetch"; import { GetUserWithID } from "#utils/user"; import { spawn } from "child_process"; -import fs from "fs"; -import https from "https"; import path from "path"; import { applyMigrations } from "tachi-db-migration-engine"; @@ -69,30 +68,20 @@ async function RunOnInit() { void RunOnInit(); -let instance: http.Server | https.Server; +const instance: http.Server = server.listen(Env.PORT); +log.info({ bootInfo: true }, `HTTP Listening on port ${Env.PORT}`); -if (ServerConfig.ENABLE_SERVER_HTTPS === true) { - if (Env.NODE_ENV === "production") { - log.warn( - { bootInfo: true }, - "HTTPS Mode is enabled. This should not be used in production, and you should instead run behind a reverse proxy.", - ); - } - - const privateKey = fs.readFileSync("./cert/key.pem"); - const certificate = fs.readFileSync("./cert/cert.pem"); - - const httpsServer = https.createServer({ key: privateKey, cert: certificate }, server); - - instance = httpsServer.listen(Env.PORT); - log.info({ bootInfo: true }, `HTTPS Listening on port ${Env.PORT}`); -} else { - instance = server.listen(Env.PORT); - log.info({ bootInfo: true }, `HTTP Listening on port ${Env.PORT}`); +let metricsInstance: http.Server | undefined; +if (metricsApp) { + metricsInstance = metricsApp.listen(METRICS_PORT); + log.info( + { bootInfo: true }, + `Prometheus metrics listening on port ${METRICS_PORT} (/metrics).`, + ); } process.on("SIGTERM", () => { - void HandleSIGTERMGracefully(instance); + void HandleSIGTERMGracefully(instance, metricsInstance); }); if (process.env.INVOKE_JOB_RUNNER) { diff --git a/typescript/server/src/scripts/migrate-to-postgres.ts b/typescript/server/src/scripts/migrate-to-postgres.ts index c0b9488f8..a3a448c05 100644 --- a/typescript/server/src/scripts/migrate-to-postgres.ts +++ b/typescript/server/src/scripts/migrate-to-postgres.ts @@ -225,6 +225,58 @@ function permRec(permissions: Record, key: string): boolean | n const INSERT_CHUNK = 500; +/** JSON text form of U+0000 that JSON.stringify uses; Postgres jsonb rejects this in strings. */ +const JSON_TEXT_ESCAPE_U0000 = ["\\", "u0000"].join(""); + +function jsonStringHasPostgresRejectedNul(s: string): boolean { + return s.includes("\u0000") || s.includes(JSON_TEXT_ESCAPE_U0000); +} + +/** + * Some chart_id sha256s in fervidex imports have nulbytes at the end; which are illegal in postgres serializations. + */ +function stripNulBytesIllegalInPostgres(s: string): string { + return s.replaceAll("\u0000", "").replaceAll(JSON_TEXT_ESCAPE_U0000, ""); +} + +function sanitizeOrphanScoreJsonForPostgres(value: unknown): unknown { + const json = JSON.stringify(value ?? null); + const stripped = stripNulBytesIllegalInPostgres(json); + return JSON.parse(stripped) as unknown; +} + +/** + * Logs when a value destined for Postgres (jsonb or text) contains NUL / JSON \\u0000, + * which Postgres rejects (22P05). + */ +function logPostgresNulColumnIssue( + scopeLabel: string, + columnName: string, + value: string, + meta: Record, +): void { + const idxLiteral = value.indexOf("\u0000"); + const idxEscaped = value.indexOf(JSON_TEXT_ESCAPE_U0000); + const idx = idxLiteral !== -1 ? idxLiteral : idxEscaped; + const kind = + idxLiteral !== -1 + ? "literal U+0000 in string" + : "JSON \\u0000 escape (disallowed by Postgres jsonb)"; + const snippet = + idx === -1 + ? undefined + : value.slice(Math.max(0, idx - 48), Math.min(value.length, idx + 64)); + + console.warn( + `[${scopeLabel}] column "${columnName}" ${kind} — Postgres will reject this row. Source document:`, + { + ...meta, + firstOccurrenceAtIndex: idx, + ...(snippet !== undefined ? { snippetAroundFirstOccurrence: snippet } : {}), + }, + ); +} + async function batchInsert( table: T, rows: ReadonlyArray>, @@ -429,19 +481,26 @@ async function main(): Promise { context: unknown; } - const mongoOrphans = await mongoDB.get("orphan-scores").find({}); + const mongoOrphans = await mongoDB + .get<{ _id?: unknown } & MongoOrphanScoreDocument>("orphan-scores") + .find({}); - const orphanScoreRows: Array = mongoOrphans.map((o) => ({ - orphan_id: o.orphanID, - user_id: o.userID, - import_id: null, - import_type: o.importType as ImportType, - game_group: o.game, - data: o.data, - context: o.context, - time_inserted: tsReq(o.timeInserted), - error_message: o.errMsg ?? "", - })); + const orphanScoreRows: Array = []; + for (const o of mongoOrphans) { + const errMsg = stripNulBytesIllegalInPostgres(o.errMsg ?? ""); + + orphanScoreRows.push({ + orphan_id: o.orphanID, + user_id: o.userID, + import_id: null, + import_type: o.importType as ImportType, + game_group: o.game, + data: sanitizeOrphanScoreJsonForPostgres(o.data), + context: sanitizeOrphanScoreJsonForPostgres(o.context), + time_inserted: tsReq(o.timeInserted), + error_message: errMsg, + }); + } for (let i = 0; i < orphanScoreRows.length; i = i + INSERT_CHUNK) { const chunk = orphanScoreRows.slice(i, i + INSERT_CHUNK); @@ -848,18 +907,29 @@ async function main(): Promise { // ── session ─────────────────────────────────────────────────────────────── console.log("\n[session]"); - await streamMigrate("sessions", "session", (s) => ({ - id: s.sessionID, - user_id: s.userID, - game: toGame(s.game, s.playtype), - name: s.name, - description: s.desc, - time_inserted: tsReq(s.timeInserted), - time_started: tsReq(s.timeStarted), - time_ended: tsReq(s.timeEnded), - calculated_data: JSON.stringify(s.calculatedData), - highlight: s.highlight, - })); + await streamCollection<{ _id?: unknown } & MONGO_SessionDocument>( + "sessions", + async (docs) => { + const rows: Array = []; + for (const s of docs) { + const calculated_data = JSON.stringify(s.calculatedData); + rows.push({ + id: s.sessionID, + user_id: s.userID, + game: toGame(s.game, s.playtype), + name: s.name, + description: s.desc, + time_inserted: tsReq(s.timeInserted), + time_started: tsReq(s.timeStarted), + time_ended: tsReq(s.timeEnded), + calculated_data, + highlight: s.highlight, + }); + } + await batchInsert("session", rows); + }, + "sessions", + ); // ── import + import_* children ──────────────────────────────────────────── // Streamed: 2.5M rows. One pass populates the base table and all 6 child @@ -1333,6 +1403,11 @@ async function main(): Promise { // console.warn(` [score] ${s.scoreID} belongs to no import!`); // } + if (!s.scoreData.optional) { + // obscure corrupt data, one score has no "optional" data? + s.scoreData.optional = { enumIndexes: {} }; + } + const gpt = GetGPTString(s.game, s.playtype); const { data, derived, judgements } = mongoScoreDataToPg(gpt, s.scoreData); diff --git a/typescript/server/src/security-audit/poc-folder-where-sql-raw.test.ts b/typescript/server/src/security-audit/poc-folder-where-sql-raw.test.ts new file mode 100644 index 000000000..b19098ae4 --- /dev/null +++ b/typescript/server/src/security-audit/poc-folder-where-sql-raw.test.ts @@ -0,0 +1,99 @@ +import { BuildFolderQuery } from "#lib/folders/folders.js"; +import DB from "#services/pg/db"; +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +/** + * POC for docs/security-audit-2026-04-05.md §2: `folder.where` is embedded with `sql.raw`. + * A writer who can set `where` to a tautology affects which charts belong to the folder. + * + * This is not an unauthenticated network exploit — it requires DB write access to `folder`. + */ +describe("POC: tautological folder.where via sql.raw", () => { + function ids(prefix: string) { + const u = randomUUID().replace(/-/gu, "").slice(0, 12); + + return { + folderId: `${prefix}-f-${u}`, + folderLegacy: `${prefix}-fl-${u}`, + songId: `${prefix}-s-${u}`, + chartA: `${prefix}-ca-${u}`, + chartB: `${prefix}-cb-${u}`, + }; + } + + it("matches every chart in the game when where is always true", async () => { + const { folderId, folderLegacy, songId, chartA, chartB } = ids("sqlraw"); + + const songLegacy = 9_800_000 + Math.floor(Math.random() * 99_000); + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: songLegacy, + game_group: "iidx", + title: "POC Song", + artist: "P", + search_terms: [], + alt_titles: [], + fts_document: "", + data: JSON.stringify({}), + }) + .execute(); + + await DB.insertInto("chart") + .values([ + { + id: chartA, + legacy_id: chartA, + game: "iidx-sp", + song_id: songId, + level: "9", + level_num: 9, + is_primary: true, + difficulty: "ANOTHER", + versions: [], + data: JSON.stringify({}), + }, + { + id: chartB, + legacy_id: chartB, + game: "iidx-sp", + song_id: songId, + level: "10", + level_num: 10, + is_primary: true, + difficulty: "HYPER", + versions: [], + data: JSON.stringify({}), + }, + ]) + .execute(); + + await DB.insertInto("folder") + .values({ + id: folderId, + legacy_id: folderLegacy, + game: "iidx-sp", + inactive: false, + title: "POC tautology", + slug: null, + where: "true", + version_filter: null, + search_terms: [], + }) + .execute(); + + const { folderQuery } = await BuildFolderQuery(folderId); + const { rows } = await folderQuery.execute(DB); + + const got = new Set(rows.map((r) => (r as { id: string }).id)); + + // Both charts match — `where: "true"` is an unrestricted tautology. + // A legitimate folder (e.g. `where: "chart.level_num = 10"`) would + // return only chartB. The tautology returns both. + expect(got.has(chartA)).toBe(true); + expect(got.has(chartB)).toBe(true); + expect(rows.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/typescript/server/src/security-audit/poc-seeds-shell-injection.test.ts b/typescript/server/src/security-audit/poc-seeds-shell-injection.test.ts new file mode 100644 index 000000000..0ee49650e --- /dev/null +++ b/typescript/server/src/security-audit/poc-seeds-shell-injection.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +/** + * POC for docs/security-audit-2026-04-05.md §1. + * + * The real handler is GET /api/v1/seeds/collections with ?revision=… + * (typescript/server/src/server/router/api/v1/seeds/router.ts). + * It only runs under RequireLocalDevelopment. + * + * The implementation builds a shell line like: + * PAGER=cat git show '${rev}:seeds/collections' | tail -n +3 + * and passes it to asyncExec (child_process.exec). + * + * Only ':' is rejected in revision — not ', ;, $(), etc. + */ +function buildGitShowTreeCommand(rev: string): string { + return `PAGER=cat git show '${rev}:seeds/collections' | tail -n +3`; +} + +describe("POC: seeds revision breaks out of single-quoted git argument (local dev)", () => { + it("injects shell separators when revision contains a single quote", () => { + const rev = "x'; id; echo 'y"; + const cmd = buildGitShowTreeCommand(rev); + // After the first ', the shell runs `; id; echo` before the trailing quote. + expect(cmd).toContain("'; id;"); + }); + + it("shows ':' alone is insufficient to prevent injection", () => { + const blocked = "abc:def"; + expect(blocked.includes(":")).toBe(true); + + const maliciousNoColon = "x'; whoami; echo 'z"; + expect(maliciousNoColon.includes(":")).toBe(false); + expect(buildGitShowTreeCommand(maliciousNoColon)).toMatch(/'; whoami;/u); + }); +}); diff --git a/typescript/server/src/server/middleware/request-logger.ts b/typescript/server/src/server/middleware/request-logger.ts index d4a64b52e..8459b337e 100644 --- a/typescript/server/src/server/middleware/request-logger.ts +++ b/typescript/server/src/server/middleware/request-logger.ts @@ -41,10 +41,19 @@ export const RequestLoggerMiddleware: RequestHandler = (req, res, next) => { res.json = ResJsonInteceptor(res, res.json); res.on("finish", () => { - const contents = { - // @ts-expect-error we're doing some monkey patching - contentBody is what we're returning. + // special overrides + // This stuff is spam, so we'll just not log it. + if (res.statusCode === 429) { + return; + } - body: res.contentBody, + // 403 bannings like this are also spam. + // @ts-expect-error we're doing some monkey patching - contentBody is what we're returning. + if (res.contentBody?.description === `You are banned from ${TachiConfig.NAME}.`) { + return; + } + + const contents = { statusCode: res.statusCode, requestQuery: req.query, requestBody: safeBody, @@ -55,18 +64,6 @@ export const RequestLoggerMiddleware: RequestHandler = (req, res, next) => { fromIp: req.ip, }; - // special overrides - // This stuff is spam, so we'll just not log it. - if (res.statusCode === 429) { - return; - } - - // 403 bannings like this are also spam. - - if (contents.body?.description === `You are banned from ${TachiConfig.NAME}.`) { - return; - } - if (res.statusCode < 400 || res.statusCode === 404) { let level: "debug" | "info"; diff --git a/typescript/server/src/server/prometheus.ts b/typescript/server/src/server/prometheus.ts new file mode 100644 index 000000000..7d56752bb --- /dev/null +++ b/typescript/server/src/server/prometheus.ts @@ -0,0 +1,86 @@ +import type { Express, RequestHandler } from "express"; +import type { ImportTypes } from "tachi-common"; + +import ExpressPromBundle from "express-prom-bundle"; +import { collectDefaultMetrics, Counter, Histogram, Registry } from "prom-client"; + +/** Dedicated listener for `GET /metrics` (see `main.ts`). */ +export const METRICS_PORT = 9779; + +/** Seconds — aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */ +const HTTP_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; + +/** Seconds — score imports can run from sub-second to tens of minutes. */ +const SCORE_IMPORT_DURATION_BUCKETS = [0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600, 1800]; + +let scoreImportDurationSeconds: Histogram | null = null; + +/** + * Records wall-clock duration of a completed score import (from `timeStarted` through successful + * finalization). No-op when metrics are disabled. + */ +export function observeScoreImportDuration(importType: ImportTypes, totalMs: number): void { + if (!scoreImportDurationSeconds) { + return; + } + scoreImportDurationSeconds.observe({ import_type: importType }, totalMs / 1000); +} + +/** + * Prometheus middlewares: HTTP duration histogram (`http_request_duration_seconds`), + * request counter (`http_requests_total`), `up`, and Node/process default metrics from + * `collectDefaultMetrics` (unprefixed: `process_cpu_*`, `process_resident_memory_bytes`, + * `nodejs_heap_*`, `nodejs_eventloop_lag_*`, `nodejs_version_info`, etc.). + * + * No prefix on default metrics so names match stock Grafana dashboards and prom-client docs. + * + * Scraping is served only on `metricsApp` at `/metrics` (`autoregister: false` on the main app). + */ +export function createPrometheusMiddlewares(metricsApp: Express): RequestHandler[] { + const registry = new Registry(); + + scoreImportDurationSeconds = new Histogram({ + name: "score_import_duration_seconds", + help: "Wall-clock duration of completed score imports in seconds (start through DB finalization).", + labelNames: ["import_type"], + buckets: SCORE_IMPORT_DURATION_BUCKETS, + registers: [registry], + }); + + collectDefaultMetrics({ register: registry }); + + const bundleOpts: Parameters[0] = { + autoregister: false, + includeMethod: true, + includePath: true, + includeStatusCode: true, + buckets: HTTP_DURATION_BUCKETS, + metricsApp, + metricsPath: "/metrics", + promRegistry: registry, + }; + + const httpRequestsTotal = new Counter({ + name: "http_requests_total", + help: "Total number of HTTP requests (labels match http_request_duration_seconds).", + labelNames: ["status_code", "method", "path"], + registers: [registry], + }); + + const promBundle = ExpressPromBundle(bundleOpts); + + const requestCountMiddleware: RequestHandler = (req, res, next) => { + res.on("finish", () => { + const status = ExpressPromBundle.normalizeStatusCode(res); + const path = ExpressPromBundle.normalizePath(req, bundleOpts); + httpRequestsTotal.inc({ + status_code: String(status), + method: req.method ?? "UNKNOWN", + path, + }); + }); + next(); + }; + + return [promBundle, requestCountMiddleware]; +} diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts index 8dca90777..b49eb16df 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts @@ -118,7 +118,9 @@ router.get("/", async (req, res) => { ); const iidxGameSpecific = iidxSettings?.preferences - .gameSpecific as MONGO_UGPTSettingsDocument<"iidx:DP" | "iidx:SP">["preferences"]["gameSpecific"]; + .gameSpecific as MONGO_UGPTSettingsDocument< + "iidx:DP" | "iidx:SP" + >["preferences"]["gameSpecific"]; if (!iidxGameSpecific?.display2DXTra) { charts = charts.filter( diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts index d2eada26c..30a53b529 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts @@ -1,5 +1,4 @@ -import type { MONGO_GoalDocument } from "tachi-common"; - +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents"; import { CreateGoalTitle, ValidateGoalChartsAndCriteria } from "#lib/targets/goal-utils"; import { GetQuestsThatContainGoal } from "#lib/targets/goals"; @@ -10,8 +9,7 @@ import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-dat import { GetUsersWithIDs } from "#utils/user"; import { type RequestHandler, Router } from "express"; import { p } from "prudence"; -import { GamePTToV3 } from "tachi-common"; -import type { Game } from "tachi-db"; +import { GamePTToV3, type MONGO_GoalDocument } from "tachi-common"; const router: Router = Router({ mergeParams: true }); @@ -135,7 +133,11 @@ const ResolveGoalID: RequestHandler = async (req, res, next) => { const v3Game = GamePTToV3(game, playtype); - const row = await DB.selectFrom("goal").selectAll().where("goal.id", "=", goalID).where("goal.game", "=", v3Game).executeTakeFirst(); + const row = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "=", goalID) + .where("goal.game", "=", v3Game) + .executeTakeFirst(); if (!row) { return res.status(404).json({ @@ -159,17 +161,11 @@ router.get("/:goalID", ResolveGoalID, async (req, res) => { const subRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.goal_id", "=", goal.goalID) .execute(); - const goalSubs = subRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); const users = await GetUsersWithIDs(goalSubs.map((e) => e.userID)); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts index 511bc0162..a511936e6 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts @@ -1,3 +1,5 @@ +import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; +import { SELECT_QUESTLINE_ROW } from "#lib/db-formats/questline"; import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents"; import { GetGoalsInQuest, GetGoalsInQuests } from "#lib/targets/quests"; import DB from "#services/pg/db"; @@ -6,7 +8,7 @@ import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-dat import { GetUsersWithIDs } from "#utils/user"; import { type RequestHandler, Router } from "express"; import { GamePTToV3, V3ToGamePT } from "tachi-common"; -import type { Game } from "tachi-db"; + const router: Router = Router({ mergeParams: true }); const ResolveQuestID: RequestHandler = async (req, res, next) => { @@ -16,7 +18,7 @@ const ResolveQuestID: RequestHandler = async (req, res, next) => { const v3Game = GamePTToV3(game, playtype); const row = await DB.selectFrom("quest") - .selectAll() + .select(SELECT_QUEST) .where("quest.id", "=", questID) .where("quest.game", "=", v3Game) .executeTakeFirst(); @@ -55,7 +57,7 @@ router.get("/", async (req, res) => { const pattern = `%${likeEsc}%`; const rows = await DB.selectFrom("quest") - .selectAll() + .select(SELECT_QUEST) .where("quest.game", "=", v3Game) .where((eb) => eb.or([eb("quest.name", "ilike", pattern), eb("quest.description", "ilike", pattern)]), @@ -83,23 +85,11 @@ router.get("/:questID", ResolveQuestID, async (req, res) => { const questSubRows = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.quest_id", "=", quest.questID) .execute(); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - quest_id: r.quest_id, - user_id: r.user_id, - progress: r.progress, - last_interaction: r.last_interaction, - achieved: r.achieved, - time_achieved: r.time_achieved, - was_instantly_achieved: r.was_instantly_achieved, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); const users = await GetUsersWithIDs(questSubs.map((e) => e.userID)); @@ -107,7 +97,7 @@ router.get("/:questID", ResolveQuestID, async (req, res) => { const qlRows = await DB.selectFrom("questline") .innerJoin("questline_quest", "questline_quest.questline_id", "questline.id") - .selectAll("questline") + .select(SELECT_QUESTLINE_ROW) .where("questline_quest.quest_id", "=", quest.questID) .execute(); diff --git a/typescript/server/src/server/router/api/v1/localdev/router.ts b/typescript/server/src/server/router/api/v1/localdev/router.ts index e3fbdcbee..141df2ef6 100644 --- a/typescript/server/src/server/router/api/v1/localdev/router.ts +++ b/typescript/server/src/server/router/api/v1/localdev/router.ts @@ -1,5 +1,6 @@ import { RequireLocalDevelopment } from "#server/middleware/type-require"; import DB from "#services/pg/db"; +import { GetFirstAdmin } from "#utils/user"; import { Router } from "express"; /** @@ -30,4 +31,29 @@ router.get("/song-seed-status", async (_req, res) => { }); }); +/** + * Username of the lowest-ID admin (same as {@link GetFirstAdmin}) and the dev default + * password for the quick-login button on the login page. + * + * @name GET /api/v1/localdev/first-admin-login + */ +router.get("/first-admin-login", async (_req, res) => { + try { + const admin = await GetFirstAdmin(); + return res.status(200).json({ + success: true, + description: "First admin login hint retrieved.", + body: { + username: admin.username, + password: "password", + }, + }); + } catch { + return res.status(200).json({ + success: false, + description: "No admin account exists on this instance.", + }); + } +}); + export default router; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index de540e4de..771a0a7e5 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -1,7 +1,7 @@ -import type { Game } from "tachi-db"; - import { ACTION_AddGoal } from "#actions/add-goal"; import { ACTION_RemoveGoalSubscription } from "#actions/remove-goal-subscription"; +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; +import { SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { ToGoalDocument, ToGoalSubscriptionDocument, @@ -34,25 +34,22 @@ router.get("/", async (req, res) => { const subRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", user.id) .where("goal.game", "=", v3Game) .execute(); - const goalSubs = subRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = subRows.map((r) => ToGoalSubscriptionDocument(r)); const goalIds = goalSubs.map((e) => e.goalID); const goalRows = goalIds.length === 0 ? [] - : await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIds).execute(); + : await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIds) + .execute(); const goals = goalRows.map(ToGoalDocument); @@ -60,24 +57,12 @@ router.get("/", async (req, res) => { const questSubRows = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", user.id) .where("quest.game", "=", v3Game) .execute(); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - quest_id: r.quest_id, - user_id: r.user_id, - progress: r.progress, - last_interaction: r.last_interaction, - achieved: r.achieved, - time_achieved: r.time_achieved, - was_instantly_achieved: r.was_instantly_achieved, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); const questSubIDs = questSubs.map((e) => e.questID); @@ -213,8 +198,7 @@ const GetGoalSubscription: RequestHandler = async (req, res, next) => { const row = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", user.id) .where("goal_sub.goal_id", "=", req.params.goalID) .where("goal.game", "=", v3Game) @@ -227,10 +211,7 @@ const GetGoalSubscription: RequestHandler = async (req, res, next) => { }); } - const goalSub = ToGoalSubscriptionDocument({ - ...row, - goal_game: row.goal_game as Game, - }); + const goalSub = ToGoalSubscriptionDocument(row); AssignToReqTachiData(req, { goalSubDoc: goalSub }); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts index 2a2f72fe2..aac102754 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts @@ -1,4 +1,5 @@ import { SubscribeFailReasons } from "#lib/constants/err-codes"; +import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents"; import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; @@ -13,9 +14,8 @@ import DB from "#services/pg/db"; import { AssignToReqTachiData, GetGPT, GetTachiData, GetUGPT } from "#utils/req-tachi-data"; import { FormatUserDoc } from "#utils/user"; import { type RequestHandler, Router } from "express"; -import { GamePTToV3 } from "tachi-common"; -import type { Game } from "tachi-db"; import { sql } from "kysely"; +import { GamePTToV3 } from "tachi-common"; import { RequireAuthedAsUser } from "../../../../../middleware"; @@ -33,31 +33,22 @@ router.get("/", async (req, res) => { const questSubRows = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", user.id) .where("quest.game", "=", v3Game) .execute(); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - quest_id: r.quest_id, - user_id: r.user_id, - progress: r.progress, - last_interaction: r.last_interaction, - achieved: r.achieved, - time_achieved: r.time_achieved, - was_instantly_achieved: r.was_instantly_achieved, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); const questIds = questSubs.map((e) => e.questID); const questRows = questIds.length === 0 ? [] - : await DB.selectFrom("quest").selectAll().where("quest.id", "in", questIds).execute(); + : await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "in", questIds) + .execute(); if (questRows.length !== questSubs.length) { log.error( @@ -98,8 +89,7 @@ const GetQuestSubscription: RequestHandler = async (req, res, next) => { const row = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", user.id) .where("quest_sub.quest_id", "=", req.params.questID) .where("quest.game", "=", v3Game) @@ -112,16 +102,7 @@ const GetQuestSubscription: RequestHandler = async (req, res, next) => { }); } - const questSub = ToQuestSubscriptionDocument({ - quest_id: row.quest_id, - user_id: row.user_id, - progress: row.progress, - last_interaction: row.last_interaction, - achieved: row.achieved, - time_achieved: row.time_achieved, - was_instantly_achieved: row.was_instantly_achieved, - quest_game: row.quest_game as Game, - }); + const questSub = ToQuestSubscriptionDocument(row); AssignToReqTachiData(req, { questSubDoc: questSub }); @@ -134,7 +115,7 @@ const GetQuest: RequestHandler = async (req, res, next) => { const v3Game = GamePTToV3(game, playtype); const row = await DB.selectFrom("quest") - .selectAll() + .select(SELECT_QUEST) .where("quest.id", "=", req.params.questID) .where("quest.game", "=", v3Game) .executeTakeFirst(); @@ -270,8 +251,7 @@ router.delete( const row = await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", user.id) .where("quest_sub.quest_id", "=", quest.questID) .where("quest.game", "=", v3Game) @@ -284,16 +264,7 @@ router.delete( }); } - const questSub = ToQuestSubscriptionDocument({ - quest_id: row.quest_id, - user_id: row.user_id, - progress: row.progress, - last_interaction: row.last_interaction, - achieved: row.achieved, - time_achieved: row.time_achieved, - was_instantly_achieved: row.was_instantly_achieved, - quest_game: row.quest_game as Game, - }); + const questSub = ToQuestSubscriptionDocument(row); await UnsubscribeFromQuest(questSub, quest); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts index 88d28e0ee..5a0ddd199 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts @@ -1,5 +1,7 @@ import { GetChartById } from "#lib/db-formats/chart"; import { LoadFolderDocumentById } from "#lib/db-formats/folders"; +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; +import { SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { ToGoalDocument, ToGoalSubscriptionDocument, @@ -19,7 +21,6 @@ import { GetFolderChartIDs } from "#utils/folder"; import { GetUGPT } from "#utils/req-tachi-data"; import { Router } from "express"; import { GamePTToV3, MongoChartLegacyId } from "tachi-common"; -import type { Game } from "tachi-db"; import goalsRouter from "./goals/router"; import questsRouter from "./quests/router"; @@ -122,25 +123,13 @@ router.get("/on-chart/:chartID", async (req, res) => { ? [] : await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", user.id) .where("quest.game", "=", v3Game) .where("quest_sub.quest_id", "in", questIds) .execute(); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - quest_id: r.quest_id, - user_id: r.user_id, - progress: r.progress, - last_interaction: r.last_interaction, - achieved: r.achieved, - time_achieved: r.time_achieved, - was_instantly_achieved: r.was_instantly_achieved, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); return res.status(200).json({ success: true, @@ -179,25 +168,22 @@ router.get("/on-folder/:folderID", async (req, res) => { const allSubRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", user.id) .where("goal.game", "=", v3Game) .execute(); - const allGoalSubs = allSubRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const allGoalSubs = allSubRows.map((r) => ToGoalSubscriptionDocument(r)); const goalIDs = allGoalSubs.map((e) => e.goalID); const goalDocRows = goalIDs.length === 0 ? [] - : await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute(); + : await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIDs) + .execute(); const goals: Array> = []; @@ -228,25 +214,13 @@ router.get("/on-folder/:folderID", async (req, res) => { ? [] : await DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", user.id) .where("quest.game", "=", v3Game) .where("quest_sub.quest_id", "in", questIds) .execute(); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - quest_id: r.quest_id, - user_id: r.user_id, - progress: r.progress, - last_interaction: r.last_interaction, - achieved: r.achieved, - time_achieved: r.time_achieved, - was_instantly_achieved: r.was_instantly_achieved, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); return res.status(200).json({ success: true, @@ -273,39 +247,21 @@ router.get("/all-subs", async (req, res) => { const [goalSubRows, questSubRows] = await Promise.all([ DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", user.id) .where("goal.game", "=", v3Game) .execute(), DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.user_id", "=", user.id) .where("quest.game", "=", v3Game) .execute(), ]); - const goalSubs = goalSubRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r)); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - quest_id: r.quest_id, - user_id: r.user_id, - progress: r.progress, - last_interaction: r.last_interaction, - achieved: r.achieved, - time_achieved: r.time_achieved, - was_instantly_achieved: r.was_instantly_achieved, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); return res.status(200).json({ success: true, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts index 05181c4c6..9333eb0b4 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -1,6 +1,6 @@ import { ACTION_DeleteAllNotifications } from "#actions/delete-all-notifications.js"; import { ACTION_MarkAllNotificationsRead } from "#actions/mark-all-notifications-read.js"; -import { ToNotificationDocument } from "#lib/db-formats/notification"; +import { SELECT_NOTIFICATION, ToNotificationDocument } from "#lib/db-formats/notification"; import DB from "#services/pg/db"; import { GetTachiData } from "#utils/req-tachi-data"; import { Router } from "express"; @@ -22,9 +22,9 @@ router.get("/", async (req, res) => { const user = GetTachiData(req, "requestedUser"); const rows = await DB.selectFrom("notification") - .selectAll() - .where("sent_to", "=", user.id) - .orderBy("sent_at", "desc") + .select(SELECT_NOTIFICATION) + .where("notification.sent_to", "=", user.id) + .orderBy("notification.sent_at", "desc") .execute(); const notifs = rows.map(ToNotificationDocument); diff --git a/typescript/server/src/server/router/router.ts b/typescript/server/src/server/router/router.ts index 56fc023d4..ff70c43a3 100644 --- a/typescript/server/src/server/router/router.ts +++ b/typescript/server/src/server/router/router.ts @@ -22,7 +22,7 @@ router.use("/api/v1", apiRouterV1); // if in localdev, add a debug endpoint to tell users when they got a successful fetch // on the root endpoint. -// That is to say, if a user is hitting 127.0.0.1:8080/ +// That is to say, if a user is hitting localhost:8080/ // instead of "cannot GET /", they should get a nice message. if (Env.NODE_ENV === "dev") { router.get("/", (_req, res) => diff --git a/typescript/server/src/server/server.ts b/typescript/server/src/server/server.ts index 46778b11a..4913aa771 100644 --- a/typescript/server/src/server/server.ts +++ b/typescript/server/src/server/server.ts @@ -12,11 +12,11 @@ import { Env, ServerConfig, TachiConfig } from "#lib/setup/config"; import { RedisClient } from "#services/redis/redis"; import { IsNonEmptyString, IsRecord } from "#utils/misc"; import { ExpectedErr } from "bliss"; -import ExpressPromBundle from "express-prom-bundle"; import expressSession from "express-session"; import helmet from "helmet"; import { RequestLoggerMiddleware } from "./middleware/request-logger"; +import { createPrometheusMiddlewares } from "./prometheus"; import mainRouter from "./router/router"; let store; @@ -42,20 +42,18 @@ const userSessionMiddleware = expressSession({ resave: true, saveUninitialized: false, cookie: { - // the absence of Secure in combination with SameSite=None will cause issues on non-https - // instances in newer versions of chromium. there is no workaround for this. - secure: - Env.NODE_ENV === "production" || - Env.NODE_ENV === "staging" || - ServerConfig.ENABLE_SERVER_HTTPS, + secure: Env.NODE_ENV === "production" || Env.NODE_ENV === "staging", // Very important. Without this, we're vulnerable to CSRF! - sameSite: Env.NODE_ENV === "production" || Env.NODE_ENV === "staging" ? "strict" : "none", + sameSite: Env.NODE_ENV === "production" || Env.NODE_ENV === "staging" ? "strict" : "lax", }, }); const app: Express = express(); +/** When metrics are enabled, exposed only on `METRICS_PORT` as `GET /metrics` (not on the main HTTP port). */ +export const metricsApp: Express | undefined = ServerConfig.ENABLE_METRICS ? express() : undefined; + app.get("/.deploy/up", (_req, res) => res.sendStatus(200)); if (Env.NODE_ENV !== "production" && IsNonEmptyString(ServerConfig.CLIENT_DEV_SERVER)) { @@ -113,8 +111,10 @@ if (Env.NODE_ENV !== "production" && IsNonEmptyString(ServerConfig.CLIENT_DEV_SE app.use(helmet()); } -if (ServerConfig.ENABLE_METRICS) { - app.use(ExpressPromBundle({ includeMethod: true, includePath: true })); +if (ServerConfig.ENABLE_METRICS && metricsApp) { + for (const mw of createPrometheusMiddlewares(metricsApp)) { + app.use(mw); + } } app.use(userSessionMiddleware); diff --git a/typescript/server/src/services/pg/seeds.ts b/typescript/server/src/services/pg/seeds.ts index 323a8b213..75cf65a77 100644 --- a/typescript/server/src/services/pg/seeds.ts +++ b/typescript/server/src/services/pg/seeds.ts @@ -1,12 +1,13 @@ -import type { - MONGO_BMSCourseDocument, - MONGO_ChartDocument, - MONGO_FolderDocument, - MONGO_GoalDocument, - MONGO_QuestDocument, - MONGO_QuestlineDocument, - MONGO_SongDocument, - MONGO_TableDocument, +import { + type MONGO_BMSCourseDocument, + type MONGO_ChartDocument, + type MONGO_FolderDocument, + type MONGO_GoalDocument, + type MONGO_QuestDocument, + type MONGO_QuestlineDocument, + type MONGO_SongDocument, + type MONGO_TableDocument, + V3ToGPTString, } from "tachi-common"; /* eslint-disable no-await-in-loop */ import type { @@ -25,6 +26,7 @@ import type { Game as PgGame, } from "tachi-db"; +import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum"; import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup.js"; import fs from "fs"; import { type Insertable, type Kysely, sql } from "kysely"; @@ -172,6 +174,8 @@ async function upsertChartsForPgGame( const chartRows: Array = []; + const gpt = V3ToGPTString(pgGame); + for (const c of charts) { if (!c.id) { throw new Error( @@ -186,6 +190,8 @@ async function upsertChartsForPgGame( ); } + const checksum = computeDerivationChecksumForGPT(gpt, c as unknown as MONGO_ChartDocument); + chartRows.push({ id: c.id, legacy_id: c.legacyChartID, @@ -197,6 +203,7 @@ async function upsertChartsForPgGame( difficulty: c.difficulty, versions: (c.versions ?? []).map(String), data: JSON.stringify(c.data), + derivation_checksum: checksum, }); } @@ -214,6 +221,7 @@ async function upsertChartsForPgGame( difficulty: sql`excluded.difficulty`, versions: sql`excluded.versions`, data: sql`excluded.data`, + derivation_checksum: sql`excluded.derivation_checksum`, }), ) .execute(); diff --git a/typescript/server/src/utils/calculations/recalc-scores.ts b/typescript/server/src/utils/calculations/recalc-scores.ts index 5a0d70f70..f58272acf 100644 --- a/typescript/server/src/utils/calculations/recalc-scores.ts +++ b/typescript/server/src/utils/calculations/recalc-scores.ts @@ -1,7 +1,24 @@ -export async function RecalcAllScores() { - // todo impl +import DB from "#services/pg/db"; + +/** + * Enqueue every chart into `score_rederive` so the async worker will + * re-run `scoreDeriver` + `scoreCalcs` on every score. The worker also + * fires PB recalculation via the `pb_dirty` trigger. + */ +export async function RecalcAllScores(): Promise { + await DB.insertInto("score_rederive") + .expression(DB.selectFrom("chart").select(["chart.id as chart_id"])) + .onConflict((oc) => oc.doNothing()) + .execute(); } -export async function UpdateAllPBs() { - // todo impl +/** + * Enqueue every (user_id, chart_id) pair from the `score` table into + * `pb_dirty` so the async worker will recalculate PBs for all users. + */ +export async function UpdateAllPBs(): Promise { + await DB.insertInto("pb_dirty") + .expression(DB.selectFrom("score").select(["score.user_id", "score.chart_id"]).distinct()) + .onConflict((oc) => oc.doNothing()) + .execute(); } diff --git a/typescript/server/src/utils/db.ts b/typescript/server/src/utils/db.ts index 48308ccfe..9c9cc9964 100644 --- a/typescript/server/src/utils/db.ts +++ b/typescript/server/src/utils/db.ts @@ -3,6 +3,8 @@ import type { Game } from "tachi-db"; import { GetChartsByIds, SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; import { LoadFolderDocumentById } from "#lib/db-formats/folders.js"; +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; +import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest"; import { GetSongsByLegacyIDs } from "#lib/db-formats/song"; import { ToGoalDocument, @@ -214,7 +216,10 @@ export async function GetFolderForIDGuaranteed(folderID: string) { } export async function GetGoalForIDGuaranteed(goalID: string) { - const row = await DB.selectFrom("goal").selectAll().where("id", "=", goalID).executeTakeFirst(); + const row = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "=", goalID) + .executeTakeFirst(); if (!row) { throw new Error(`Couldn't find goal with ID ${goalID}`); @@ -226,10 +231,9 @@ export async function GetGoalForIDGuaranteed(goalID: string) { export async function GetGoalSubscriptionForIDGuaranteed(goalID: string, userID: integer) { const row = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") - .where("goal_id", "=", goalID) - .where("user_id", "=", userID) + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) + .where("goal_sub.goal_id", "=", goalID) + .where("goal_sub.user_id", "=", userID) .executeTakeFirst(); if (!row) { @@ -243,8 +247,8 @@ export async function GetGoalSubscriptionForIDGuaranteed(goalID: string, userID: export async function GetQuestForIDGuaranteed(questID: string) { const row = await DB.selectFrom("quest") - .selectAll() - .where("id", "=", questID) + .select(SELECT_QUEST) + .where("quest.id", "=", questID) .executeTakeFirst(); if (!row) { @@ -276,8 +280,7 @@ export async function GetRecentlyAchievedGoals( let q = DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.was_instantly_achieved", "=", false) .where("goal_sub.achieved", "=", true); @@ -319,19 +322,17 @@ export async function GetRecentlyAchievedGoals( const goalSubRows = await q.execute(); - const goalSubs = goalSubRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r)); const goalIds = [...new Set(goalSubs.map((g) => g.goalID))]; const goalRows = goalIds.length === 0 ? [] - : await DB.selectFrom("goal").selectAll().where("id", "in", goalIds).execute(); + : await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIds) + .execute(); const goals = goalRows.map(ToGoalDocument); @@ -346,8 +347,7 @@ export async function GetRecentlyInteractedGoals( let q = DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.was_instantly_achieved", "=", false) .where("goal_sub.achieved", "=", false) .where("goal_sub.last_interaction", "is not", null); @@ -390,19 +390,17 @@ export async function GetRecentlyInteractedGoals( const goalSubRows = await q.execute(); - const goalSubs = goalSubRows.map((r) => - ToGoalSubscriptionDocument({ - ...r, - goal_game: r.goal_game as Game, - }), - ); + const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r)); const goalIds = [...new Set(goalSubs.map((g) => g.goalID))]; const goalRows = goalIds.length === 0 ? [] - : await DB.selectFrom("goal").selectAll().where("id", "in", goalIds).execute(); + : await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIds) + .execute(); const goals = goalRows.map(ToGoalDocument); @@ -417,8 +415,7 @@ export async function GetRecentlyAchievedQuests( let q = DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.was_instantly_achieved", "=", false) .where("quest_sub.achieved", "=", true); @@ -460,19 +457,17 @@ export async function GetRecentlyAchievedQuests( const questSubRows = await q.execute(); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - ...r, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); const questIds = [...new Set(questSubs.map((s) => s.questID))]; const questRows = questIds.length === 0 ? [] - : await DB.selectFrom("quest").selectAll().where("id", "in", questIds).execute(); + : await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "in", questIds) + .execute(); const quests = questRows.map(ToQuestDocument); @@ -487,8 +482,7 @@ export async function GetRecentlyInteractedQuests( let q = DB.selectFrom("quest_sub") .innerJoin("quest", "quest.id", "quest_sub.quest_id") - .selectAll("quest_sub") - .select("quest.game as quest_game") + .select(SELECT_QUEST_SUB_WITH_QUEST_GAME) .where("quest_sub.last_interaction", "is not", null) .where("quest_sub.achieved", "=", false) .where("quest_sub.was_instantly_achieved", "=", false); @@ -531,19 +525,17 @@ export async function GetRecentlyInteractedQuests( const questSubRows = await q.execute(); - const questSubs = questSubRows.map((r) => - ToQuestSubscriptionDocument({ - ...r, - quest_game: r.quest_game as Game, - }), - ); + const questSubs = questSubRows.map((r) => ToQuestSubscriptionDocument(r)); const questIds = [...new Set(questSubs.map((s) => s.questID))]; const questRows = questIds.length === 0 ? [] - : await DB.selectFrom("quest").selectAll().where("id", "in", questIds).execute(); + : await DB.selectFrom("quest") + .select(SELECT_QUEST) + .where("quest.id", "in", questIds) + .execute(); const quests = questRows.map(ToQuestDocument); @@ -609,7 +601,10 @@ export async function GetMostSubscribedGoals( return []; } - const goalRows = await DB.selectFrom("goal").selectAll().where("id", "in", goalIds).execute(); + const goalRows = await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIds) + .execute(); const byId = new Map(goalRows.map((g) => [g.id, g])); @@ -656,8 +651,8 @@ export async function GetMostSubscribedQuests( } const questRows = await DB.selectFrom("quest") - .selectAll() - .where("id", "in", questIds) + .select(SELECT_QUEST) + .where("quest.id", "in", questIds) .execute(); const byId = new Map(questRows.map((x) => [x.id, x])); @@ -684,7 +679,7 @@ export async function GetChildQuests(questline: MONGO_QuestlineDocument) { } const quests = await DB.selectFrom("quest") - .selectAll() + .select(SELECT_QUEST) .where("quest.id", "in", questline.quests) .execute(); diff --git a/typescript/server/src/utils/queries/questlines.ts b/typescript/server/src/utils/queries/questlines.ts index cefddc3bc..32881bd69 100644 --- a/typescript/server/src/utils/queries/questlines.ts +++ b/typescript/server/src/utils/queries/questlines.ts @@ -28,7 +28,11 @@ export async function GetQuestlinesForGamePlaytype( const questlineIds = qlRows.map((r) => r.questline_id); const qqRows = await DB.selectFrom("questline_quest") - .select(["questline_quest.questline_id", "questline_quest.quest_id", "questline_quest.sort_order"]) + .select([ + "questline_quest.questline_id", + "questline_quest.quest_id", + "questline_quest.sort_order", + ]) .where("questline_quest.questline_id", "in", questlineIds) .orderBy("questline_quest.questline_id") .orderBy("questline_quest.sort_order") diff --git a/typescript/server/src/utils/queries/songs.ts b/typescript/server/src/utils/queries/songs.ts index c2720f4a1..136c92a66 100644 --- a/typescript/server/src/utils/queries/songs.ts +++ b/typescript/server/src/utils/queries/songs.ts @@ -1,7 +1,7 @@ import type { KtLogger } from "#lib/log/log"; import type { GameGroup, integer, MONGO_SongDocument } from "tachi-common"; -import type { Song } from "tachi-db"; +import { SELECT_SONG_ROW, ToSongDocumentFromRow } from "#lib/db-formats/song"; import { AmbiguousTitleFailure, InternalFailure, @@ -11,17 +11,6 @@ import { sql } from "kysely"; import { EscapeStringRegexp } from "../misc"; -function rowToSongDoc(row: Song): MONGO_SongDocument { - return { - id: row.legacy_id, - title: row.title, - artist: row.artist, - searchTerms: row.search_terms, - altTitles: row.alt_titles, - data: row.data as MONGO_SongDocument["data"], - }; -} - /** * Finds a song document for the given game with the given title (or alt-title). * This is NOT the preferred way to find a song, as encodings, and typos, make this @@ -35,10 +24,10 @@ export async function FindSongOnTitle( title: string, ): Promise { const res = await DB.selectFrom("song") - .selectAll() - .where("game_group", "=", game) + .select(SELECT_SONG_ROW) + .where("song.game_group", "=", game) .where((eb) => - eb.or([eb("title", "=", title), sql`${title} = ANY(song.alt_titles)`]), + eb.or([eb("song.title", "=", title), sql`${title} = ANY(song.alt_titles)`]), ) .limit(2) .execute(); @@ -50,7 +39,7 @@ export async function FindSongOnTitle( ); } - return res[0] ? rowToSongDoc(res[0]) : null; + return res[0] ? ToSongDocumentFromRow(res[0]) : null; } /** @@ -66,8 +55,8 @@ export async function FindSongOnTitleInsensitive( const artistPat = `^${EscapeStringRegexp(artist ?? "")}$`; let q = DB.selectFrom("song") - .selectAll() - .where("game_group", "=", game) + .select(SELECT_SONG_ROW) + .where("song.game_group", "=", game) .where((eb) => eb.or([ sql`song.title ~* ${titlePat}`, @@ -91,7 +80,7 @@ export async function FindSongOnTitleInsensitive( ); } - return res[0] ? rowToSongDoc(res[0]) : null; + return res[0] ? ToSongDocumentFromRow(res[0]) : null; } /** @@ -103,12 +92,12 @@ export async function FindSongOnTitleInsensitive( */ export async function FindSongOnID(game: GameGroup, songID: integer) { const row = await DB.selectFrom("song") - .selectAll() - .where("game_group", "=", game) - .where("legacy_id", "=", songID) + .select(SELECT_SONG_ROW) + .where("song.game_group", "=", game) + .where("song.legacy_id", "=", songID) .executeTakeFirst(); - return row ? rowToSongDoc(row) : null; + return row ? ToSongDocumentFromRow(row) : null; } /** @@ -116,12 +105,12 @@ export async function FindSongOnID(game: GameGroup, songID: integer) { */ export async function FindDDRSongOnDDRSongHash(hash: string) { const row = await DB.selectFrom("song") - .selectAll() - .where("game_group", "=", "ddr") + .select(SELECT_SONG_ROW) + .where("song.game_group", "=", "ddr") .where(sql`(song.data::jsonb->>'ddrSongHash') = ${hash}`) .executeTakeFirst(); - return row ? rowToSongDoc(row) : null; + return row ? ToSongDocumentFromRow(row) : null; } export async function FindSongOnIDGuaranteed(game: GameGroup, songID: integer, log: KtLogger) { diff --git a/typescript/server/src/utils/queries/summary.ts b/typescript/server/src/utils/queries/summary.ts index f3e665f32..69f559cc6 100644 --- a/typescript/server/src/utils/queries/summary.ts +++ b/typescript/server/src/utils/queries/summary.ts @@ -1,7 +1,7 @@ import type { integer } from "tachi-common"; -import type { Game } from "tachi-db"; import { SELECT_FOLDER, ToFolderDocument } from "#lib/db-formats/folders.js"; +import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal"; import { SELECT_SESSION_DOCUMENT, ToSessionDocument } from "#lib/db-formats/session"; import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents"; import DB from "#services/pg/db"; @@ -85,8 +85,7 @@ export async function GetGoalSummary(userID: integer) { const achievedRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) .where("goal_sub.time_achieved", ">=", timeIso) .where("goal_sub.was_instantly_achieved", "=", false) @@ -94,27 +93,25 @@ export async function GetGoalSummary(userID: integer) { const improvedRows = await DB.selectFrom("goal_sub") .innerJoin("goal", "goal.id", "goal_sub.goal_id") - .selectAll("goal_sub") - .select("goal.game as goal_game") + .select(SELECT_GOAL_SUB_WITH_GOAL_GAME) .where("goal_sub.user_id", "=", userID) .where("goal_sub.last_interaction", ">=", timeIso) .where("goal_sub.achieved", "=", false) .execute(); - const achievedGoals = achievedRows.map((r) => - ToGoalSubscriptionDocument({ ...r, goal_game: r.goal_game as Game }), - ); + const achievedGoals = achievedRows.map((r) => ToGoalSubscriptionDocument(r)); - const improvedGoals = improvedRows.map((r) => - ToGoalSubscriptionDocument({ ...r, goal_game: r.goal_game as Game }), - ); + const improvedGoals = improvedRows.map((r) => ToGoalSubscriptionDocument(r)); const goalIds = [...new Set([...achievedGoals, ...improvedGoals].map((e) => e.goalID))]; const goalRows = goalIds.length === 0 ? [] - : await DB.selectFrom("goal").selectAll().where("id", "in", goalIds).execute(); + : await DB.selectFrom("goal") + .select(SELECT_GOAL) + .where("goal.id", "in", goalIds) + .execute(); const goals = goalRows.map(ToGoalDocument); diff --git a/typescript/server/src/utils/user.ts b/typescript/server/src/utils/user.ts index 72d727791..9fbed0a50 100644 --- a/typescript/server/src/utils/user.ts +++ b/typescript/server/src/utils/user.ts @@ -1,4 +1,5 @@ import { ONE_DAY } from "#lib/constants/time"; +import { SELECT_PRIV_ACCOUNT_CREDENTIAL } from "#lib/db-formats/priv-account-credential"; import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; import { SELECT_USER_SETTINGS, ToUserSettingsDocument } from "#lib/db-formats/user-settings"; import { log } from "#lib/log/log"; @@ -62,8 +63,8 @@ export async function CheckIfEmailInUse(email: string) { export function GetUserPrivateInfo(userID: integer) { return DB.selectFrom("priv_account_credential") - .selectAll() - .where("user_id", "=", userID) + .select(SELECT_PRIV_ACCOUNT_CREDENTIAL) + .where("priv_account_credential.user_id", "=", userID) .executeTakeFirst(); } diff --git a/typescript/server/src/utils/validate-webhook-uri.ts b/typescript/server/src/utils/validate-webhook-uri.ts new file mode 100644 index 000000000..7d8f38b1d --- /dev/null +++ b/typescript/server/src/utils/validate-webhook-uri.ts @@ -0,0 +1,47 @@ +import { URL } from "node:url"; + +const PRIVATE_IP_RANGES = [ + /^127\./u, + /^10\./u, + /^172\.(1[6-9]|2\d|3[01])\./u, + /^192\.168\./u, + /^169\.254\./u, + /^0\./u, + /^::1$/u, + /^fc00:/u, + /^fe80:/u, +]; + +const PRIVATE_HOSTNAMES = new Set(["", "localhost", "localhost.localdomain"]); + +/** + * Returns `null` if the URI is acceptable, or a rejection reason string. + * Only `https:` URIs targeting public hosts are allowed. + */ +export function validateWebhookUri(uri: string): string | null { + let parsed: URL; + + try { + parsed = new URL(uri); + } catch { + return "webhookUri is not a valid URL."; + } + + if (parsed.protocol !== "https:") { + return "webhookUri must use https."; + } + + const hostname = parsed.hostname.toLowerCase(); + + if (PRIVATE_HOSTNAMES.has(hostname)) { + return "webhookUri must not target localhost."; + } + + for (const re of PRIVATE_IP_RANGES) { + if (re.test(hostname)) { + return "webhookUri must not target a private or link-local IP address."; + } + } + + return null; +} diff --git a/typescript/sieglinde/package.json b/typescript/sieglinde/package.json index cf89b603d..92c41f5a7 100644 --- a/typescript/sieglinde/package.json +++ b/typescript/sieglinde/package.json @@ -21,8 +21,7 @@ "commander": "catalog:", "fast-xml-parser": "catalog:", "node-fetch": "catalog:", - "tachi-common": "workspace:*", - "winston": "catalog:" + "tachi-common": "workspace:*" }, "devDependencies": { "@typescript/native-preview": "catalog:",