* checkpoint

* server compiles again

* fmt

* checkpoint

* last one out, get the lights

* euston station

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

* fix: some nonsense AI stuff

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

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

* fix: register must use txn not db

* checkpoint
This commit is contained in:
zk
2026-04-20 23:11:16 +01:00
committed by GitHub
parent edda03e916
commit 2bc4c3a628
1073 changed files with 52259 additions and 56015 deletions
+7 -2
View File
@@ -5,7 +5,7 @@ PORT=8080
LOG_LEVEL="debug"
MSGPACKR_NATIVE_ACCELERATION_DISABLED=true
POSTGRES_URL="postgresql://tachi:tachi@tachi-postgres/kamai"
POSTGRES_URL="postgresql://tachi:tachi@tachi-postgres/tachi_dev"
MIGRATIONS_DIR="/tachi/db/migrations"
REDIS_URL=tachi-redis
@@ -14,6 +14,11 @@ PORT=8080
MIGRATIONS_DIR=/tachi/db/migrations
LOG_LEVEL=debug
TACHI_EMAIL_FROM=dev@localhost
TACHI_EMAIL_AUTH_POSTMARK=false
TACHI_EMAIL_HOST=tachi-mailpit
TACHI_EMAIL_PORT=1025
TACHI_EMAIL_SECURE=false
TACHI_CLIENT_DEV_SERVER=http://localhost:3000
TACHI_MONGO_DATABASE_NAME=tachi-dev
TACHI_CAPTCHA_SECRET_KEY=unused
@@ -47,5 +52,5 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_BUCKET=tachi-private
TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=us-east-1
TACHI_NAME=Tachi Local Dev
TACHI_TYPE=omni
TACHI_GAMES=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki
TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
+7 -1
View File
@@ -40,5 +40,11 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_BUCKET=tachi-private
TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=us-east-1
TACHI_NAME=Tachi Testing
TACHI_TYPE=omni
TACHI_GAMES=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki
TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
TACHI_EMAIL_FROM=test@example.com
TACHI_EMAIL_AUTH_POSTMARK=false
TACHI_EMAIL_HOST=127.0.0.1
TACHI_EMAIL_PORT=1025
TACHI_EMAIL_SECURE=false
+2 -3
View File
@@ -1,13 +1,13 @@
{
"name": "tachi-server",
"version": "2.5.1",
"version": "3.0.0",
"description": "A score tracking server.",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/main.ts",
"test": "vitest",
"bench": "vitest bench --passWithNoTests",
"bench": "vitest bench",
"build": "tsgo -b tsconfig.build.json -v",
"typecheck": "tsgo --noEmit",
"lint": "eslint ./src",
@@ -93,7 +93,6 @@
"src/*/!(test-utils)/**/*.ts"
],
"exclude": [
"src/**/*.oldtest.ts",
"src/external/mongo/schemas.ts"
]
},
+4 -5
View File
@@ -1,8 +1,7 @@
import type { SYMBOL_TACHI_API_AUTH, SYMBOL_TACHI_DATA } from "#lib/constants/tachi";
import type { TachiRequestData, TachiSessionData } from "#utils/types";
import type { Session, SessionData } from "express-session";
import type { MONGO_APITokenDocument } from "tachi-common";
import type { SYMBOL_TACHI_API_AUTH, SYMBOL_TACHI_DATA } from "../../lib/constants/tachi";
import type { TachiRequestData, TachiSessionData } from "../../utils/types";
import type { APITokenDocument } from "tachi-common";
// this only exists for testing context - it doesn't seem to work properly otherwise.
declare module "express-session" {
@@ -21,7 +20,7 @@ declare global {
// even though this is technically *not* present on every request
// it's always assigned in the main router, so its functionally equivalent.
[SYMBOL_TACHI_API_AUTH]: MONGO_APITokenDocument;
[SYMBOL_TACHI_API_AUTH]: APITokenDocument;
}
}
}
-1
View File
@@ -1,5 +1,4 @@
declare module "mongodb" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface FindOneOptions<T> {
projectID?: boolean;
}
+8 -14
View File
@@ -1,6 +1,6 @@
import DB from "#services/pg/db";
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db.js";
import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_AddGoal } from "./add-goal";
@@ -20,8 +20,7 @@ describe("ACTION_AddGoal", () => {
await expect(
ACTION_AddGoal(taker, {
userID: other.id,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
charts: { type: "single", data: "chart" },
criteria: { key: "lamp", value: 0, mode: "single" },
}),
@@ -34,8 +33,7 @@ describe("ACTION_AddGoal", () => {
const { goalID } = await ACTION_AddGoal(taker, {
userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
charts: { type: "single", data: chartId },
criteria: { key: "lamp", value: 7, mode: "single" },
});
@@ -77,8 +75,7 @@ describe("ACTION_AddGoal", () => {
await ACTION_AddGoal(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
charts: { type: "single", data: chartId },
criteria: { key: "lamp", value: 7, mode: "single" },
});
@@ -103,8 +100,7 @@ describe("ACTION_AddGoal", () => {
await expect(
ACTION_AddGoal(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
charts: { type: "single", data: chartId },
criteria: { key: "not_a_real_metric", value: 0, mode: "single" },
}),
@@ -117,9 +113,8 @@ describe("ACTION_AddGoal", () => {
await expect(
ACTION_AddGoal(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
charts: { type: "single", data: "00000000-0000-0000-0000-00000000dead" },
game: "iidx-sp",
charts: { type: "single", data: "C_fake" },
criteria: { key: "lamp", value: 7, mode: "single" },
}),
).rejects.toMatchObject({ code: 400 });
@@ -131,8 +126,7 @@ describe("ACTION_AddGoal", () => {
const input = {
userID: userID,
game: "iidx" as const,
playtype: "SP" as const,
game: "iidx-sp" as const,
charts: { type: "single" as const, data: chartId },
criteria: { key: "lamp" as const, value: 7, mode: "single" as const },
};
+15 -26
View File
@@ -1,35 +1,25 @@
import { MakeAction } from "#lib/actions/actions.js";
import { SubscribeFailReasons } from "#lib/constants/err-codes.js";
import { ServerConfig } from "#lib/setup/config.js";
import { ConstructGoal, SubscribeToGoal } from "#lib/targets/goals.js";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { SubscribeFailReasons } from "#lib/constants/err-codes";
import { ServerConfig } from "#lib/setup/config";
import { ConstructGoal, SubscribeToGoal } from "#lib/targets/goals";
import DB from "#services/pg/db";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
import { sql } from "kysely";
import {
type GameGroup,
GamePTToV3,
GetGamePTConfig,
type MONGO_GoalDocument,
type Playtype,
} from "tachi-common";
import { GetGameConfig, type GoalDocument } from "tachi-common";
export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, charts, criteria } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
const { userID, game, charts, criteria } = input;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's goals.");
}
const v3Game = GamePTToV3(game, playtype);
const row = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.select(sql<number>`count(*)::int`.as("c"))
.where("goal_sub.user_id", "=", userID)
.where("goal.game", "=", v3Game)
.where("goal.game", "=", game)
.executeTakeFirst();
const existingGoalsCount = Number(row?.c ?? 0);
@@ -41,11 +31,11 @@ export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => {
);
}
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const validCriteria = [
...Object.keys(gptConfig.providedMetrics),
...Object.keys(gptConfig.derivedMetrics),
...Object.keys(gameConfig.providedMetrics),
...Object.keys(gameConfig.derivedMetrics),
];
const criteriaKey = (criteria as { key?: string }).key;
@@ -57,14 +47,13 @@ export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => {
);
}
let goal: MONGO_GoalDocument;
let goal: GoalDocument;
try {
goal = await ConstructGoal(
charts as MONGO_GoalDocument["charts"],
criteria as MONGO_GoalDocument["criteria"],
charts as GoalDocument["charts"],
criteria as GoalDocument["criteria"],
game,
playtype,
);
} catch (e) {
throw new ExpectedErr(400, (e as Error).message);
@@ -1,76 +1,34 @@
import type { GameGroup, MONGO_ChartDocument, MONGO_SongDocument } from "tachi-common";
import type { ChartDocument, GameGroup, SongDocument } from "tachi-common";
/* eslint-disable no-await-in-loop */
import { MakeAction } from "#lib/actions/actions.js";
import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart.js";
import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song.js";
import { log } from "#lib/log/log.js";
import { MakeAction } from "#lib/actions/actions";
import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart";
import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song";
import { log } from "#lib/log/log";
import { PullDatabaseSeeds } from "#lib/seeds/repo";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import DB from "#services/pg/db";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
const FETCH_CHUNK = 3_000;
async function loadAllSongsForGame(game: GameGroup): Promise<Array<MONGO_SongDocument>> {
const out: Array<MONGO_SongDocument> = [];
let lastLegacy: number | undefined;
for (;;) {
let q = DB.selectFrom("song")
.select(SELECT_SONG_DOCUMENT)
.where("game_group", "=", game)
.orderBy("legacy_id", "asc")
.limit(FETCH_CHUNK);
if (lastLegacy !== undefined) {
q = q.where("legacy_id", ">", lastLegacy);
}
const rows = await q.execute();
if (rows.length === 0) {
break;
}
for (const row of rows) {
out.push(ToSongDocument(row));
}
lastLegacy = rows[rows.length - 1]!.song_legacy_id;
}
async function loadAllSongsForGame(game: GameGroup): Promise<Array<SongDocument>> {
const out = await DB.selectFrom("song")
.select(SELECT_SONG_DOCUMENT)
.where("game_group", "=", game)
.orderBy("id", "asc")
.execute()
.then((r) => r.map(ToSongDocument));
return out;
}
async function loadAllChartsForGame(game: GameGroup): Promise<Array<MONGO_ChartDocument>> {
const out: Array<MONGO_ChartDocument> = [];
let lastChartId: string | undefined;
for (;;) {
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("song.game_group", "=", game)
.orderBy("chart.id", "asc")
.limit(FETCH_CHUNK);
if (lastChartId !== undefined) {
q = q.where("chart.id", ">", lastChartId);
}
const rows = await q.execute();
if (rows.length === 0) {
break;
}
for (const row of rows) {
out.push(ToChartDocument(row));
}
lastChartId = rows[rows.length - 1]!.chart_id;
}
async function loadAllChartsForGame(game: GameGroup): Promise<Array<ChartDocument>> {
const out = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("song.game_group", "=", game)
.orderBy("chart.id", "asc")
.execute()
.then((r) => r.map(ToChartDocument));
return out;
}
@@ -14,7 +14,7 @@ const { testMd5, mockLoadBMSTable, fakeTable } = vi.hoisted(() => {
name: "TestTable",
prefix: "★",
asciiPrefix: "t",
playtype: "7K" as const,
game: "bms-7k" as const,
url: "https://test.invalid/bms-table-unit",
description: "unit test",
};
+18 -25
View File
@@ -1,5 +1,4 @@
/* eslint-disable no-await-in-loop */
import type { Game } from "tachi-db";
import { MakeAction } from "#lib/actions/actions";
import { log } from "#lib/log/log";
@@ -14,20 +13,15 @@ import { sql } from "kysely";
import _ from "lodash";
import {
BMS_TABLES,
type BMSGames,
type BMSTableInfo,
GamePTToV3,
type MONGO_ChartDocument,
type MONGO_ChartDocumentData,
type Playtypes,
type ChartDocument,
type ChartDocumentData,
} from "tachi-common";
const UPDATE_CHUNK = 500;
type BmsChartData = MONGO_ChartDocumentData["bms:7K"];
function v3GameForBmsPlaytype(playtype: Playtypes["bms"]): Game {
return GamePTToV3("bms", playtype) as Game;
}
type BmsChartData = ChartDocumentData["bms-7k"];
function stripTableFoldersKeySql(prefix: string) {
return sql`jsonb_set(
@@ -46,7 +40,7 @@ function stripTableFoldersKeySql(prefix: string) {
*/
async function HandleTableRemovals(
tableEntries: Array<BMSTableEntry>,
playtype: Playtypes["bms"],
game: BMSGames,
prefix: string,
) {
if (tableEntries.length === 0) {
@@ -56,12 +50,10 @@ async function HandleTableRemovals(
return;
}
const v3Game = v3GameForBmsPlaytype(playtype);
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(["chart.id as chart_id", "chart.data as chart_data"])
.where("chart.game", "=", v3Game)
.where("chart.game", "=", game)
.where(sql<boolean>`(chart.data::jsonb->'tableFolders') ? ${prefix}`)
.execute();
@@ -115,15 +107,14 @@ async function HandleTableRemovals(
async function ImportTableLevels(
tableEntries: Array<BMSTableEntry>,
prefix: string,
playtype: Playtypes["bms"],
game: BMSGames,
) {
let failures = 0;
let success = 0;
const total = tableEntries.length;
const v3Game = v3GameForBmsPlaytype(playtype);
log.info(`Handling removals for ${playtype}:${prefix}...`);
await HandleTableRemovals(tableEntries, playtype, prefix);
log.info(`Handling removals for ${game}:${prefix}...`);
await HandleTableRemovals(tableEntries, game, prefix);
const md5s = tableEntries.filter((e) => e.checksum.type === "md5").map((e) => e.checksum.value);
const sha256s = tableEntries
@@ -133,7 +124,7 @@ async function ImportTableLevels(
if (md5s.length > 0) {
await DB.updateTable("chart")
.set({ data: stripTableFoldersKeySql(prefix) })
.where("game", "=", v3Game)
.where("game", "=", game)
.where(sql`(data::jsonb->>'hashMD5')::text`, "in", md5s)
.execute();
}
@@ -141,18 +132,20 @@ async function ImportTableLevels(
if (sha256s.length > 0) {
await DB.updateTable("chart")
.set({ data: stripTableFoldersKeySql(prefix) })
.where("game", "=", v3Game)
.where("game", "=", game)
.where(sql`(data::jsonb->>'hashSHA256')::text`, "in", sha256s)
.execute();
}
for (const td of tableEntries) {
let chart: MONGO_ChartDocument<"bms:7K" | "bms:14K"> | null =
await FindBMSChartOnHashInGame(td.checksum.value, v3Game);
let chart: ChartDocument<BMSGames> | null = await FindBMSChartOnHashInGame(
td.checksum.value,
game,
);
if (!chart) {
chart = await DeorphanBmsIfInOrphanChartPg(
playtype,
game,
td.checksum.type === "md5" ? "md5" : "sha256",
td.checksum.value,
);
@@ -196,7 +189,7 @@ async function ImportTableLevels(
? sql`jsonb_set(data::jsonb, '{tableString}', 'null'::jsonb)`
: sql`jsonb_set(data::jsonb, '{tableString}', to_jsonb(${tableString}::text))`,
})
.where("legacy_id", "=", chart.songID)
.where("song.id", "=", chart.song.id)
.where("song.game_group", "=", "bms")
.execute();
@@ -211,7 +204,7 @@ export async function UpdateTable(tableInfo: BMSTableInfo) {
const table = await LoadBMSTable(tableInfo.url);
log.info(`Bumping levels...`);
await ImportTableLevels(table.body, tableInfo.prefix, tableInfo.playtype);
await ImportTableLevels(table.body, tableInfo.prefix, tableInfo.game);
log.info(`Levels bumped.`);
}
@@ -1,8 +1,8 @@
import { MakeAction } from "#lib/actions/actions.js";
import { CDNStoreOrOverwrite } from "#lib/cdn/cdn.js";
import { GetProfileBannerURL } from "#lib/cdn/url-format.js";
import DB from "#services/pg/db.js";
import { HashSHA256 } from "#utils/crypto.js";
import { MakeAction } from "#lib/actions/actions";
import { CDNStoreOrOverwrite } from "#lib/cdn/cdn";
import { GetProfileBannerURL } from "#lib/cdn/url-format";
import DB from "#services/pg/db";
import { HashSHA256 } from "#utils/crypto";
import { ExpectedErr } from "bliss";
export const ACTION_ChangeBanner = MakeAction(
@@ -1,7 +1,6 @@
import { ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db";
import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_ChangeEmail } from "./change-email";
@@ -129,7 +128,7 @@ describe("ACTION_ChangeEmail", () => {
).rejects.toMatchObject({ code: 409 });
});
// ── Success path (no EMAIL_CONFIG) ────────────────────────────────────────
// ── Success path ──────────────────────────────────────────────────────────
it("returns an empty object on success", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
@@ -152,7 +151,33 @@ describe("ACTION_ChangeEmail", () => {
expect(row.email).toBe(NEW_EMAIL);
});
it("does not insert a verify-email token when EMAIL_CONFIG is absent", async () => {
it("inserts a verify-email token for the new address", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const row = await DB.selectFrom("priv_verify_email_token")
.selectAll()
.where("user_id", "=", userId)
.executeTakeFirstOrThrow();
expect(row.email).toBe(NEW_EMAIL);
});
it("generates a non-empty hex token", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const row = await DB.selectFrom("priv_verify_email_token")
.select("token")
.where("user_id", "=", userId)
.executeTakeFirstOrThrow();
expect(row.token).toMatch(/^[0-9a-f]{40}$/u);
});
it("keeps exactly one token row for the user after the change", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
@@ -162,7 +187,44 @@ describe("ACTION_ChangeEmail", () => {
.where("user_id", "=", userId)
.execute();
expect(rows).toHaveLength(0);
expect(rows).toHaveLength(1);
});
it("replaces any pre-existing verify-email token", async () => {
await seedVerifyEmailToken(userId, "old@example.com", "OLD_TOKEN_ABCDEF1234");
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const rows = await DB.selectFrom("priv_verify_email_token")
.select(["token", "email"])
.where("user_id", "=", userId)
.execute();
expect(rows).toHaveLength(1);
expect(rows[0]?.token).not.toBe("OLD_TOKEN_ABCDEF1234");
expect(rows[0]?.email).toBe(NEW_EMAIL);
});
it("does not touch verify-email tokens belonging to other users", async () => {
const other = await seedUser({
username: "other_user",
email: "other@example.com",
withCredential: true,
});
await seedVerifyEmailToken(other.id, "other@example.com", "OTHER_TOKEN_XYZ");
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const row = await DB.selectFrom("priv_verify_email_token")
.select("token")
.where("user_id", "=", other.id)
.executeTakeFirstOrThrow();
expect(row.token).toBe("OTHER_TOKEN_XYZ");
});
it("does not affect other users' credential rows", async () => {
@@ -184,97 +246,6 @@ describe("ACTION_ChangeEmail", () => {
expect(row.email).toBe("other@example.com");
});
// ── Success path (with EMAIL_CONFIG) ──────────────────────────────────────
describe("when EMAIL_CONFIG is set", () => {
afterEach(() => {
// Restore the config after each test in this group.
(ServerConfig as Record<string, unknown>).EMAIL_CONFIG = undefined;
});
beforeEach(() => {
(ServerConfig as Record<string, unknown>).EMAIL_CONFIG = {
FROM: "noreply@example.com",
};
});
it("inserts a verify-email token for the new address", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const row = await DB.selectFrom("priv_verify_email_token")
.selectAll()
.where("user_id", "=", userId)
.executeTakeFirstOrThrow();
expect(row.email).toBe(NEW_EMAIL);
});
it("generates a non-empty hex token", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const row = await DB.selectFrom("priv_verify_email_token")
.select("token")
.where("user_id", "=", userId)
.executeTakeFirstOrThrow();
expect(row.token).toMatch(/^[0-9a-f]{40}$/u);
});
it("keeps exactly one token row for the user after the change", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const rows = await DB.selectFrom("priv_verify_email_token")
.select("token")
.where("user_id", "=", userId)
.execute();
expect(rows).toHaveLength(1);
});
it("replaces any pre-existing verify-email token", async () => {
await seedVerifyEmailToken(userId, "old@example.com", "OLD_TOKEN_ABCDEF1234");
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const rows = await DB.selectFrom("priv_verify_email_token")
.select(["token", "email"])
.where("user_id", "=", userId)
.execute();
expect(rows).toHaveLength(1);
expect(rows[0]?.token).not.toBe("OLD_TOKEN_ABCDEF1234");
expect(rows[0]?.email).toBe(NEW_EMAIL);
});
it("does not touch verify-email tokens belonging to other users", async () => {
const other = await seedUser({
username: "other_user",
email: "other@example.com",
withCredential: true,
});
await seedVerifyEmailToken(other.id, "other@example.com", "OTHER_TOKEN_XYZ");
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
const row = await DB.selectFrom("priv_verify_email_token")
.select("token")
.where("user_id", "=", other.id)
.executeTakeFirstOrThrow();
expect(row.token).toBe("OTHER_TOKEN_XYZ");
});
});
// ── Audit log ─────────────────────────────────────────────────────────────
it("writes a GOOD action row to the audit log on success", async () => {
+21 -27
View File
@@ -1,11 +1,10 @@
import { MakeAction } from "#lib/actions/actions.js";
import { PasswordCompare } from "#lib/auth/auth.js";
import { SendEmail } from "#lib/email/client.js";
import { EmailFormatVerifyEmail } from "#lib/email/formats.js";
import { ServerConfig } from "#lib/setup/config.js";
import DB from "#services/pg/db.js";
import { Random20Hex } from "#utils/misc.js";
import { CheckIfEmailInUse } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { PasswordCompare } from "#lib/auth/auth";
import { SendEmail } from "#lib/email/client";
import { EmailFormatVerifyEmail } from "#lib/email/formats";
import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { CheckIfEmailInUse } from "#utils/user";
import { ExpectedErr, log } from "bliss";
export const ACTION_ChangeEmail = MakeAction(
@@ -45,29 +44,24 @@ export const ACTION_ChangeEmail = MakeAction(
.where("user_id", "=", taker.acct.id)
.execute();
if (ServerConfig.EMAIL_CONFIG) {
const resetEmailCode = Random20Hex();
const resetEmailCode = Random20Hex();
// clear out the previous email code!
await DB.deleteFrom("priv_verify_email_token")
.where("user_id", "=", taker.acct.id)
.execute();
// clear out the previous email code!
await DB.deleteFrom("priv_verify_email_token")
.where("user_id", "=", taker.acct.id)
.execute();
await DB.insertInto("priv_verify_email_token")
.values({
email,
token: resetEmailCode,
user_id: taker.acct.id,
})
.execute();
await DB.insertInto("priv_verify_email_token")
.values({
email,
token: resetEmailCode,
user_id: taker.acct.id,
})
.execute();
const { text, html } = EmailFormatVerifyEmail(
taker.acct.username,
resetEmailCode,
);
const { text, html } = EmailFormatVerifyEmail(taker.acct.username, resetEmailCode);
void SendEmail(email, "Email Verification", html, text);
}
void SendEmail(email, "Email Verification", html, text);
});
return {};
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import { HashPassword, PasswordCompare } from "#lib/auth/auth.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import { HashPassword, PasswordCompare } from "#lib/auth/auth";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
export const ACTION_ChangePassword = MakeAction(
+5 -5
View File
@@ -1,8 +1,8 @@
import { MakeAction } from "#lib/actions/actions.js";
import { CDNStoreOrOverwrite } from "#lib/cdn/cdn.js";
import { GetProfilePictureURL } from "#lib/cdn/url-format.js";
import DB from "#services/pg/db.js";
import { HashSHA256 } from "#utils/crypto.js";
import { MakeAction } from "#lib/actions/actions";
import { CDNStoreOrOverwrite } from "#lib/cdn/cdn";
import { GetProfilePictureURL } from "#lib/cdn/url-format";
import DB from "#services/pg/db";
import { HashSHA256 } from "#utils/crypto";
import { ExpectedErr } from "bliss";
export const ACTION_ChangePfp = MakeAction(
@@ -1,8 +1,8 @@
import { MakeAction } from "#lib/actions/actions.js";
import { PasswordCompare } from "#lib/auth/auth.js";
import DB from "#services/pg/db.js";
import { NowISO8601 } from "#utils/time.js";
import { CanChangeUsername } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { PasswordCompare } from "#lib/auth/auth";
import DB from "#services/pg/db";
import { NowISO8601 } from "#utils/time";
import { CanChangeUsername } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_ChangeUsername = MakeAction(
@@ -1,9 +1,9 @@
import { MakeAction } from "#lib/actions/actions.js";
import { ServerConfig } from "#lib/setup/config.js";
import DB from "#services/pg/db.js";
import { DedupeArr, Random20Hex } from "#utils/misc.js";
import { IsUserAdmin } from "#utils/user.js";
import { validateWebhookUri } from "#utils/validate-webhook-uri.js";
import { MakeAction } from "#lib/actions/actions";
import { ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db";
import { DedupeArr, Random20Hex } from "#utils/misc";
import { IsUserAdmin } from "#utils/user";
import { validateWebhookUri } from "#utils/validate-webhook-uri";
import { ExpectedErr } from "bliss";
import { ALL_PERMISSIONS, type APIPermissions } from "tachi-common";
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { Random20Hex } from "#utils/misc.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { ExpectedErr } from "bliss";
import { ALL_PERMISSIONS } from "tachi-common";
@@ -1,9 +1,9 @@
import { MakeAction } from "#lib/actions/actions.js";
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user.js";
import { GetTotalAllowedInvites } from "#lib/invites/invites.js";
import DB from "#services/pg/db.js";
import { Random20Hex } from "#utils/misc.js";
import { ISO8601ToUnixMilliseconds } from "#utils/time.js";
import { MakeAction } from "#lib/actions/actions";
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user";
import { GetTotalAllowedInvites } from "#lib/invites/invites";
import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { ISO8601ToUnixMilliseconds } from "#utils/time";
import { ExpectedErr } from "bliss";
import { sql } from "kysely";
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { Random20Hex } from "#utils/misc.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
export const ACTION_CreateOAuth2AuthCode = MakeAction("CREATE_OAUTH2_AUTH_CODE", async (taker) => {
const code = Random20Hex();
@@ -1,8 +1,8 @@
import { LoadScoreDocumentById } from "#lib/db-formats/score.js";
import { LoadScoreDocumentById } from "#lib/db-formats/score";
import { mongoScoreDataToPg, pgScoreDataToMongo } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { type MONGO_ScoreData } from "tachi-common";
import { type ScoreData } from "tachi-common";
import { describe, expect, it } from "vitest";
import { ACTION_CustomiseScore } from "./customise-score";
@@ -17,9 +17,9 @@ describe("mergeScoreDataFromPg", () => {
percent: 50,
score: 200,
optional: {},
} as MONGO_ScoreData<"iidx:SP">;
} as ScoreData<"iidx-sp">;
const pg = mongoScoreDataToPg("iidx:SP", { ...original, judgements: {} });
const pg = mongoScoreDataToPg("iidx-sp", { ...original, judgements: {} });
const back = pgScoreDataToMongo("iidx-sp", pg);
expect(back).toMatchObject({
@@ -72,14 +72,14 @@ describe("ACTION_CustomiseScore", () => {
})
.execute();
const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", {
const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", {
grade: "F",
lamp: "FAILED",
percent: 0,
score: 100,
optional: {},
judgements: {},
} as MONGO_ScoreData<"iidx:SP">);
} as ScoreData<"iidx-sp">);
await DB.insertInto("score")
.values({
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
export const ACTION_CustomiseScore = MakeAction("CUSTOMISE_SCORE", async (_taker, input) => {
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import { ONE_SECOND } from "#lib/constants/time.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import { ONE_SECOND } from "#lib/constants/time";
import DB from "#services/pg/db";
export const ACTION_DeleteAllNotifications = MakeAction(
"DELETE_ALL_NOTIFICATIONS",
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteApiClient = MakeAction(
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteApiToken = MakeAction("DELETE_API_TOKEN", async (taker, { token }) => {
@@ -1,7 +1,7 @@
import { MakeAction } from "#lib/actions/actions.js";
import { CDNDelete } from "#lib/cdn/cdn.js";
import { GetProfileBannerURL } from "#lib/cdn/url-format.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import { CDNDelete } from "#lib/cdn/cdn";
import { GetProfileBannerURL } from "#lib/cdn/url-format";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteBanner = MakeAction("DELETE_BANNER", async (taker) => {
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
export const ACTION_DeleteCgCardInfo = MakeAction(
"DELETE_CG_CARD_INFO",
@@ -1,7 +1,7 @@
import { MakeAction } from "#lib/actions/actions.js";
import { LoadImportDocumentById } from "#lib/db-formats/import-document.js";
import { RevertImport } from "#lib/imports/imports.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { LoadImportDocumentById } from "#lib/db-formats/import-document";
import { RevertImport } from "#lib/imports/imports";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteImport = MakeAction("DELETE_IMPORT", async (taker, { id }) => {
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
export const ACTION_DeleteMytCardInfo = MakeAction(
"DELETE_MYT_CARD_INFO",
+4 -4
View File
@@ -1,7 +1,7 @@
import { MakeAction } from "#lib/actions/actions.js";
import { CDNDelete } from "#lib/cdn/cdn.js";
import { GetProfilePictureURL } from "#lib/cdn/url-format.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import { CDNDelete } from "#lib/cdn/cdn";
import { GetProfilePictureURL } from "#lib/cdn/url-format";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
export const ACTION_DeletePfp = MakeAction("DELETE_PFP", async (taker) => {
@@ -1,7 +1,7 @@
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { type MONGO_ScoreData } from "tachi-common";
import { type ScoreData } from "tachi-common";
import { describe, expect, it } from "vitest";
import { ACTION_DeleteScore } from "./delete-score";
@@ -45,14 +45,14 @@ describe("ACTION_DeleteScore", () => {
})
.execute();
const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", {
const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", {
grade: "F",
lamp: "FAILED",
percent: 0,
score: 100,
optional: {},
judgements: {},
} as MONGO_ScoreData<"iidx:SP">);
} as ScoreData<"iidx-sp">);
await DB.insertInto("score")
.values({
@@ -1,7 +1,7 @@
import { MakeAction } from "#lib/actions/actions.js";
import { LoadScoreDocumentById } from "#lib/db-formats/score.js";
import { DeleteScore } from "#lib/score-mutation/delete-scores.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { LoadScoreDocumentById } from "#lib/db-formats/score";
import { DeleteScore } from "#lib/score-mutation/delete-scores";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteScore = MakeAction("DELETE_SCORE", async (taker, { id, blacklist }) => {
@@ -1,7 +1,7 @@
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { type MONGO_ScoreData } from "tachi-common";
import { type ScoreData } from "tachi-common";
import { describe, expect, it } from "vitest";
import { ACTION_DeleteSession } from "./delete-session";
@@ -46,14 +46,14 @@ describe("ACTION_DeleteSession", () => {
})
.execute();
const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", {
const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", {
grade: "F",
lamp: "FAILED",
percent: 0,
score: 100,
optional: {},
judgements: {},
} as MONGO_ScoreData<"iidx:SP">);
} as ScoreData<"iidx-sp">);
await DB.insertInto("session")
.values({
@@ -1,8 +1,8 @@
import { MakeAction } from "#lib/actions/actions.js";
import { LoadSessionDocumentById } from "#lib/db-formats/session.js";
import { DeleteMultipleScores } from "#lib/score-mutation/delete-scores.js";
import { GetScoresFromSession } from "#utils/session.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { LoadSessionDocumentById } from "#lib/db-formats/session";
import { DeleteMultipleScores } from "#lib/score-mutation/delete-scores";
import { GetScoresFromSession } from "#utils/session";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteSession = MakeAction("DELETE_SESSION", async (taker, { id }) => {
@@ -1,7 +1,7 @@
import { ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_FollowUser } from "./follow-user";
@@ -23,13 +23,13 @@ describe("ACTION_FollowUser", () => {
it("throws when userID is 0 (not a positive integer)", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_FollowUser(taker, { userID: 0 })).rejects.toThrow("invalid input");
await expect(ACTION_FollowUser(taker, { userID: 0 })).rejects.toMatchObject({ code: 400 });
});
it("throws when userID is negative", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_FollowUser(taker, { userID: -1 })).rejects.toThrow("invalid input");
await expect(ACTION_FollowUser(taker, { userID: -1 })).rejects.toMatchObject({ code: 400 });
});
// ── Self-follow guard ─────────────────────────────────────────────────────
+4 -4
View File
@@ -1,7 +1,7 @@
import { MakeAction } from "#lib/actions/actions.js";
import { ServerConfig } from "#lib/setup/config.js";
import DB from "#services/pg/db.js";
import { GetUserWithID } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db";
import { GetUserWithID } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_FollowUser = MakeAction("FOLLOW_USER", async (taker, { userID: toFollow }) => {
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import { ONE_SECOND } from "#lib/constants/time.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import { ONE_SECOND } from "#lib/constants/time";
import DB from "#services/pg/db";
export const ACTION_MarkAllNotificationsRead = MakeAction(
"MARK_ALL_NOTIFICATIONS_READ",
@@ -1,5 +1,5 @@
import { SELECT_ACTION } from "#lib/db-formats/action";
import { GetUGPTSettingsDocument, SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings.js";
import { GetUGPTSettingsDocument, SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
@@ -33,12 +33,11 @@ describe("ACTION_PatchUGPTSettings", () => {
await ACTION_PatchUGPTSettings(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
preferences: { preferredScoreAlg: "ktLampRating" },
});
const settings = await GetUGPTSettingsDocument(userId, "iidx", "SP");
const settings = await GetUGPTSettingsDocument(userId, "iidx-sp");
expect(settings?.preferences.preferredScoreAlg).toBe("ktLampRating");
@@ -56,8 +55,7 @@ describe("ACTION_PatchUGPTSettings", () => {
await ACTION_PatchUGPTSettings(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
preferences: { preferredProfileAlg: "ktRating" },
});
@@ -76,8 +74,7 @@ describe("ACTION_PatchUGPTSettings", () => {
await expect(
ACTION_PatchUGPTSettings(taker, {
userID: other.id,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
preferences: { preferredScoreAlg: "ktLampRating" },
}),
).rejects.toMatchObject({ code: 403 });
@@ -1,32 +1,23 @@
import { MakeAction } from "#lib/actions/actions.js";
import { SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { GetUGPTSettingsDocument, SELECT_GAME_SETTINGS } from "#lib/db-formats/ugpt-settings";
import DB from "#services/pg/db";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
import {
type GameGroup,
GamePTToV3,
type MONGO_UGPTSettingsDocument,
type Playtype,
} from "tachi-common";
import { type UGPTSettingsDocument } from "tachi-common";
import { type GameSettingsUpdate } from "tachi-db";
export const ACTION_PatchUGPTSettings = MakeAction("PATCH_UGPT_SETTINGS", async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, preferences } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
const body = preferences as Partial<MONGO_UGPTSettingsDocument["preferences"]>;
const { userID, game, preferences } = input;
const body = preferences as Partial<UGPTSettingsDocument["preferences"]>;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's settings.");
}
const v3Game = GamePTToV3(game, playtype);
if (typeof body.defaultTable === "string") {
const table = await DB.selectFrom("table")
.select("id")
.where("game", "=", v3Game)
.where("game", "=", game)
.where("legacy_id", "=", body.defaultTable)
.executeTakeFirst();
@@ -54,13 +45,17 @@ export const ACTION_PatchUGPTSettings = MakeAction("PATCH_UGPT_SETTINGS", async
hasGameSpecificKeys;
if (!hasUpdates) {
return {};
const settings = await GetUGPTSettingsDocument(userID, game);
if (!settings) {
throw new ExpectedErr(404, "You do not have an account for this game.");
}
return { settings };
}
const row = await DB.selectFrom("game_settings")
.select(SELECT_GAME_SETTINGS)
.where("game_settings.user_id", "=", userID)
.where("game_settings.game", "=", v3Game)
.where("game_settings.game", "=", game)
.executeTakeFirst();
if (!row) {
@@ -97,14 +92,22 @@ export const ACTION_PatchUGPTSettings = MakeAction("PATCH_UGPT_SETTINGS", async
}
if (Object.keys(set).length === 0) {
return {};
const settings = await GetUGPTSettingsDocument(userID, game);
if (!settings) {
throw new ExpectedErr(404, "You do not have an account for this game.");
}
return { settings };
}
await DB.updateTable("game_settings")
.set(set)
.where("user_id", "=", userID)
.where("game", "=", v3Game)
.where("game", "=", game)
.execute();
return {};
const settings = await GetUGPTSettingsDocument(userID, game);
if (!settings) {
throw new ExpectedErr(500, "Settings were updated but could not be reloaded.");
}
return { settings };
});
@@ -1,7 +1,7 @@
import { MakeAction } from "#lib/actions/actions.js";
import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup.js";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup";
import DB from "#services/pg/db";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_RebuildFolderChartLookup = MakeAction(
@@ -20,8 +20,7 @@ describe("ACTION_RemoveGoalSubscription", () => {
await expect(
ACTION_RemoveGoalSubscription(taker, {
userID: other.id,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
goalID: "Gfake",
}),
).rejects.toMatchObject({ code: 403 });
@@ -33,8 +32,7 @@ describe("ACTION_RemoveGoalSubscription", () => {
await expect(
ACTION_RemoveGoalSubscription(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
goalID: "Gnonexistent_goal_id_xyz",
}),
).rejects.toMatchObject({ code: 404 });
@@ -46,16 +44,14 @@ describe("ACTION_RemoveGoalSubscription", () => {
const { goalID } = await ACTION_AddGoal(taker, {
userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
charts: { type: "single", data: chartId },
criteria: { key: "lamp", value: 7, mode: "single" },
});
await ACTION_RemoveGoalSubscription(taker, {
userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
goalID,
});
@@ -74,16 +70,14 @@ describe("ACTION_RemoveGoalSubscription", () => {
const { goalID } = await ACTION_AddGoal(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
charts: { type: "single", data: chartId },
criteria: { key: "lamp", value: 7, mode: "single" },
});
await ACTION_RemoveGoalSubscription(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
goalID,
});
@@ -106,8 +100,7 @@ describe("ACTION_RemoveGoalSubscription", () => {
await expect(
ACTION_RemoveGoalSubscription(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
goalID: "Gmissing_subscription",
}),
).rejects.toMatchObject({ code: 404 });
@@ -1,32 +1,27 @@
import { MakeAction } from "#lib/actions/actions.js";
import { MakeAction } from "#lib/actions/actions";
import { SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal";
import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents.js";
import { UnsubscribeFromGoal } from "#lib/targets/goals.js";
import DB from "#services/pg/db.js";
import { staticAssertUnreachable } from "#utils/misc.js";
import { IsUserAdmin } from "#utils/user.js";
import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents";
import { UnsubscribeFromGoal } from "#lib/targets/goals";
import DB from "#services/pg/db";
import { staticAssertUnreachable } from "#utils/misc";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
import { type GameGroup, GamePTToV3, type Playtype } from "tachi-common";
export const ACTION_RemoveGoalSubscription = MakeAction(
"REMOVE_GOAL_SUBSCRIPTION",
async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, goalID } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
const { userID, game, goalID } = input;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's goals.");
}
const v3Game = GamePTToV3(game, playtype);
const row = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
.where("goal_sub.user_id", "=", userID)
.where("goal_sub.goal_id", "=", goalID)
.where("goal.game", "=", v3Game)
.where("goal.game", "=", game)
.executeTakeFirst();
if (!row) {
@@ -1,7 +1,7 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { Random20Hex } from "#utils/misc.js";
import { GetClientByID } from "#utils/queries/api-clients.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { GetClientByID } from "#utils/queries/api-clients";
import { ExpectedErr } from "bliss";
export const ACTION_ResetApiClientSecret = MakeAction(
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
export const ACTION_RevokeKaiAuthToken = MakeAction(
"REVOKE_KAI_AUTH_TOKEN",
@@ -38,8 +38,7 @@ describe("ACTION_SetRivals", () => {
await ACTION_SetRivals(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
rivalIDs: [rivalId],
});
@@ -58,8 +57,7 @@ describe("ACTION_SetRivals", () => {
await ACTION_SetRivals(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
rivalIDs: [rivalId],
});
@@ -78,8 +76,7 @@ describe("ACTION_SetRivals", () => {
await expect(
ACTION_SetRivals(taker, {
userID: other.id,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
rivalIDs: [rivalId],
}),
).rejects.toMatchObject({ code: 403 });
@@ -91,8 +88,7 @@ describe("ACTION_SetRivals", () => {
await expect(
ACTION_SetRivals(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
rivalIDs: [userId],
}),
).rejects.toMatchObject({ code: 400 });
@@ -129,8 +125,7 @@ describe("ACTION_SetRivals", () => {
await expect(
ACTION_SetRivals(taker, {
userID: main.id,
game: "iidx",
playtype: "SP",
game: "iidx-sp",
rivalIDs: rivalUsers.map((u) => u.id),
}),
).rejects.toMatchObject({ code: 400 });
+11 -20
View File
@@ -1,24 +1,17 @@
import { MakeAction } from "#lib/actions/actions.js";
import { SetRivalsFailReasons } from "#lib/constants/err-codes.js";
import { setRivalsWithResult } from "#lib/rivals/rivals.js";
import { staticAssertUnreachable } from "#utils/misc.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { SetRivalsFailReasons } from "#lib/constants/err-codes";
import { setRivalsWithResult } from "#lib/rivals/rivals";
import { staticAssertUnreachable } from "#utils/misc";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
import { FormatGameGroup, type GameGroup, type Playtype } from "tachi-common";
import { type V3Game } from "tachi-common";
function throwIfSetRivalsFailed(
game: GameGroup,
playtype: Playtype,
reason: SetRivalsFailReasons,
): never {
function throwIfSetRivalsFailed(game: V3Game, reason: SetRivalsFailReasons): never {
switch (reason) {
case SetRivalsFailReasons.RIVALED_SELF:
throw new ExpectedErr(400, `You cannot rival yourself.`);
case SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT:
throw new ExpectedErr(
400,
`Not all of the rivals you specified have played ${FormatGameGroup(game, playtype)}.`,
);
throw new ExpectedErr(400, `Not all of the rivals you specified have played ${game}.`);
case SetRivalsFailReasons.TOO_MANY:
throw new ExpectedErr(400, `You can't set more than 5 rivals.`);
default:
@@ -27,17 +20,15 @@ function throwIfSetRivalsFailed(
}
export const ACTION_SetRivals = MakeAction("SET_RIVALS", async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, rivalIDs } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
const { userID, game, rivalIDs } = input;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's rivals.");
}
const err = await setRivalsWithResult(userID, game, playtype, rivalIDs);
const err = await setRivalsWithResult(userID, game, rivalIDs);
if (err !== null) {
throwIfSetRivalsFailed(game, playtype, err);
throwIfSetRivalsFailed(game, err);
}
return {};
@@ -28,16 +28,16 @@ describe("ACTION_SetUserSupporterStatus", () => {
it("throws when userID is 0 (not a positive integer)", async () => {
await expect(
ACTION_SetUserSupporterStatus(adminTaker(), { userID: 0, isSupporter: true }),
).rejects.toThrow(/SET_USER_SUPPORTER_STATUS received invalid input/u);
).rejects.toMatchObject({ code: 400 });
});
it("throws when userID is negative", async () => {
await expect(
ACTION_SetUserSupporterStatus(adminTaker(), { userID: -1, isSupporter: true }),
).rejects.toThrow(/SET_USER_SUPPORTER_STATUS received invalid input/u);
).rejects.toMatchObject({ code: 400 });
});
it("writes a THROW action row when input fails Zod validation", async () => {
it("writes a BAD action row when input fails Zod validation", async () => {
await expect(
ACTION_SetUserSupporterStatus(adminTaker("9.9.9.9"), { userID: 0, isSupporter: true }),
).rejects.toThrow();
@@ -50,7 +50,7 @@ describe("ACTION_SetUserSupporterStatus", () => {
expect(action).toMatchObject({
kind: "SET_USER_SUPPORTER_STATUS",
result: "THROW",
result: "BAD",
ip: "9.9.9.9",
});
});
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_SetUserSupporterStatus = MakeAction(
@@ -1,5 +1,8 @@
import { SELECT_API_TOKEN } from "#lib/db-formats/api-token";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
export { seedUser };
// ─── seedApiClient ────────────────────────────────────────────────────────────
@@ -1,13 +1,13 @@
import type { Game } from "tachi-db";
/* eslint-disable no-await-in-loop */
import { MakeAction } from "#lib/actions/actions.js";
import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js";
import { log } from "#lib/log/log.js";
import DB from "#services/pg/db.js";
import { GetMillisecondsSince } from "#utils/misc.js";
import { UnixMillisecondsToISO8601 } from "#utils/time.js";
import { GetAllRankings, GetUGPTPlaycount, IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles";
import { log } from "#lib/log/log";
import DB from "#services/pg/db";
import { GetMillisecondsSince } from "#utils/misc";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { GetAllRankings, GetUGPTPlaycount, IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
const PROFILE_BATCH = 500;
@@ -91,10 +91,10 @@ export async function runUgsSnapshotCore() {
for (const row of rows) {
const stats = ToGameStatsDocument(row);
log.debug(`Snapshotting ${stats.userID} ${stats.playtype} ${stats.game}.`);
log.debug(`Snapshotting ${stats.userID} ${stats.game}.`);
const [playcount, rankings] = await Promise.all([
GetUGPTPlaycount(stats.userID, stats.game, stats.playtype),
GetUGPTPlaycount(stats.userID, stats.game),
GetAllRankings(stats),
]);
@@ -53,13 +53,17 @@ describe("ACTION_UnfollowUser", () => {
it("throws when userID is 0 (not a positive integer)", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_UnfollowUser(taker, { userID: 0 })).rejects.toThrow("invalid input");
await expect(ACTION_UnfollowUser(taker, { userID: 0 })).rejects.toMatchObject({
code: 400,
});
});
it("throws when userID is negative", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_UnfollowUser(taker, { userID: -1 })).rejects.toThrow("invalid input");
await expect(ACTION_UnfollowUser(taker, { userID: -1 })).rejects.toMatchObject({
code: 400,
});
});
// ── Happy path ────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { GetUserWithID } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { GetUserWithID } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_UnfollowUser = MakeAction(
@@ -1,7 +1,7 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { GetClientByID } from "#utils/queries/api-clients.js";
import { validateWebhookUri } from "#utils/validate-webhook-uri.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { GetClientByID } from "#utils/queries/api-clients";
import { validateWebhookUri } from "#utils/validate-webhook-uri";
import { ExpectedErr } from "bliss";
export const ACTION_UpdateApiClient = MakeAction(
@@ -1,25 +1,27 @@
/* eslint-disable no-await-in-loop */
import type { Difficulties, integer, MONGO_ChartDocument, Playtypes } from "tachi-common";
import type { ChartDocument, Difficulties, GamesForGroup, integer } from "tachi-common";
import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum";
import { ComputeChartStabilityChecksum } from "#game-implementations/utils/derivation-checksum";
import { MakeAction } from "#lib/actions/actions";
import { log } from "#lib/log/log";
import DB from "#services/pg/db";
import fetch from "#utils/fetch";
import { FindChartWithPTDFVersion } from "#utils/queries/charts";
import { FindChartWithSongDifficultyVersion } from "#utils/queries/charts";
import { FindSongOnTitle } from "#utils/queries/songs";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
const difficultyResolve: Record<string, [Playtypes["iidx"], Difficulties["iidx:DP" | "iidx:SP"]]> =
{
3: ["SP", "HYPER"],
4: ["SP", "ANOTHER"],
8: ["DP", "HYPER"],
9: ["DP", "ANOTHER"],
10: ["SP", "LEGGENDARIA"],
11: ["DP", "LEGGENDARIA"],
};
const difficultyResolve: Record<
string,
[GamesForGroup["iidx"], Difficulties[GamesForGroup["iidx"]]]
> = {
3: ["iidx-sp", "HYPER"],
4: ["iidx-sp", "ANOTHER"],
8: ["iidx-dp", "HYPER"],
9: ["iidx-dp", "ANOTHER"],
10: ["iidx-sp", "LEGGENDARIA"],
11: ["iidx-dp", "LEGGENDARIA"],
};
interface PoyashiProxyBPIInfo {
title: string;
@@ -55,14 +57,14 @@ export async function updateBpiDataCore() {
const updatedChartIDs: Array<string> = [];
for (const d of data.body) {
const res: ["DP" | "SP", Difficulties["iidx:DP" | "iidx:SP"]] | undefined =
const res: [GamesForGroup["iidx"], Difficulties[GamesForGroup["iidx"]]] | undefined =
difficultyResolve[d.difficulty];
if (!res) {
throw new Error(`Unknown difficulty ${d.difficulty}`);
}
const [playtype, diff] = res;
const [game, diff] = res;
const tachiSong = await FindSongOnTitle("iidx", d.title);
@@ -71,25 +73,18 @@ export async function updateBpiDataCore() {
continue;
}
const tachiChart = await FindChartWithPTDFVersion(
"iidx",
tachiSong.id,
playtype,
diff,
"29",
);
// TODO(zk): why 29? shouldn't this be updated by now?
const tachiChart = await FindChartWithSongDifficultyVersion(game, tachiSong.id, diff, "29");
if (!tachiChart) {
log.warn(
`Cannot find chart ${tachiSong.title} (${tachiSong.id}) ${playtype}, ${diff}?`,
);
log.warn(`Cannot find chart ${tachiSong.title} (${tachiSong.id}) ${game}, ${diff}?`);
continue;
}
const kavg = Number(d.avg);
if (kavg < 0) {
log.warn(`${tachiSong.title} (${playtype} ${diff}). Invalid kavg ${d.avg}, Skipping.`);
log.warn(`${tachiSong.title} (${game} ${diff}). Invalid kavg ${d.avg}, Skipping.`);
continue;
}
@@ -102,9 +97,7 @@ export async function updateBpiDataCore() {
const newKavg = Number(d.avg);
const newWR = Number(d.wr);
const iidxChart = tachiChart as
| MONGO_ChartDocument<"iidx:DP">
| MONGO_ChartDocument<"iidx:SP">;
const iidxChart = tachiChart as ChartDocument<GamesForGroup["iidx"]>;
if (
iidxChart.data.bpiCoefficient !== newCoef ||
@@ -120,9 +113,8 @@ export async function updateBpiDataCore() {
worldRecord: newWR,
};
const gpt = `iidx:${playtype}` as const;
const updatedChart = { ...tachiChart, data: mergedData } as MONGO_ChartDocument;
const checksum = computeDerivationChecksumForGPT(gpt, updatedChart);
const updatedChart = { ...tachiChart, data: mergedData } as ChartDocument;
const checksum = ComputeChartStabilityChecksum(game, updatedChart);
await DB.updateTable("chart")
.set({
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
export const ACTION_UpdateCgCardInfo = MakeAction(
"UPDATE_CG_CARD_INFO",
@@ -1,12 +1,12 @@
import type { MONGO_ChartDocument } from "tachi-common";
import type { ChartDocument } from "tachi-common";
/* eslint-disable no-await-in-loop */
import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum";
import { ComputeChartStabilityChecksum } from "#game-implementations/utils/derivation-checksum";
import { MakeAction } from "#lib/actions/actions";
import { log } from "#lib/log/log";
import DB from "#services/pg/db";
import fetch from "#utils/fetch";
import { FindChartWithPTDFVersion } from "#utils/queries/charts";
import { FindChartWithSongDifficultyVersion } from "#utils/queries/charts";
import { FindSongOnTitle } from "#utils/queries/songs";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
@@ -42,7 +42,7 @@ export async function updateDpTiersCore() {
log.info(`Got DP tier data. Applying it.`);
const updatedSongIDs = new Set<number>();
const updatedSongIDs = new Set<string>();
for (const d of parsedData) {
const song = await FindSongOnTitle("iidx", d.songTitle);
@@ -57,7 +57,12 @@ export async function updateDpTiersCore() {
continue;
}
const chart = await FindChartWithPTDFVersion("iidx", song.id, "DP", difficulty, "29");
const chart = await FindChartWithSongDifficultyVersion(
"iidx-dp",
song.id,
difficulty,
"29",
);
if (!chart) {
log.warn(
@@ -72,7 +77,7 @@ export async function updateDpTiersCore() {
individualDifference: false,
};
const dpChart = chart as MONGO_ChartDocument<"iidx:DP">;
const dpChart = chart as ChartDocument<"iidx-dp">;
const prev = dpChart.data.dpTier;
if (
@@ -89,8 +94,8 @@ export async function updateDpTiersCore() {
dpTier: nextTier,
};
const updatedChart = { ...chart, data: mergedData } as MONGO_ChartDocument;
const checksum = computeDerivationChecksumForGPT("iidx:DP", updatedChart);
const updatedChart = { ...chart, data: mergedData } as ChartDocument;
const checksum = ComputeChartStabilityChecksum("iidx-dp", updatedChart);
await DB.updateTable("chart")
.set({
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
export const ACTION_UpdateFervidexSettings = MakeAction(
"UPDATE_FERVIDEX_SETTINGS",
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
export const ACTION_UpdateKshookSv6cSettings = MakeAction(
"UPDATE_KSHOOK_SV6C_SETTINGS",
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
export const ACTION_UpdateMytCardInfo = MakeAction(
"UPDATE_MYT_CARD_INFO",
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
export const ACTION_UpdateSession = MakeAction("UPDATE_SESSION", async (taker, input) => {
@@ -1,12 +1,12 @@
import type { Difficulties, integer, MONGO_ChartDocument } from "tachi-common";
import type { ChartDocument, Difficulties, integer } from "tachi-common";
/* eslint-disable no-await-in-loop */
import { computeDerivationChecksumForGPT } from "#game-implementations/utils/derivation-checksum";
import { ComputeChartStabilityChecksum } from "#game-implementations/utils/derivation-checksum";
import { MakeAction } from "#lib/actions/actions";
import { log } from "#lib/log/log";
import DB from "#services/pg/db";
import fetch from "#utils/fetch";
import { FindChartWithPTDF } from "#utils/queries/charts";
import { FindChartWithSongDifficulty } from "#utils/queries/charts";
import { FindSongOnTitle } from "#utils/queries/songs";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
@@ -57,7 +57,7 @@ export async function updateSp12DataCore() {
const updatedChartIDs: Array<string> = [];
for (const sh of rj.sheets) {
let chart: MONGO_ChartDocument<"iidx:SP">;
let chart: ChartDocument<"iidx-sp">;
try {
chart = await HumanisedTitleLookup(sh.title);
@@ -179,8 +179,8 @@ export async function updateSp12DataCore() {
},
};
const updatedChart = { ...chart, data: chartData } as MONGO_ChartDocument;
const checksum = computeDerivationChecksumForGPT("iidx:SP", updatedChart);
const updatedChart = { ...chart, data: chartData } as ChartDocument;
const checksum = ComputeChartStabilityChecksum("iidx-sp", updatedChart);
await DB.updateTable("chart")
.set({
@@ -205,7 +205,7 @@ export async function updateSp12DataCore() {
}
async function HumanisedTitleLookup(originalTitle: string) {
let difficulty: Difficulties["iidx:SP"] = "ANOTHER";
let difficulty: Difficulties["iidx-sp"] = "ANOTHER";
let title: string | undefined = originalTitle;
@@ -234,7 +234,7 @@ async function HumanisedTitleLookup(originalTitle: string) {
);
}
const chart = await FindChartWithPTDF("iidx", song.id, "SP", difficulty);
const chart = await FindChartWithSongDifficulty("iidx-sp", song.id, difficulty);
if (!chart) {
throw new Error(
@@ -242,7 +242,7 @@ async function HumanisedTitleLookup(originalTitle: string) {
);
}
return chart as MONGO_ChartDocument<"iidx:SP">;
return chart as ChartDocument<"iidx-sp">;
}
export const ACTION_UpdateSp12Data = MakeAction("UPDATE_SP12_DATA", async (taker, _input) => {
@@ -1,27 +1,23 @@
import { MakeAction } from "#lib/actions/actions.js";
import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings.js";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { MakeAction } from "#lib/actions/actions";
import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings";
import DB from "#services/pg/db";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
import { type GameGroup, GamePTToV3, type Playtype, type ShowcaseStatDetails } from "tachi-common";
import { type ShowcaseStatDetails } from "tachi-common";
export const ACTION_UpdateUgptShowcase = MakeAction(
"UPDATE_UGPT_SHOWCASE",
async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, stats } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
const { userID, game, stats } = input;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's showcase.");
}
const v3Game = GamePTToV3(game, playtype);
const settingsRow = await DB.selectFrom("game_settings")
.select("user_id")
.where("user_id", "=", userID)
.where("game", "=", v3Game)
.where("game", "=", game)
.executeTakeFirst();
if (!settingsRow) {
@@ -33,7 +29,7 @@ export const ACTION_UpdateUgptShowcase = MakeAction(
await DB.insertInto("game_settings_showcase")
.values({
user_id: userID,
game: v3Game,
game,
data: JSON.stringify(payload),
})
.onConflict((oc) =>
@@ -41,7 +37,7 @@ export const ACTION_UpdateUgptShowcase = MakeAction(
)
.execute();
const newSettings = await GetUGPTSettingsDocument(userID, game, playtype);
const newSettings = await GetUGPTSettingsDocument(userID, game);
if (!newSettings) {
throw new ExpectedErr(500, "Failed to load settings after updating showcase.");
@@ -1,5 +1,5 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
export const ACTION_UpdateUserSettings = MakeAction(
+3 -3
View File
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "#utils/misc.js";
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "#utils/misc";
import { ExpectedErr } from "bliss";
export const ACTION_UpdateUser = MakeAction("UPDATE_USER", async (taker, body) => {
@@ -1,6 +1,6 @@
import { MakeAction } from "#lib/actions/actions.js";
import { upsertKaiAuthTokensInDb } from "#lib/kai-auth-token/persist.js";
import DB from "#services/pg/db.js";
import { MakeAction } from "#lib/actions/actions";
import { upsertKaiAuthTokensInDb } from "#lib/kai-auth-token/persist";
import DB from "#services/pg/db";
export const ACTION_UpsertKaiAuthToken = MakeAction(
"UPSERT_KAI_AUTH_TOKEN",
@@ -1,7 +1,6 @@
import { MakeAnonAction } from "#lib/actions/actions";
import { SendEmail } from "#lib/email/client";
import { EmailFormatResetPassword } from "#lib/email/formats";
import { Env, ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { GetUserWithIDGuaranteed } from "#utils/user";
@@ -10,10 +9,6 @@ import { ExpectedErr, log } from "bliss";
export const ANON_ACTION_ForgotPassword = MakeAnonAction(
"FORGOT_PASSWORD",
async (taker, { email }) => {
if (!ServerConfig.EMAIL_CONFIG && Env.NODE_ENV !== "test") {
throw new ExpectedErr(501, "This server does not support password resets.");
}
if (taker.ip === null) {
throw new ExpectedErr(400, "IP address is required to send a password reset email.");
}
+15 -20
View File
@@ -7,7 +7,7 @@ import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user";
import { ExpectedErr, log } from "bliss";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export const ANON_ACTION_Register = MakeAnonAction(
"REGISTER",
@@ -43,7 +43,7 @@ export const ANON_ACTION_Register = MakeAnonAction(
throw new ExpectedErr(409, `This email is already in use.`);
}
const newUser = await DB.transaction().execute(async (txn): Promise<MONGO_UserDocument> => {
const newUser = await DB.transaction().execute(async (txn): Promise<UserDocument> => {
// if we get to this point, We're good to create the user.
const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email);
@@ -65,7 +65,7 @@ export const ANON_ACTION_Register = MakeAnonAction(
if (!inviteCodeDoc) {
log.info(`Invalid invite code given: ${inviteCode}.`);
throw new Error(`Invalid invite code given: ${inviteCode}.`);
throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`);
}
log.info(`Consumed invite ${inviteCodeDoc.code}.`);
@@ -81,26 +81,21 @@ export const ANON_ACTION_Register = MakeAnonAction(
.execute();
}
// If we have an EMAIL_CONFIG set, send out
// authentication emails.
// Otherwise, don't bother; this is equivalent to
// automatically verifying all users' emails.
if (ServerConfig.EMAIL_CONFIG) {
const resetEmailCode = Random20Hex();
const resetEmailCode = Random20Hex();
await DB.insertInto("priv_verify_email_token")
.values({
token: resetEmailCode,
user_id: newUser.id,
email,
})
.execute();
await txn
.insertInto("priv_verify_email_token")
.values({
token: resetEmailCode,
user_id: newUser.id,
email,
})
.execute();
// TODO: Put this on job queue
const { text, html } = EmailFormatVerifyEmail(newUser.username, resetEmailCode);
// TODO: Put this on job queue
const { text, html } = EmailFormatVerifyEmail(newUser.username, resetEmailCode);
void SendEmail(email, "Email Verification", html, text);
}
void SendEmail(email, "Email Verification", html, text);
return newUser;
});
@@ -1,4 +1,4 @@
import type { GPTImplementations } from "./types";
import type { GameImplementations } from "./types";
import { ARCAEA_IMPL } from "./games/arcaea";
import { BMS_7K_IMPL, BMS_14K_IMPL, PMS_CONTROLLER_IMPL, PMS_KEYBOARD_IMPL } from "./games/bms-pms";
@@ -12,7 +12,7 @@ import { MAIMAI_IMPL } from "./games/maimai";
import { MAIMAIDX_IMPL } from "./games/maimaidx";
import { MUSECA_IMPL } from "./games/museca";
import { ONGEKI_IMPL } from "./games/ongeki";
import { POPN_9B_IMPL } from "./games/popn";
import { POPN_IMPL } from "./games/popn";
import { SDVX_IMPL } from "./games/sdvx";
import { USC_CONTROLLER_IMPL, USC_KEYBOARD_IMPL } from "./games/usc";
import { WACCA_IMPL } from "./games/wacca";
@@ -25,28 +25,28 @@ import { WACCA_IMPL } from "./games/wacca";
*
* Basically, anything that can't be done in the common config, specific to the server.
*/
export const GPT_SERVER_IMPLEMENTATIONS: GPTImplementations = {
"bms:14K": BMS_14K_IMPL,
"bms:7K": BMS_7K_IMPL,
"pms:Controller": PMS_CONTROLLER_IMPL,
"pms:Keyboard": PMS_KEYBOARD_IMPL,
"iidx:SP": IIDX_SP_IMPL,
"iidx:DP": IIDX_DP_IMPL,
"wacca:Single": WACCA_IMPL,
"chunithm:Single": CHUNITHM_IMPL,
"gitadora:Dora": GITADORA_DORA_IMPL,
"gitadora:Gita": GITADORA_GITA_IMPL,
"itg:Stamina": ITG_STAMINA_IMPL,
"jubeat:Single": JUBEAT_IMPL,
"maimai:Single": MAIMAI_IMPL,
"maimaidx:Single": MAIMAIDX_IMPL,
"museca:Single": MUSECA_IMPL,
"popn:9B": POPN_9B_IMPL,
"usc:Controller": USC_CONTROLLER_IMPL,
"usc:Keyboard": USC_KEYBOARD_IMPL,
"sdvx:Single": SDVX_IMPL,
"arcaea:Touch": ARCAEA_IMPL,
"ongeki:Single": ONGEKI_IMPL,
"ddr:SP": DDR_IMPL,
"ddr:DP": DDR_IMPL,
export const GAME_IMPLEMENTATIONS: GameImplementations = {
"bms-14k": BMS_14K_IMPL,
"bms-7k": BMS_7K_IMPL,
"pms-controller": PMS_CONTROLLER_IMPL,
"pms-keyboard": PMS_KEYBOARD_IMPL,
"iidx-sp": IIDX_SP_IMPL,
"iidx-dp": IIDX_DP_IMPL,
wacca: WACCA_IMPL,
chunithm: CHUNITHM_IMPL,
"gitadora-dora": GITADORA_DORA_IMPL,
"gitadora-gita": GITADORA_GITA_IMPL,
"itg-stamina": ITG_STAMINA_IMPL,
jubeat: JUBEAT_IMPL,
maimai: MAIMAI_IMPL,
maimaidx: MAIMAIDX_IMPL,
museca: MUSECA_IMPL,
popn: POPN_IMPL,
"usc-controller": USC_CONTROLLER_IMPL,
"usc-keyboard": USC_KEYBOARD_IMPL,
sdvx: SDVX_IMPL,
arcaea: ARCAEA_IMPL,
ongeki: ONGEKI_IMPL,
"ddr-sp": DDR_IMPL,
"ddr-dp": DDR_IMPL,
};
@@ -19,22 +19,29 @@ import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish, NumToDP } from "#utils/misc";
import { Volforce } from "rg-stats";
import {
type ChartDocument,
FmtNum,
FmtNumCompact,
GetGrade,
GetGradeDeltas,
type GPTString,
type GPTStrings,
type GradeBoundary,
IIDXLIKE_GBOUNDARIES,
IIDXLikeGetGrade,
type integer,
type MONGO_ChartDocument,
type MONGO_ScoreDocument,
type MONGO_SpecificUserGameStats,
type ScoreDocument,
SDVXLIKE_GBOUNDARIES,
type SpecificUserGameStats,
type V3Game,
} from "tachi-common";
/** BMS/PMS v3 games (e.g. `bms:7K` → `bms-7k`). */
type BmsPmsGames = "bms-7k" | "bms-14k" | "pms-controller" | "pms-keyboard";
/** IIDX/BMS/PMS shared chart logic (e.g. `iidx:SP` → `iidx-sp`). */
type IIDXLikes = "iidx-dp" | "iidx-sp" | BmsPmsGames;
/** SDVX and USC v3 games (`sdvx:Single` → `sdvx`; `usc:Controller` → `usc-controller`). */
type SDVXLikes = "sdvx" | "usc-controller" | "usc-keyboard";
export const EX_SCORE_CHECK: ChartSpecificMetricValidator<IIDXLikes> = (exScore, chart) => {
if (exScore < 0) {
return `EX Score cannot be negative.`;
@@ -51,8 +58,6 @@ function calculateIIDXLikePercent(exScore: integer, notecount: integer) {
return (100 * exScore) / (notecount * 2);
}
type IIDXLikes = GPTStrings["bms" | "iidx" | "pms"];
export const IIDXLIKE_SCORE_DERIVER: GPTScoreDeriver<IIDXLikes> = (scoreData, chart) => ({
percent: calculateIIDXLikePercent(scoreData.score, chart.data.notecount),
grade: IIDXLikeGetGrade(scoreData.score, chart.data.notecount),
@@ -76,8 +81,6 @@ export const IIDXLIKE_SCORE_VALIDATORS: Array<ScoreValidator<IIDXLikes>> = [
},
];
type SDVXLikes = GPTStrings["sdvx" | "usc"];
export const SDVXLIKE_SCORE_DERIVER: GPTScoreDeriver<SDVXLikes> = (scoreData, _chart) => ({
grade: GetGrade(SDVXLIKE_GBOUNDARIES, scoreData.score),
});
@@ -91,9 +94,7 @@ export const IIDXLIKE_PB_RANKING_VALUES: PBRankingValuesFunction<IIDXLikes> = (p
tb5: null,
});
export function VF6ToClass(
vf: number,
): MONGO_SpecificUserGameStats<"sdvx:Single">["classes"]["vfClass"] {
export function VF6ToClass(vf: number): SpecificUserGameStats<"sdvx">["classes"]["vfClass"] {
// jesus christ man
if (vf >= 23) {
return "IMPERIAL_IV";
@@ -188,12 +189,8 @@ export const SDVXLIKE_SESSION_CALCS: GPTSessionCalcs<SDVXLikes> = (arr) => {
return { ProfileVF6: v !== null ? v * 50 : null };
};
export const SDVXLIKE_PROFILE_CALCS: GPTProfileCalcs<SDVXLikes> = async (
game,
playtype,
userID,
) => ({
VF6: await ProfileSumBestN("VF6", 50)(game, playtype, userID),
export const SDVXLIKE_PROFILE_CALCS: GPTProfileCalcs<SDVXLikes> = async (game, userID) => ({
VF6: await ProfileSumBestN("VF6", 50)(game, userID),
});
export const SDVXLIKE_CLASS_DERIVERS: GPTClassDerivers<SDVXLikes> = (ratings) => ({
@@ -221,9 +218,14 @@ export const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters<SDVXLikes> = {
};
export const SDVXLIKE_PB_MERGERS: Array<PBMergeFunction<SDVXLikes>> = [
CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor<SDVXLikes>(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
];
export const SDVXLIKE_DEFAULT_MERGE_NAME = "Best Score";
@@ -250,23 +252,15 @@ export const SDVXLIKE_SCORE_VALIDATORS: Array<ScoreValidator<SDVXLikes>> = [
},
];
export const SGL_SESSION_CALCS: GPTSessionCalcs<GPTStrings["bms" | "pms"]> = (arr) => ({
export const SGL_SESSION_CALCS: GPTSessionCalcs<BmsPmsGames> = (arr) => ({
sieglinde: SessionAvgBest10For("sieglinde")(arr),
});
export const SGL_PROFILE_CALCS: GPTProfileCalcs<GPTStrings["bms" | "pms"]> = async (
game,
playtype,
userID,
) => ({
sieglinde: await ProfileAvgBestN("sieglinde", 20)(game, playtype, userID),
export const SGL_PROFILE_CALCS: GPTProfileCalcs<BmsPmsGames> = async (game, userID) => ({
sieglinde: await ProfileAvgBestN("sieglinde", 20)(game, userID),
});
export const SGL_SCORE_CALCS: GPTScoreCalcs<GPTStrings["bms" | "pms"]> = (
scoreData,
_derivedData,
chart,
) => {
export const SGL_SCORE_CALCS: GPTScoreCalcs<BmsPmsGames> = (scoreData, _derivedData, chart) => {
const ecValue = chart.data.sglEC ?? 0;
const hcValue = chart.data.sglHC ?? 0;
@@ -341,10 +335,10 @@ export function GradeGoalFormatter<G extends string>(
*
* @returns undefined on success, an array of error messages (strings) on failure.
*/
export function RunValidators<GPT extends GPTString>(
validators: Array<ScoreValidator<GPT>>,
score: MONGO_ScoreDocument<GPT>,
chart: MONGO_ChartDocument<GPT>,
export function RunValidators<TGame extends V3Game>(
validators: Array<ScoreValidator<TGame>>,
score: ScoreDocument<TGame>,
chart: ChartDocument<TGame>,
) {
const errs = [];
@@ -1,257 +0,0 @@
import type { DeepPartial } from "#utils/types";
import { log } from "#lib/log/log.js";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import MONGODB_KILL from "#services/mongo/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import ResetDBState from "#test-utils/resets";
import { TestSnapshot } from "#test-utils/single-process-snapshot";
import { TestingArcaeaSheriruthFTR, TestingWaccaPupaExp } from "#test-utils/test-data";
import {
ARCAEA_GRADES,
ARCAEA_LAMPS,
type ChartDocument,
type ProvidedMetrics,
type ScoreData,
type ScoreDocument,
} from "tachi-common";
import t from "tap";
import { RunValidators } from "./_common";
import { ARCAEA_IMPL } from "./arcaea";
const baseMetrics: ProvidedMetrics["arcaea:Touch"] = {
lamp: "CLEAR",
score: 9_979_366,
};
const scoreData: ScoreData<"arcaea:Touch"> = {
lamp: "CLEAR",
score: 9_979_366,
grade: "EX+",
judgements: {
pure: 1148,
far: 1,
lost: 2,
},
optional: { enumIndexes: {} },
enumIndexes: {
grade: ARCAEA_GRADES.EX_PLUS,
lamp: ARCAEA_LAMPS.CLEAR,
},
};
const mockScore = mkMockScore("arcaea", "Touch", TestingArcaeaSheriruthFTR, scoreData);
const mockPB = mkMockPB("arcaea", "Touch", TestingArcaeaSheriruthFTR, scoreData);
t.test("Arcaea Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: string) =>
t.equal(
ARCAEA_IMPL.scoreDeriver(
dmf(baseMetrics, { score }) as any,
TestingArcaeaSheriruthFTR,
).grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
f(0, "D");
f(8_600_000, "C");
f(8_900_000, "B");
f(9_200_000, "A");
f(9_500_000, "AA");
f(9_800_000, "EX");
f(9_900_000, "EX+");
t.end();
});
t.test("Rating Calc", (t) => {
t.equal(
ARCAEA_IMPL.scoreCalcs(
scoreData,
ARCAEA_IMPL.scoreDeriver(scoreData, TestingArcaeaSheriruthFTR),
TestingArcaeaSheriruthFTR,
).potential,
11.99,
"Basic potential check",
);
t.end();
});
t.todo("Session Calcs");
t.todo("Profile Calcs");
t.test("Colour Deriver", (t) => {
const f = (v: number | null, expected: any) =>
t.equal(
ARCAEA_IMPL.classDerivers({ naivePotential: v }).badge,
expected,
`A naivePotential of ${v} should result in ${expected}.`,
);
f(null, null);
f(0, "BLUE");
f(3.5, "GREEN");
f(7, "ASH_PURPLE");
f(10, "PURPLE");
f(11, "RED");
f(12, "ONE_STAR");
f(12.5, "TWO_STARS");
f(13, "THREE_STARS");
t.end();
});
t.test("Goal Formatters", (t) => {
t.test("Criteria", (t) => {
t.equal(
ARCAEA_IMPL.goalCriteriaFormatters.score(10_002_221),
"Get a score of 10,002,221 on",
);
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof ARCAEA_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"arcaea:Touch">>,
goalValue: any,
expected: any,
) =>
t.equal(
ARCAEA_IMPL.goalProgressFormatters[k](
dmf(mockPB, {
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("grade", { grade: "EX", score: 9_897_342 }, ARCAEA_GRADES.EX_PLUS, "(EX+)-2.7K");
f("score", { score: 9_982_123 }, 10_000_000, "9,982,123");
f("lamp", { lamp: "CLEAR" }, ARCAEA_LAMPS.CLEAR, "CLEAR");
t.end();
});
t.test("Out Of", (t) => {
t.equal(ARCAEA_IMPL.goalOutOfFormatters.score(10_001_003), "10,001,003");
t.equal(ARCAEA_IMPL.goalOutOfFormatters.score(9_983_132), "9,983,132");
t.end();
});
t.end();
});
t.test("PB Merging", (t) => {
t.beforeEach(ResetDBState);
t.test("Should join best lamp", async (t) => {
await MONGODB_KILL.scores.insert(mockScore);
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestLamp",
scoreData: {
score: 0,
lamp: "FULL RECALL",
enumIndexes: { lamp: ARCAEA_LAMPS.FULL_RECALL },
},
}),
);
t.hasStrict(await CreatePBDoc("arcaea:Touch", 1, TestingArcaeaSheriruthFTR, log), {
composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }],
scoreData: {
score: mockScore.scoreData.score,
lamp: "FULL RECALL",
enumIndexes: { lamp: ARCAEA_LAMPS.FULL_RECALL },
},
});
t.end();
});
t.end();
});
t.test("Score Validations", (t) => {
const f = (s: DeepPartial<ScoreDocument<"arcaea:Touch">>) =>
RunValidators(
ARCAEA_IMPL.scoreValidators,
dmf(mockScore, s),
TestingArcaeaSheriruthFTR,
);
t.strictSame(
f({
scoreData: {
lamp: "PURE MEMORY",
score: 10_001_151,
judgements: {
pure: 1151,
far: 0,
lost: 0,
},
},
}),
undefined,
);
t.strictSame(f({ scoreData: { lamp: "FULL RECALL", judgements: { lost: 0 } } }), undefined);
t.strictSame(
ARCAEA_IMPL.chartSpecificValidators.score(
mockScore.scoreData.score,
TestingArcaeaSheriruthFTR,
),
true,
);
TestSnapshot(
t,
f({ scoreData: { lamp: "PURE MEMORY", score: 9_999_999 } }),
"Arcaea IMPL Validators: Should disallow PURE MEMORY with non-perfect score",
);
TestSnapshot(
t,
f({ scoreData: { lamp: "FULL RECALL", score: 4_999_999 } }),
"Arcaea IMPL Validators: Should disallow FULL RECALL scores smaller than the minimum possible score",
);
TestSnapshot(
t,
f({ scoreData: { lamp: "FULL RECALL", judgements: { lost: 1 } } }),
`Arcaea IMPL Validators: Should disallow FR with lost`,
);
TestSnapshot(
t,
f({ scoreData: { lamp: "PURE MEMORY", judgements: { far: 1 } } }),
"Arcaea IMPL Validators: Should disallow PURE MEMORY with non-perfect Far judgements.",
);
TestSnapshot(
t,
f({ scoreData: { lamp: "PURE MEMORY", judgements: { lost: 1 } } }),
"Arcaea IMPL Validators: Should disallow PURE MEMORY with non-perfect Lost judgements.",
);
TestSnapshot(
t,
ARCAEA_IMPL.chartSpecificValidators.score(-1, TestingArcaeaSheriruthFTR),
"Arcaea IMPL Validators: Should disallow negative scores.",
);
TestSnapshot(
t,
ARCAEA_IMPL.chartSpecificValidators.score(10_001_152, TestingArcaeaSheriruthFTR),
"Arcaea IMPL Validators: Should disallow scores that exceed 10 million + notecount.",
);
t.end();
});
t.end();
});
@@ -0,0 +1,325 @@
import type { DeepPartial } from "#utils/types";
import { RunValidators } from "#game-implementations/games/_common";
import { ARCAEA_IMPL } from "#game-implementations/games/arcaea";
import { log } from "#lib/log/log";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingArcaeaSheriruthFTR, TestingArcaeaSheriruthSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
ARCAEA_GRADES,
ARCAEA_LAMPS,
type MongoProvidedMetrics,
type ScoreData,
type ScoreDocument,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
const chart = TestingArcaeaSheriruthFTR;
const baseMetrics: MongoProvidedMetrics["arcaea"] = {
lamp: "CLEAR",
score: 9_979_366,
};
const scoreData: ScoreData<"arcaea"> = {
lamp: "CLEAR",
score: 9_979_366,
grade: "EX+",
judgements: {
pure: 1148,
far: 1,
lost: 2,
},
optional: { enumIndexes: {} },
enumIndexes: {
grade: ARCAEA_GRADES.EX_PLUS,
lamp: ARCAEA_LAMPS.CLEAR,
},
};
async function seedSheriruthChart() {
await DB.insertInto("song")
.values({
id: TestingArcaeaSheriruthSong.id,
legacy_id: 19,
game_group: "arcaea",
title: TestingArcaeaSheriruthSong.title,
artist: TestingArcaeaSheriruthSong.artist,
search_terms: TestingArcaeaSheriruthSong.searchTerms,
alt_titles: TestingArcaeaSheriruthSong.altTitles,
data: TestingArcaeaSheriruthSong.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chart.chartID,
legacy_id: chart.chartID,
game: "arcaea",
song_id: TestingArcaeaSheriruthSong.id,
difficulty: chart.difficulty,
level: chart.level,
level_num: chart.levelNum,
is_primary: chart.isPrimary,
versions: chart.versions,
data: chart.data,
})
.execute();
}
async function insertArcaeaScoreRow(opts: {
scoreData: ScoreData<"arcaea">;
scoreId: string;
timeAchievedMs: number;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg("arcaea", opts.scoreData);
const t = UnixMillisecondsToISO8601(opts.timeAchievedMs);
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: chart.chartID,
game: "arcaea",
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: t,
time_added: t,
highlight: false,
comment: null,
})
.execute();
}
describe("ARCAEA_IMPL", () => {
describe("scoreDeriver (grade)", () => {
const g = (score: number, expected: string) =>
expect(
ARCAEA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never)
.grade,
).toBe(expected);
it("maps score thresholds to letter grades", () => {
g(0, "D");
g(8_600_000, "C");
g(8_900_000, "B");
g(9_200_000, "A");
g(9_500_000, "AA");
g(9_800_000, "EX");
g(9_900_000, "EX+");
});
});
it("scoreCalcs potential", () => {
expect(
ARCAEA_IMPL.scoreCalcs(scoreData, ARCAEA_IMPL.scoreDeriver(scoreData, chart), chart)
.potential,
).toBe(11.99);
});
describe("classDerivers (naivePotential → badge)", () => {
const badge = (
v: number | null,
expected: ReturnType<typeof ARCAEA_IMPL.classDerivers>["badge"],
) => expect(ARCAEA_IMPL.classDerivers({ naivePotential: v }).badge).toBe(expected);
it("maps potential tiers to badge colours", () => {
badge(null, null);
badge(0, "BLUE");
badge(3.5, "GREEN");
badge(7, "ASH_PURPLE");
badge(10, "PURPLE");
badge(11, "RED");
badge(12, "ONE_STAR");
badge(12.5, "TWO_STARS");
badge(13, "THREE_STARS");
});
});
describe("goal formatters", () => {
const mockPB = mkMockPB("arcaea", chart, scoreData);
it("formats score criteria", () => {
expect(ARCAEA_IMPL.goalCriteriaFormatters.score(10_002_221)).toBe(
"Get a score of 10,002,221 on",
);
});
it("formats progress for grade, score, and lamp", () => {
const f = (
k: keyof typeof ARCAEA_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"arcaea">>,
goalValue: number,
expected: string,
) =>
expect(
ARCAEA_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
f("grade", { grade: "EX", score: 9_897_342 }, ARCAEA_GRADES.EX_PLUS, "(EX+)-2.7K");
f("score", { score: 9_982_123 }, 10_000_000, "9,982,123");
f("lamp", { lamp: "CLEAR" }, ARCAEA_LAMPS.CLEAR, "CLEAR");
});
it("formats out-of score", () => {
expect(ARCAEA_IMPL.goalOutOfFormatters.score(10_001_003)).toBe("10,001,003");
expect(ARCAEA_IMPL.goalOutOfFormatters.score(9_983_132)).toBe("9,983,132");
});
});
describe("PB merging (CreatePBDoc)", () => {
beforeEach(seedSheriruthChart);
it("joins best lamp into the default-metric PB", async () => {
const { id: userId } = await seedUser({ username: "arcaea_pb_lamp" });
const main = mkMockScore("arcaea", chart, scoreData);
const lampScoreData: ScoreData<"arcaea"> = {
...scoreData,
score: 0,
lamp: "FULL RECALL",
enumIndexes: {
...scoreData.enumIndexes,
lamp: ARCAEA_LAMPS.FULL_RECALL,
},
};
await insertArcaeaScoreRow({
userId,
scoreId: main.scoreID,
scoreData,
timeAchievedMs: 1_000,
});
await insertArcaeaScoreRow({
userId,
scoreId: "bestLamp",
scoreData: lampScoreData,
timeAchievedMs: 2_000,
});
const pb = await CreatePBDoc("arcaea", userId, chart, log);
expect(pb).toMatchObject({
composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }],
scoreData: {
score: scoreData.score,
lamp: "FULL RECALL",
enumIndexes: { lamp: ARCAEA_LAMPS.FULL_RECALL },
},
});
});
});
describe("scoreValidators & chartSpecificValidators", () => {
const mockScore = mkMockScore("arcaea", chart, scoreData);
const runVal = (s: DeepPartial<ScoreDocument<"arcaea">>) =>
RunValidators(ARCAEA_IMPL.scoreValidators, dmf(mockScore, s) as never, chart as never);
it("accepts valid PM, FR, and chart score bounds", () => {
expect(
runVal({
scoreData: {
lamp: "PURE MEMORY",
score: 10_001_151,
judgements: {
pure: 1151,
far: 0,
lost: 0,
},
},
}),
).toBeUndefined();
expect(
runVal({ scoreData: { lamp: "FULL RECALL", judgements: { lost: 0 } } }),
).toBeUndefined();
expect(
ARCAEA_IMPL.chartSpecificValidators.score(mockScore.scoreData.score, chart),
).toBe(true);
});
it("rejects inconsistent lamps and judgements", () => {
expect(
runVal({
scoreData: {
lamp: "PURE MEMORY",
score: 9_999_999,
judgements: { pure: 1151, far: 0, lost: 0 },
},
}),
).toEqual([
"PURE MEMORY scores must have a score larger than 10 million. Got 9999999 instead.",
]);
expect(
runVal({
scoreData: {
lamp: "FULL RECALL",
score: 4_999_999,
judgements: { pure: 500, far: 500, lost: 0 },
},
}),
).toEqual([
"FULL RECALL scores must have a score larger than 5 million. Got 4999999 instead.",
]);
expect(
runVal({
scoreData: {
lamp: "FULL RECALL",
score: 8_000_000,
judgements: { pure: 800, far: 0, lost: 1 },
},
}),
).toEqual(["Cannot have a FULL RECALL with non-zero lost count."]);
expect(
runVal({
scoreData: {
lamp: "PURE MEMORY",
score: 10_001_151,
judgements: { pure: 1150, far: 1, lost: 0 },
},
}),
).toEqual(["Cannot have a PURE MEMORY with any fars or losts."]);
expect(
runVal({
scoreData: {
lamp: "PURE MEMORY",
score: 10_001_151,
judgements: { pure: 1150, far: 0, lost: 1 },
},
}),
).toEqual(["Cannot have a PURE MEMORY with any fars or losts."]);
});
it("rejects out-of-range chart scores", () => {
expect(ARCAEA_IMPL.chartSpecificValidators.score(-1, chart)).toBe(
"Score must be non-negative. Got -1",
);
expect(ARCAEA_IMPL.chartSpecificValidators.score(10_001_152, chart)).toBe(
`Score cannot exceed ${10_000_000 + chart.data.notecount} for this chart.`,
);
});
});
});
@@ -1,15 +1,14 @@
import type { GPTServerImplementation } from "#game-implementations/types";
import { type GameImplementation } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish } from "#utils/misc";
import { Potential } from "rg-stats";
import { ARCAEA_GBOUNDARIES, FmtNum, GetGrade, type GPTStrings } from "tachi-common";
import { ARCAEA_GBOUNDARIES, FmtNum, GetGrade } from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
export const ARCAEA_IMPL: GPTServerImplementation<GPTStrings["arcaea"]> = {
export const ARCAEA_IMPL: GameImplementation<"arcaea"> = {
chartSpecificValidators: {
score: (score, chart) => {
if (score < 0) {
@@ -40,8 +39,8 @@ export const ARCAEA_IMPL: GPTServerImplementation<GPTStrings["arcaea"]> = {
sessionCalcs: (arr) => ({
naivePotential: SessionAvgBest10For("potential")(arr),
}),
profileCalcs: async (game, playtype, userID) => ({
naivePotential: await ProfileAvgBestN("potential", 30)(game, playtype, userID),
profileCalcs: async (game, userID) => ({
naivePotential: await ProfileAvgBestN("potential", 30)(game, userID),
}),
classDerivers: (ratings) => {
const potential = ratings.naivePotential;
@@ -96,7 +95,7 @@ export const ARCAEA_IMPL: GPTServerImplementation<GPTStrings["arcaea"]> = {
),
],
defaultMergeRefName: "Best Score",
derivationRelevantFields: ["levelNum"],
chartDataRelevantFields: ["levelNum"],
scoreValidators: [
(s) => {
if (s.scoreData.lamp === "PURE MEMORY" && s.scoreData.score < 10_000_000) {
@@ -0,0 +1,408 @@
import {
BMS_7K_IMPL,
BMS_14K_IMPL,
PMS_CONTROLLER_IMPL,
PMS_KEYBOARD_IMPL,
} from "#game-implementations/games/bms-pms";
import { log } from "#lib/log/log";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { BMSGazerChart, BMSGazerSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
type BMSGames,
type ChartDocument,
type ChartDocumentData,
IIDX_GRADES,
IIDX_LAMPS,
type MongoProvidedMetrics,
type ScoreData,
type SongDocument,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
const max = 2256 * 2;
function percentToScore(percent: number) {
return (percent / 100) * max;
}
function scoreToPercent(score: number) {
return (100 * score) / max;
}
const pmsGazerSong: SongDocument<"pms"> = {
id: BMSGazerSong.id,
title: BMSGazerSong.title,
artist: BMSGazerSong.artist,
searchTerms: BMSGazerSong.searchTerms,
altTitles: BMSGazerSong.altTitles,
data: {
genre: BMSGazerSong.data.genre,
subtitle: BMSGazerSong.data.subtitle,
subartist: BMSGazerSong.data.subartist,
tableString: BMSGazerSong.data.tableString,
},
};
function makeChart<G extends BMSGames>(game: G): ChartDocument<G> {
if (game === "bms-7k" || game === "bms-14k") {
return {
...BMSGazerChart,
game,
song: BMSGazerSong,
} as ChartDocument<G>;
}
const { aiLevel: _omit, ...pmsData } = BMSGazerChart.data;
return {
game,
chartID: BMSGazerChart.chartID,
data: pmsData as ChartDocumentData[G],
level: BMSGazerChart.level,
levelNum: BMSGazerChart.levelNum,
difficulty: "CHART",
isPrimary: true,
versions: [],
song: pmsGazerSong,
} as ChartDocument<G>;
}
describe.each([
["bms-7k", BMS_7K_IMPL] as const,
["bms-14k", BMS_14K_IMPL] as const,
["pms-controller", PMS_CONTROLLER_IMPL] as const,
["pms-keyboard", PMS_KEYBOARD_IMPL] as const,
])("%s", (game, impl) => {
type G = typeof game;
const chart = makeChart(game);
const baseMetrics: MongoProvidedMetrics[G] = {
lamp: "CLEAR",
score: Math.floor(percentToScore(79.123)),
};
const scoreData: ScoreData<G> = {
lamp: "CLEAR",
score: Math.floor(percentToScore(79.123)),
grade: "AA",
percent: 79.123,
enumIndexes: { grade: IIDX_GRADES.AA, lamp: IIDX_LAMPS.CLEAR },
judgements: {},
optional: { enumIndexes: {} },
};
const mockScore = mkMockScore(game, chart, scoreData);
const mockPB = mkMockPB(game, chart, scoreData);
describe("scoreDeriver", () => {
it("percent", () => {
const f = (modifant: Partial<typeof baseMetrics>, expected: number, _msg?: string) => {
expect(
impl.scoreDeriver(dmf(baseMetrics, modifant) as never, chart as never).percent,
).toBe(expected);
};
f({ score: 1000 }, (100 * 1000) / max);
f({ score: 0 }, 0);
f({ score: chart.data.notecount * 2 }, 100);
});
it("grade from percent", () => {
const f = (percent: number, expected: string) => {
expect(
impl.scoreDeriver(
dmf(baseMetrics, { score: percentToScore(percent) }) as never,
chart as never,
).grade,
).toBe(expected);
};
f(0, "F");
f(22.23, "E");
f(33.34, "D");
f(44.45, "C");
f(55.56, "B");
f(66.67, "A");
f(77.78, "AA");
f(88.89, "AAA");
f(94.45, "MAX-");
f(100, "MAX");
f(0, "F");
f(11.11, "F");
f(22.22, "F");
f(33.33, "E");
f(44.44, "D");
f(55.55, "C");
f(66.66, "B");
f(77.77, "A");
f(88.88, "AA");
f(94.44, "AAA");
f(99.99, "MAX-");
});
});
describe("chartSpecificValidators", () => {
it("accepts in-range EX score", () => {
expect(impl.chartSpecificValidators.score(1000, chart as never)).toBe(true);
expect(impl.chartSpecificValidators.score(0, chart as never)).toBe(true);
expect(
impl.chartSpecificValidators.score(chart.data.notecount * 2, chart as never),
).toBe(true);
});
it("rejects out-of-range EX score", () => {
expect(impl.chartSpecificValidators.score(-1, chart as never)).toBe(
"EX Score cannot be negative.",
);
expect(
impl.chartSpecificValidators.score(chart.data.notecount * 2 + 1, chart as never),
).toBe(`EX Score cannot be greater than ${chart.data.notecount * 2} for this chart.`);
});
});
describe("scoreCalcs sieglinde", () => {
const f = (
sd: Partial<ScoreData<G>>,
chartData: Partial<ChartDocumentData[G]>,
expected: number,
_msg?: string,
) => {
const mergedSd = dmf(mockScore.scoreData, sd);
const ch = dmf(chart, { data: chartData as never });
expect(
impl.scoreCalcs(mergedSd, impl.scoreDeriver(mergedSd, ch as never), ch as never)
.sieglinde,
).toBe(expected);
};
it("null / failed lamps", () => {
f({ lamp: "FAILED" }, {}, 0);
f({ lamp: "ASSIST CLEAR" }, {}, 0);
f({ lamp: "EASY CLEAR" }, {}, 0);
f({ lamp: "CLEAR" }, {}, 0);
f({ lamp: "HARD CLEAR" }, {}, 0);
f({ lamp: "EX HARD CLEAR" }, {}, 0);
f({ lamp: "FULL COMBO" }, {}, 0);
});
it("sieglinde tiers", () => {
f({ lamp: "FAILED" }, { sglEC: 10, sglHC: 11 }, 0);
f({ lamp: "ASSIST CLEAR" }, { sglEC: 10, sglHC: 11 }, 0);
f({ lamp: "EASY CLEAR" }, { sglEC: 10, sglHC: 11 }, 10);
f({ lamp: "CLEAR" }, { sglEC: 10, sglHC: 11 }, 10);
f({ lamp: "HARD CLEAR" }, { sglEC: 10, sglHC: 11 }, 11);
f({ lamp: "EX HARD CLEAR" }, { sglEC: 10, sglHC: 11 }, 11);
f({ lamp: "FULL COMBO" }, { sglEC: 10, sglHC: 11 }, 11);
f({ lamp: "HARD CLEAR" }, { sglEC: 10 }, 10);
f({ lamp: "EX HARD CLEAR" }, { sglEC: 10 }, 10);
f({ lamp: "FULL COMBO" }, { sglEC: 10 }, 10);
});
});
describe("goal formatters", () => {
it("criteria", () => {
expect(impl.goalCriteriaFormatters.percent(94.42123)).toBe("Get 94.42% on");
expect(impl.goalCriteriaFormatters.percent(94.426)).toBe("Get 94.43% on");
expect(impl.goalCriteriaFormatters.score(3570)).toBe("Get a score of 3570 on");
expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on");
});
it("progress", () => {
const f = (
k: keyof typeof impl.goalProgressFormatters,
modifant: Partial<ScoreData<G>>,
goalValue: unknown,
expected: string,
) => {
expect(
impl.goalProgressFormatters[k](
dmf(mockPB, {
scoreData: modifant,
}) as never,
goalValue as never,
),
).toBe(expected);
};
f("score", { score: 3570 }, 1000, "3570");
f("score", { score: 0 }, 1000, "0");
f("percent", { percent: 92.17472 }, 100, "92.17%");
f("percent", { percent: 92.17572 }, 100, "92.18%");
f(
"grade",
{ score: 3570, percent: scoreToPercent(3570), grade: "AA" },
IIDX_GRADES.AAA,
"AAA-441",
);
f(
"grade",
{ score: 3670, percent: scoreToPercent(3670), grade: "AA" },
IIDX_GRADES.AAA,
"AAA-341",
);
f(
"grade",
{ score: 4070, percent: scoreToPercent(4070), grade: "AAA" },
IIDX_GRADES.AAA,
"AAA+59",
);
f(
"lamp",
{ lamp: "HARD CLEAR", optional: { bp: 2 } as never },
IIDX_LAMPS.HARD_CLEAR,
"HARD CLEAR (BP: 2)",
);
f(
"lamp",
{ lamp: "HARD CLEAR", optional: { bp: null } as never },
IIDX_LAMPS.HARD_CLEAR,
"HARD CLEAR",
);
});
it("outOf", () => {
expect(impl.goalOutOfFormatters.percent(94.42123)).toBe("94.42%");
expect(impl.goalOutOfFormatters.score(3570)).toBe("3570");
});
});
describe("CreatePBDoc merges", () => {
async function seedSongAndChart() {
const gameGroup = game.startsWith("pms") ? "pms" : "bms";
await DB.insertInto("song")
.values({
id: chart.song.id,
legacy_id: 27_339,
game_group: gameGroup,
title: chart.song.title,
artist: chart.song.artist,
search_terms: chart.song.searchTerms,
alt_titles: chart.song.altTitles,
data: chart.song.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chart.chartID,
legacy_id: chart.chartID,
game,
song_id: chart.song.id,
difficulty: chart.difficulty,
level: chart.level,
level_num: chart.levelNum,
is_primary: chart.isPrimary,
versions: chart.versions,
data: chart.data,
})
.execute();
}
async function insertScoreRow(opts: {
scoreId: string;
sd: ScoreData<G>;
timeMs: number;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg(game, opts.sd);
const ts = UnixMillisecondsToISO8601(opts.timeMs);
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: chart.chartID,
game,
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: ts,
time_added: ts,
highlight: false,
comment: null,
})
.execute();
}
beforeEach(seedSongAndChart);
it("joins best lamp", async () => {
const { id: userId } = await seedUser({ username: `bms_pms_pb_lamp_${game}` });
const main = mkMockScore(game, chart, scoreData);
const lampScore: ScoreData<G> = {
...scoreData,
score: 0,
lamp: "FULL COMBO",
enumIndexes: {
...scoreData.enumIndexes,
lamp: IIDX_LAMPS.FULL_COMBO,
},
};
await insertScoreRow({ scoreId: main.scoreID, sd: scoreData, timeMs: 1000, userId });
await insertScoreRow({ scoreId: "bestLamp", sd: lampScore, timeMs: 2000, userId });
const pb = await CreatePBDoc(game, userId, chart as never, log);
expect(pb).toMatchObject({
composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }],
scoreData: {
score: scoreData.score,
lamp: "FULL COMBO",
enumIndexes: {
lamp: IIDX_LAMPS.FULL_COMBO,
},
},
});
});
it("joins lowest BP", async () => {
const { id: userId } = await seedUser({ username: `bms_pms_pb_bp_${game}` });
const main = mkMockScore(game, chart, scoreData);
await insertScoreRow({ scoreId: main.scoreID, sd: scoreData, timeMs: 1000, userId });
const hiBp: ScoreData<G> = {
...scoreData,
optional: { ...scoreData.optional, bp: 100 },
};
const loBp: ScoreData<G> = {
...scoreData,
optional: { ...scoreData.optional, bp: 1 },
};
await insertScoreRow({ scoreId: "whateverBP", sd: hiBp, timeMs: 2000, userId });
await insertScoreRow({ scoreId: "lowestBP", sd: loBp, timeMs: 3000, userId });
const pb = await CreatePBDoc(game, userId, chart as never, log);
expect(pb).toMatchObject({
composedFrom: [{ name: "Best Score" }, { name: "Lowest BP", scoreID: "lowestBP" }],
scoreData: {
score: scoreData.score,
optional: { bp: 1 },
},
});
});
});
});
@@ -1,355 +0,0 @@
import { log } from "#lib/log/log.js";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import MONGODB_KILL from "#services/mongo/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import ResetDBState from "#test-utils/resets";
import { TestSnapshot } from "#test-utils/single-process-snapshot";
import {
type ChartDocument,
type ChartDocumentData,
GetGPTString,
type GPTStrings,
IIDX_GRADES,
IIDX_LAMPS,
type ProvidedMetrics,
type ScoreData,
} from "tachi-common";
import t from "tap";
import { BMS_7K_IMPL, BMS_14K_IMPL, PMS_CONTROLLER_IMPL, PMS_KEYBOARD_IMPL } from "./bms-pms";
type BMSPMS = GPTStrings["bms" | "pms"];
const max = 2256 * 2;
const baseMetrics: ProvidedMetrics[BMSPMS] = {
lamp: "CLEAR",
score: Math.floor(percentToScore(79.123)),
};
const scoreData: ScoreData<BMSPMS> = {
lamp: "CLEAR",
score: Math.floor(percentToScore(79.123)),
grade: "AA",
percent: 79.123,
enumIndexes: { grade: IIDX_GRADES.AA, lamp: IIDX_LAMPS.CLEAR },
judgements: {},
optional: { enumIndexes: {} },
};
function percentToScore(percent: number) {
return (percent / 100) * max;
}
function scoreToPercent(score: number) {
return (100 * score) / max;
}
for (const [game, playtype, impl] of [
["bms", "7K", BMS_7K_IMPL],
["bms", "14K", BMS_14K_IMPL],
["pms", "Controller", PMS_CONTROLLER_IMPL],
["pms", "Keyboard", PMS_KEYBOARD_IMPL],
] as const) {
const chart: ChartDocument<BMSPMS> = {
songID: 27339,
chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b",
data: {
aiLevel: "0",
notecount: 2256,
hashMD5: "38616b85332037cc12924f2ae2840262",
hashSHA256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d",
tableFolders: [
{
level: "17",
table: "★",
},
],
sglEC: null,
sglHC: null,
},
level: "?",
levelNum: 0,
difficulty: "CHART",
playtype,
isPrimary: true,
versions: [],
};
const mockScore = mkMockScore(game, playtype, chart, scoreData);
const mockPB = mkMockPB(game, playtype, chart, scoreData);
const gptStr = GetGPTString(game, playtype);
t.test("Derivers", (t) => {
t.test("Percent", (t) => {
const f = (modifant: Partial<typeof baseMetrics>, expected: any, msg: string) =>
t.equal(
impl.scoreDeriver(dmf(baseMetrics, modifant) as any, chart as any).percent,
expected,
msg,
);
f(
{ score: 1000 },
(100 * 1000) / max,
"An EXScore of 1000 should result in a percent of 1000 / (notecount*2)",
);
f({ score: 0 }, 0, "An EXScore of 0 should count as 0%.");
f({ score: chart.data.notecount * 2 }, 100, "A perfect score should be worth 100%");
t.end();
});
t.test("Grade", (t) => {
const f = (percent: number, expected: any) =>
t.equal(
impl.scoreDeriver(
dmf(baseMetrics, { score: percentToScore(percent) }) as any,
chart as any,
).grade,
expected,
`A percent of ${percent}% should result in grade=${expected}.`,
);
f(0, "F");
f(22.23, "E");
f(33.34, "D");
f(44.45, "C");
f(55.56, "B");
f(66.67, "A");
f(77.78, "AA");
f(88.89, "AAA");
f(94.45, "MAX-");
f(100, "MAX");
// almosts
f(0, "F");
f(11.11, "F");
f(22.22, "F");
f(33.33, "E");
f(44.44, "D");
f(55.55, "C");
f(66.66, "B");
f(77.77, "A");
f(88.88, "AA");
f(94.44, "AAA");
f(99.99, "MAX-");
t.end();
});
t.end();
});
t.test("Validators", (t) => {
t.equal(impl.chartSpecificValidators.score(1000, chart as any), true);
t.equal(impl.chartSpecificValidators.score(0, chart as any), true);
t.equal(impl.chartSpecificValidators.score(chart.data.notecount * 2, chart as any), true);
TestSnapshot(
t,
impl.chartSpecificValidators.score(-1, chart as any),
`EX Score Validator ${playtype}: negative`,
);
TestSnapshot(
t,
impl.chartSpecificValidators.score(chart.data.notecount * 2 + 1, chart as any),
`EX Score Validator ${playtype}: excessive`,
);
t.end();
});
t.test("CalculatedData", (t) => {
t.test("Sieglinde", (t) => {
const f = (
scoreData: Partial<ScoreData<BMSPMS>>,
chartData: Partial<ChartDocumentData[BMSPMS]>,
expected: any,
msg: string,
) => {
const sd = dmf(mockScore.scoreData, scoreData);
const ch = dmf(chart, { data: chartData as any });
return t.equal(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).sieglinde,
expected,
msg,
);
};
t.test("null sieglinde", (t) => {
f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0");
f({ lamp: "ASSIST CLEAR" }, {}, 0, "ac should be worth 0");
f({ lamp: "EASY CLEAR" }, {}, 0, "ec should be worth 0");
f({ lamp: "CLEAR" }, {}, 0, "nc should be worth 0");
f({ lamp: "HARD CLEAR" }, {}, 0, "hc should be worth 0");
f({ lamp: "EX HARD CLEAR" }, {}, 0, "exhc should be worth 0");
f({ lamp: "FULL COMBO" }, {}, 0, "fc should be worth 0");
t.end();
});
f({ lamp: "FAILED" }, { sglEC: 10, sglHC: 11 }, 0, "fails should be worth 0");
f({ lamp: "ASSIST CLEAR" }, { sglEC: 10, sglHC: 11 }, 0, "ac should be worth 0");
f({ lamp: "EASY CLEAR" }, { sglEC: 10, sglHC: 11 }, 10, "ec should be worth sglEC");
f({ lamp: "CLEAR" }, { sglEC: 10, sglHC: 11 }, 10, "nc should be worth sglEC");
f({ lamp: "HARD CLEAR" }, { sglEC: 10, sglHC: 11 }, 11, "hc should be worth sglHC");
f(
{ lamp: "EX HARD CLEAR" },
{ sglEC: 10, sglHC: 11 },
11,
"exhc should be worth sglHC",
);
f({ lamp: "FULL COMBO" }, { sglEC: 10, sglHC: 11 }, 11, "fc should be worth sglHC");
f({ lamp: "HARD CLEAR" }, { sglEC: 10 }, 10, "hc should fallback to EC");
f({ lamp: "EX HARD CLEAR" }, { sglEC: 10 }, 10, "exhc should fallback to EC");
f({ lamp: "FULL COMBO" }, { sglEC: 10 }, 10, "fc should fallback to EC");
t.end();
});
t.end();
});
t.test("Goal Formatters", (t) => {
t.test("Criteria", (t) => {
t.equal(impl.goalCriteriaFormatters.percent(94.42123), "Get 94.42% on");
t.equal(impl.goalCriteriaFormatters.percent(94.426), "Get 94.43% on");
t.equal(impl.goalCriteriaFormatters.score(3570), "Get a score of 3570 on");
t.equal(impl.goalCriteriaFormatters.score(0), "Get a score of 0 on");
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof impl.goalProgressFormatters,
modifant: Partial<ScoreData<BMSPMS>>,
goalValue: any,
expected: any,
) =>
t.equal(
impl.goalProgressFormatters[k](
dmf(mockPB, {
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("score", { score: 3570 }, 1000, "3570");
f("score", { score: 0 }, 1000, "0");
f("percent", { percent: 92.17472 }, 100, "92.17%");
f("percent", { percent: 92.17572 }, 100, "92.18%");
f(
"grade",
{ score: 3570, percent: scoreToPercent(3570), grade: "AA" },
IIDX_GRADES.AAA,
"AAA-441",
);
f(
"grade",
{ score: 3670, percent: scoreToPercent(3670), grade: "AA" },
IIDX_GRADES.AAA,
"AAA-341",
);
f(
"grade",
{ score: 4070, percent: scoreToPercent(4070), grade: "AAA" },
IIDX_GRADES.AAA,
"AAA+59",
);
f(
"lamp",
{ lamp: "HARD CLEAR", optional: { bp: 2 } as any },
IIDX_LAMPS.HARD_CLEAR,
"HARD CLEAR (BP: 2)",
);
f(
"lamp",
{ lamp: "HARD CLEAR", optional: { bp: null } as any },
IIDX_LAMPS.HARD_CLEAR,
"HARD CLEAR",
);
t.end();
});
t.test("OutOf", (t) => {
t.equal(impl.goalOutOfFormatters.percent(94.42123), "94.42%");
t.equal(impl.goalOutOfFormatters.score(3570), "3570");
t.end();
});
t.end();
});
t.test("PB Mergers", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(() => MONGODB_KILL.scores.insert(mockScore));
t.test("Should join best lamp", async (t) => {
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestLamp",
scoreData: {
score: 0,
lamp: "FULL COMBO",
enumIndexes: { lamp: IIDX_LAMPS.FULL_COMBO },
},
}),
);
t.hasStrict(await CreatePBDoc(gptStr, 1, chart, log), {
composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }],
scoreData: {
score: 3570,
lamp: "FULL COMBO",
enumIndexes: {
lamp: IIDX_LAMPS.FULL_COMBO,
},
},
});
t.end();
});
t.test("Should join best BP", async (t) => {
await MONGODB_KILL.scores.insert([
dmf(mockScore, {
scoreID: "whateverBP",
scoreData: { optional: { bp: 100 } },
}),
dmf(mockScore, {
scoreID: "lowestBP",
scoreData: { optional: { bp: 1 } },
}),
]);
t.hasStrict(await CreatePBDoc(gptStr, 1, chart, log), {
composedFrom: [{ name: "Best Score" }, { name: "Lowest BP", scoreID: "lowestBP" }],
scoreData: {
score: 3570,
optional: { bp: 1 },
},
});
t.end();
});
t.end();
});
t.end();
}
@@ -1,7 +1,7 @@
import type { GPTServerImplementation, PBMergeFunction } from "#game-implementations/types";
import type { GameImplementation, PBMergeFunction } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { type GPTStrings, IIDXLIKE_GBOUNDARIES } from "tachi-common";
import { type BMSGames, IIDXLIKE_GBOUNDARIES } from "tachi-common";
import {
GoalFmtPercent,
@@ -16,7 +16,7 @@ import {
SGL_SESSION_CALCS,
} from "./_common";
const BMS_PMS_MERGERS: Array<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
const BMS_PMS_MERGERS: Array<PBMergeFunction<BMSGames>> = [
CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, lamp) => {
base.scoreData.lamp = lamp.scoreData.lamp;
@@ -36,7 +36,7 @@ const BMS_PMS_MERGERS: Array<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
// bms and pms currently have *identical*
// implementations. Nice.
const BMS_IMPL: GPTServerImplementation<GPTStrings["bms" | "pms"]> = {
const BMS_IMPL: GameImplementation<BMSGames> = {
scoreDeriver: IIDXLIKE_SCORE_DERIVER,
scoreCalcs: SGL_SCORE_CALCS,
sessionCalcs: SGL_SESSION_CALCS,
@@ -84,12 +84,12 @@ const BMS_IMPL: GPTServerImplementation<GPTStrings["bms" | "pms"]> = {
pbMergeFunctions: BMS_PMS_MERGERS,
defaultMergeRefName: "Best Score",
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
derivationRelevantFields: ["data.notecount", "data.sglEC", "data.sglHC"],
chartDataRelevantFields: ["data.notecount", "data.sglEC", "data.sglHC"],
};
export const BMS_14K_IMPL: GPTServerImplementation<"bms:14K"> = BMS_IMPL;
export const BMS_14K_IMPL: GameImplementation<"bms-14k"> = BMS_IMPL;
export const BMS_7K_IMPL: GPTServerImplementation<"bms:7K"> = BMS_IMPL;
export const BMS_7K_IMPL: GameImplementation<"bms-7k"> = BMS_IMPL;
export const PMS_CONTROLLER_IMPL: GPTServerImplementation<"pms:Controller"> = BMS_IMPL;
export const PMS_KEYBOARD_IMPL: GPTServerImplementation<"pms:Keyboard"> = BMS_IMPL;
export const PMS_CONTROLLER_IMPL: GameImplementation<"pms-controller"> = BMS_IMPL;
export const PMS_KEYBOARD_IMPL: GameImplementation<"pms-keyboard"> = BMS_IMPL;
@@ -1,246 +0,0 @@
import { log } from "#lib/log/log.js";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import MONGODB_KILL from "#services/mongo/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import ResetDBState from "#test-utils/resets";
import { CHUNITHMBBKKChart, TestingChunithmScorePB } from "#test-utils/test-data";
import {
CHUNITHM_CLEAR_LAMPS,
CHUNITHM_GRADES,
CHUNITHM_NOTE_LAMPS,
type ProvidedMetrics,
type ScoreData,
} from "tachi-common";
import t from "tap";
import { CHUNITHM_IMPL } from "./chunithm";
const baseMetrics: ProvidedMetrics["chunithm:Single"] = {
clearLamp: "CLEAR",
noteLamp: "NONE",
score: 1_003_000,
};
const scoreData: ScoreData<"chunithm:Single"> = {
clearLamp: "CLEAR",
noteLamp: "NONE",
score: 1_003_000,
grade: "SS",
judgements: {},
optional: { enumIndexes: {} },
enumIndexes: {
grade: CHUNITHM_GRADES.SS,
clearLamp: CHUNITHM_CLEAR_LAMPS.CLEAR,
noteLamp: CHUNITHM_NOTE_LAMPS.NONE,
},
};
const mockScore = mkMockScore("chunithm", "Single", CHUNITHMBBKKChart, scoreData);
const mockPB = mkMockPB("chunithm", "Single", CHUNITHMBBKKChart, scoreData);
t.test("CHUNITHM Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
CHUNITHM_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, CHUNITHMBBKKChart)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
f(0, "D");
f(500_000, "C");
f(600_000, "B");
f(700_000, "BB");
f(800_000, "BBB");
f(900_000, "A");
f(925_000, "AA");
f(950_000, "AAA");
f(975_000, "S");
f(990_000, "S+");
f(1_000_000, "SS");
f(1_005_000, "SS+");
f(1_007_500, "SSS");
f(1_009_000, "SSS+");
t.end();
});
t.test("Rating Calc", (t) => {
t.equal(
CHUNITHM_IMPL.scoreCalcs(
scoreData,
CHUNITHM_IMPL.scoreDeriver(scoreData, CHUNITHMBBKKChart),
CHUNITHMBBKKChart,
).rating,
4.3,
"Basic rating check",
);
t.end();
});
t.todo("Session Calcs");
t.test("Profile Calcs", (t) => {
t.beforeEach(ResetDBState);
const mockPBs = async (ratings: Array<number>) => {
await Promise.all(
ratings.map((rating, idx) =>
MONGODB_KILL["personal-bests"].insert({
...TestingChunithmScorePB,
chartID: `TEST${idx}`,
calculatedData: {
...TestingChunithmScorePB.calculatedData,
rating,
},
}),
),
);
};
t.test("Floating-point edge case", async (t) => {
await mockPBs(Array(50).fill(17.15));
t.equal((await CHUNITHM_IMPL.profileCalcs("chunithm", "Single", 1)).naiveRating, 17.15);
t.end();
});
t.test("Profile with fewer than 50 scores", async (t) => {
await mockPBs([16, 16, 16, 16]);
t.equal((await CHUNITHM_IMPL.profileCalcs("chunithm", "Single", 1)).naiveRating, 1.28);
t.end();
});
t.end();
});
t.test("Colour Deriver", (t) => {
const f = (v: number | null, expected: any) =>
t.equal(
CHUNITHM_IMPL.classDerivers({ naiveRating: v }).colour,
expected,
`A naiveRating of ${v} should result in ${expected}.`,
);
f(null, null);
f(0, "BLUE");
f(2, "GREEN");
f(4, "ORANGE");
f(7, "RED");
f(10, "PURPLE");
f(12, "COPPER");
f(13.25, "SILVER");
f(14.5, "GOLD");
f(15.25, "PLATINUM");
f(16, "RAINBOW");
f(16.25, "RAINBOW_II");
f(16.5, "RAINBOW_III");
f(16.75, "RAINBOW_IV");
f(17, "RAINBOW_EX_I");
f(17.25, "RAINBOW_EX_II");
f(17.5, "RAINBOW_EX_III");
t.end();
});
t.test("Goal Formatters", (t) => {
t.test("Criteria", (t) => {
t.equal(
CHUNITHM_IMPL.goalCriteriaFormatters.score(1_008_182),
"Get a score of 1,008,182 on",
);
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof CHUNITHM_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"chunithm:Single">>,
goalValue: any,
expected: any,
) =>
t.equal(
CHUNITHM_IMPL.goalProgressFormatters[k](
dmf(mockPB, {
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("grade", { grade: "S+", score: 997_342 }, CHUNITHM_GRADES.SS, "SS-2.7K");
f("score", { score: 982_123 }, 1_000_000, "982,123");
f("clearLamp", { clearLamp: "CLEAR" }, CHUNITHM_CLEAR_LAMPS.CLEAR, "CLEAR");
f("noteLamp", { noteLamp: "FULL COMBO" }, CHUNITHM_NOTE_LAMPS.FULL_COMBO, "FULL COMBO");
t.end();
});
t.test("Out Of", (t) => {
t.equal(CHUNITHM_IMPL.goalOutOfFormatters.score(1_001_003), "1,001,003");
t.equal(CHUNITHM_IMPL.goalOutOfFormatters.score(983_132), "983,132");
t.end();
});
t.end();
});
t.test("PB Merging", (t) => {
t.beforeEach(ResetDBState);
t.test("Should join best lamp", async (t) => {
await MONGODB_KILL.scores.insert(mockScore);
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestNoteLamp",
scoreData: {
score: 0,
noteLamp: "FULL COMBO",
enumIndexes: { noteLamp: CHUNITHM_NOTE_LAMPS.FULL_COMBO },
},
}),
);
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestClearLamp",
scoreData: {
score: 0,
clearLamp: "ABSOLUTE",
enumIndexes: { clearLamp: CHUNITHM_CLEAR_LAMPS.ABSOLUTE },
},
}),
);
t.hasStrict(await CreatePBDoc("chunithm:Single", 1, CHUNITHMBBKKChart, log), {
composedFrom: [
{ name: "Best Score" },
{ name: "Best Note Lamp", scoreID: "bestNoteLamp" },
{ name: "Best Clear Lamp", scoreID: "bestClearLamp" },
],
scoreData: {
score: mockScore.scoreData.score,
clearLamp: "ABSOLUTE",
noteLamp: "FULL COMBO",
enumIndexes: {
clearLamp: CHUNITHM_CLEAR_LAMPS.ABSOLUTE,
noteLamp: CHUNITHM_NOTE_LAMPS.FULL_COMBO,
},
},
});
t.end();
});
t.end();
});
t.end();
});
@@ -0,0 +1,333 @@
import { CHUNITHM_IMPL } from "#game-implementations/games/chunithm";
import { log } from "#lib/log/log";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { CHUNITHMBBKKChart, CHUNITHMBBKKSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
CHUNITHM_CLEAR_LAMPS,
CHUNITHM_GRADES,
CHUNITHM_NOTE_LAMPS,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
const chart = CHUNITHMBBKKChart;
const baseMetrics: MongoProvidedMetrics["chunithm"] = {
clearLamp: "CLEAR",
noteLamp: "NONE",
score: 1_003_000,
};
const scoreData: ScoreData<"chunithm"> = {
clearLamp: "CLEAR",
noteLamp: "NONE",
score: 1_003_000,
grade: "SS",
judgements: {},
optional: { enumIndexes: {} },
enumIndexes: {
grade: CHUNITHM_GRADES.SS,
clearLamp: CHUNITHM_CLEAR_LAMPS.CLEAR,
noteLamp: CHUNITHM_NOTE_LAMPS.NONE,
},
};
let profileChartCounter = 0;
async function seedChunithmChartsWithPbs(userId: number, ratings: Array<number>) {
const start = profileChartCounter;
await Promise.all(
ratings.map(async (rating, i) => {
const n = start + i + 1;
const songId = `song-chuni-prof-${n}`;
const chartId = `chart-chuni-prof-${n}`;
await DB.insertInto("song")
.values({
id: songId,
legacy_id: 80_000 + n,
game_group: "chunithm",
title: "P",
artist: "A",
search_terms: [],
alt_titles: [],
data: {},
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chartId,
legacy_id: chartId,
game: "chunithm",
song_id: songId,
level: "12",
level_num: 12,
is_primary: true,
difficulty: "MASTER",
versions: [],
data: {},
})
.execute();
await DB.insertInto("pb")
.values({
user_id: userId,
chart_id: chartId,
lens: null,
data: JSON.stringify({}),
derived_data: JSON.stringify({}),
calculated_data: JSON.stringify({ rating }),
judgements: JSON.stringify({}),
ranking_value: rating,
ranking_value_tb1: null,
ranking_value_tb2: null,
ranking_value_tb3: null,
ranking_value_tb4: null,
ranking_value_tb5: null,
highlight: false,
time_achieved: null,
})
.execute();
}),
);
profileChartCounter += ratings.length;
}
async function seedBbkkChart() {
await DB.insertInto("song")
.values({
id: CHUNITHMBBKKSong.id,
legacy_id: 3,
game_group: "chunithm",
title: CHUNITHMBBKKSong.title,
artist: CHUNITHMBBKKSong.artist,
search_terms: CHUNITHMBBKKSong.searchTerms,
alt_titles: CHUNITHMBBKKSong.altTitles,
data: CHUNITHMBBKKSong.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chart.chartID,
legacy_id: chart.chartID,
game: "chunithm",
song_id: CHUNITHMBBKKSong.id,
difficulty: chart.difficulty,
level: chart.level,
level_num: chart.levelNum,
is_primary: chart.isPrimary,
versions: chart.versions,
data: chart.data,
})
.execute();
}
async function insertChuniScore(opts: {
scoreId: string;
sd: ScoreData<"chunithm">;
timeMs: number;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg("chunithm", opts.sd);
const ts = UnixMillisecondsToISO8601(opts.timeMs);
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: chart.chartID,
game: "chunithm",
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: ts,
time_added: ts,
highlight: false,
comment: null,
})
.execute();
}
describe("CHUNITHM_IMPL", () => {
describe("scoreDeriver (grade)", () => {
const g = (score: number, expected: string) =>
expect(
CHUNITHM_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never)
.grade,
).toBe(expected);
it("maps score to grade", () => {
g(0, "D");
g(500_000, "C");
g(600_000, "B");
g(700_000, "BB");
g(800_000, "BBB");
g(900_000, "A");
g(925_000, "AA");
g(950_000, "AAA");
g(975_000, "S");
g(990_000, "S+");
g(1_000_000, "SS");
g(1_005_000, "SS+");
g(1_007_500, "SSS");
g(1_009_000, "SSS+");
});
});
it("scoreCalcs rating", () => {
expect(
CHUNITHM_IMPL.scoreCalcs(scoreData, CHUNITHM_IMPL.scoreDeriver(scoreData, chart), chart)
.rating,
).toBe(4.3);
});
describe("profileCalcs naiveRating", () => {
it("handles floating-point mean of top 50", async () => {
const { id: userId } = await seedUser({ username: "chuni_prof_fp" });
await seedChunithmChartsWithPbs(userId, Array(50).fill(17.15));
const res = await CHUNITHM_IMPL.profileCalcs("chunithm", userId);
expect(res.naiveRating).toBe(17.15);
});
it("averages fewer than 50 PBs over 50", async () => {
const { id: userId } = await seedUser({ username: "chuni_prof_short" });
await seedChunithmChartsWithPbs(userId, [16, 16, 16, 16]);
const res = await CHUNITHM_IMPL.profileCalcs("chunithm", userId);
expect(res.naiveRating).toBe(1.28);
});
});
describe("classDerivers (naiveRating → colour)", () => {
const c = (
v: number | null,
expected: ReturnType<typeof CHUNITHM_IMPL.classDerivers>["colour"],
) => expect(CHUNITHM_IMPL.classDerivers({ naiveRating: v }).colour).toBe(expected);
it("maps rating tiers", () => {
c(null, null);
c(0, "BLUE");
c(2, "GREEN");
c(4, "ORANGE");
c(7, "RED");
c(10, "PURPLE");
c(12, "COPPER");
c(13.25, "SILVER");
c(14.5, "GOLD");
c(15.25, "PLATINUM");
c(16, "RAINBOW");
c(16.25, "RAINBOW_II");
c(16.5, "RAINBOW_III");
c(16.75, "RAINBOW_IV");
c(17, "RAINBOW_EX_I");
c(17.25, "RAINBOW_EX_II");
c(17.5, "RAINBOW_EX_III");
});
});
describe("goal formatters", () => {
const mockPB = mkMockPB("chunithm", chart, scoreData);
it("formats criteria", () => {
expect(CHUNITHM_IMPL.goalCriteriaFormatters.score(1_008_182)).toBe(
"Get a score of 1,008,182 on",
);
});
it("formats progress", () => {
const f = (
k: keyof typeof CHUNITHM_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"chunithm">>,
goalValue: number,
expected: string,
) =>
expect(
CHUNITHM_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
f("grade", { grade: "S+", score: 997_342 }, CHUNITHM_GRADES.SS, "SS-2.7K");
f("score", { score: 982_123 }, 1_000_000, "982,123");
f("clearLamp", { clearLamp: "CLEAR" }, CHUNITHM_CLEAR_LAMPS.CLEAR, "CLEAR");
f("noteLamp", { noteLamp: "FULL COMBO" }, CHUNITHM_NOTE_LAMPS.FULL_COMBO, "FULL COMBO");
});
it("formats out-of", () => {
expect(CHUNITHM_IMPL.goalOutOfFormatters.score(1_001_003)).toBe("1,001,003");
expect(CHUNITHM_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
});
});
describe("PB merging (CreatePBDoc)", () => {
beforeEach(seedBbkkChart);
it("joins best note and clear lamps", async () => {
const { id: userId } = await seedUser({ username: "chuni_pb" });
const main = mkMockScore("chunithm", chart, scoreData);
const noteSd: ScoreData<"chunithm"> = {
...scoreData,
score: 0,
noteLamp: "FULL COMBO",
enumIndexes: {
...scoreData.enumIndexes,
noteLamp: CHUNITHM_NOTE_LAMPS.FULL_COMBO,
},
};
const clearSd: ScoreData<"chunithm"> = {
...scoreData,
score: 0,
clearLamp: "ABSOLUTE",
enumIndexes: {
...scoreData.enumIndexes,
clearLamp: CHUNITHM_CLEAR_LAMPS.ABSOLUTE,
},
};
await insertChuniScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 });
await insertChuniScore({ userId, scoreId: "bestNoteLamp", sd: noteSd, timeMs: 2000 });
await insertChuniScore({ userId, scoreId: "bestClearLamp", sd: clearSd, timeMs: 3000 });
const pb = await CreatePBDoc("chunithm", userId, chart, log);
expect(pb).toMatchObject({
composedFrom: [
{ name: "Best Score" },
{ name: "Best Note Lamp", scoreID: "bestNoteLamp" },
{ name: "Best Clear Lamp", scoreID: "bestClearLamp" },
],
scoreData: {
score: scoreData.score,
clearLamp: "ABSOLUTE",
noteLamp: "FULL COMBO",
enumIndexes: {
clearLamp: CHUNITHM_CLEAR_LAMPS.ABSOLUTE,
noteLamp: CHUNITHM_NOTE_LAMPS.FULL_COMBO,
},
},
});
});
});
});
@@ -1,4 +1,4 @@
import type { GPTServerImplementation } from "#game-implementations/types";
import type { GameImplementation } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
@@ -9,7 +9,7 @@ import { CHUNITHM_GBOUNDARIES, FmtNum, GetGrade } from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
export const CHUNITHM_IMPL: GameImplementation<"chunithm"> = {
chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(CHUNITHM_GBOUNDARIES, scoreData.score),
@@ -28,8 +28,8 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
sessionCalcs: (arr) => ({
naiveRating: SessionAvgBest10For("rating")(arr),
}),
profileCalcs: async (game, playtype, userID) => ({
naiveRating: await ProfileAvgBestN("rating", 50, false, 100)(game, playtype, userID),
profileCalcs: async (game, userID) => ({
naiveRating: await ProfileAvgBestN("rating", 50, false, 100)(game, userID),
}),
classDerivers: (ratings) => {
const rating = ratings.naiveRating;
@@ -109,7 +109,7 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
),
],
defaultMergeRefName: "Best Score",
derivationRelevantFields: ["levelNum"],
chartDataRelevantFields: ["levelNum"],
scoreValidators: [
(s) => {
if (
@@ -1,205 +0,0 @@
import { dmf, mkFakePBDDRSP } from "#test-utils/misc";
import { Testing511SPA, TestingDDRSP, TestingDDRSPScore } from "#test-utils/test-data";
import {
type ChartDocumentData,
DDR_GRADES,
DDR_LAMPS,
type ProvidedMetrics,
type ScoreData,
} from "tachi-common";
import t from "tap";
import { TestSnapshot } from "../../test-utils/single-process-snapshot";
import { DDR_IMPL } from "./ddr";
import { IIDX_DP_IMPL, IIDX_SP_IMPL } from "./iidx";
const baseMetrics: ProvidedMetrics["ddr:DP" | "ddr:SP"] = {
lamp: "CLEAR",
score: 750_000,
};
t.test("DDR Implementation", (t) => {
t.test("Derivers", (t) => {
const impl = DDR_IMPL;
t.test("Grade", (t) => {
const f = (score: number, expected: any) =>
t.equal(
impl.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingDDRSP as any)
.grade,
expected,
`A score of ${score} should result in grade=${expected}.`,
);
f(990500, "AAA");
f(950500, "AA+");
f(900500, "AA");
f(890500, "AA-");
f(850500, "A+");
f(800500, "A");
f(790500, "A-");
f(750500, "B+");
f(700500, "B");
f(690500, "B-");
f(650500, "C+");
f(600500, "C");
f(590500, "C-");
f(550500, "D+");
f(500, "D");
t.end();
});
t.end();
});
t.test("Class Derivers", (t) => {
const impl = DDR_IMPL;
t.test("Flare", (t) => {
const f = (ratings: number, expected: any) =>
t.equal(
impl.classDerivers({ flareSkill: ratings }).flare,
expected,
`A flare skill of ${ratings} should result in grade=${expected}.`,
);
f(1, "NONE");
f(501, "NONE+");
f(1001, "NONE++");
f(1501, "NONE+++");
f(2001, "MERCURY");
f(3001, "MERCURY+");
f(4001, "MERCURY++");
f(5001, "MERCURY+++");
f(6001, "VENUS");
f(7001, "VENUS+");
f(8001, "VENUS++");
f(9001, "VENUS+++");
f(10001, "EARTH");
f(11501, "EARTH+");
f(13001, "EARTH++");
f(14501, "EARTH+++");
f(16001, "MARS");
f(18001, "MARS+");
f(20001, "MARS++");
f(22001, "MARS+++");
f(24001, "JUPITER");
f(26501, "JUPITER+");
f(29001, "JUPITER++");
f(31501, "JUPITER+++");
f(34001, "SATURN");
f(36751, "SATURN+");
f(39501, "SATURN++");
f(42251, "SATURN+++");
f(45001, "URANUS");
f(48751, "URANUS+");
f(52501, "URANUS++");
f(56251, "URANUS+++");
f(60001, "NEPTUNE");
f(63751, "NEPTUNE+");
f(67501, "NEPTUNE++");
f(71251, "NEPTUNE+++");
f(75001, "SUN");
f(78751, "SUN+");
f(82501, "SUN++");
f(86251, "SUN+++");
f(90001, "WORLD");
t.end();
});
t.end();
});
t.test("CalculatedData", (t) => {
const impl = DDR_IMPL;
t.test("Flare Skill", (t) => {
const f = (
scoreData: Partial<ScoreData<"ddr:DP" | "ddr:SP">>,
chartData: Partial<ChartDocumentData["ddr:DP" | "ddr:SP"]>,
expected: any,
msg: string,
) => {
const sd = dmf(TestingDDRSPScore.scoreData, scoreData);
const ch = dmf(TestingDDRSP, { data: chartData as any }) as any;
return t.equal(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).flareSkill,
expected,
msg,
);
};
f(
{ grade: "AA", lamp: "CLEAR", optional: { flare: "II", enumIndexes: {} } },
{},
257,
"Level 6 song with Flare II should give 257 flare skill",
);
f({ grade: "E", lamp: "FAILED" }, {}, 0, "Song failed: flare skill should be 0");
t.end();
});
t.end();
});
t.test("Goal Formatters", (t) => {
const impl = DDR_IMPL;
t.test("Criteria", (t) => {
t.equal(impl.goalCriteriaFormatters.score(123456), "Get a score of 123,456 on");
t.equal(impl.goalCriteriaFormatters.score(0), "Get a score of 0 on");
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof impl.goalProgressFormatters,
modifant: Partial<ScoreData>,
goalValue: any,
expected: any,
) =>
t.equal(
impl.goalProgressFormatters[k](
mkFakePBDDRSP({
// @ts-expect-error lol deepmerge types
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("score", { score: 123_456 }, 1_000_000, "123,456");
f("score", { score: 0 }, 1_000_000, "0");
f("grade", { score: 955000, grade: "AA+" }, DDR_GRADES.AAA, "AAA-35,000");
f("grade", { score: 995000, grade: "AAA" }, DDR_GRADES.AAA, "AAA+5,000");
f("lamp", { lamp: "CLEAR", optional: {} as any }, DDR_LAMPS.FULL_COMBO, "CLEAR");
f(
"lamp",
{ lamp: "FULL COMBO", optional: {} as any },
DDR_LAMPS.FULL_COMBO,
"FULL COMBO",
);
t.end();
});
t.test("OutOf", (t) => {
t.equal(impl.goalOutOfFormatters.score(123456), "123,456");
t.end();
});
t.end();
});
t.end();
});
@@ -84,7 +84,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
it("returns flareSkill null when the user has no qualifying PBs", async () => {
const { id: userId } = await seedUser();
const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId);
const result = await DDR_IMPL.profileCalcs("ddr-sp", userId);
expect(result).toEqual({ flareSkill: null });
});
@@ -93,12 +93,12 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
const { id: userId } = await seedUser();
await seedDdrPbRow(userId, { flareSkill: 42, flareCategory: "CLASSIC" });
const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId);
const result = await DDR_IMPL.profileCalcs("ddr-sp", userId);
expect(result).toEqual({ flareSkill: 42 });
});
it("uses ddr-dp charts when playtype is DP", async () => {
it("uses ddr-dp charts when game is ddr-dp", async () => {
const { id: userId } = await seedUser();
await seedDdrPbRow(userId, {
flareSkill: 77,
@@ -106,10 +106,10 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
game: "ddr-dp",
});
const spOnly = await DDR_IMPL.profileCalcs("ddr", "SP", userId);
const spOnly = await DDR_IMPL.profileCalcs("ddr-sp", userId);
expect(spOnly).toEqual({ flareSkill: null });
const dp = await DDR_IMPL.profileCalcs("ddr", "DP", userId);
const dp = await DDR_IMPL.profileCalcs("ddr-dp", userId);
expect(dp).toEqual({ flareSkill: 77 });
});
@@ -117,7 +117,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
const { id: userId } = await seedUser();
await seedDdrPbRow(userId, { flareSkill: 999, flareCategory: "OTHER" });
const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId);
const result = await DDR_IMPL.profileCalcs("ddr-sp", userId);
expect(result).toEqual({ flareSkill: 0 });
});
@@ -130,7 +130,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
calculatedDataOverride: { flareSkill: "not-a-number" },
});
const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId);
const result = await DDR_IMPL.profileCalcs("ddr-sp", userId);
expect(result).toEqual({ flareSkill: null });
});
@@ -143,7 +143,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
isPrimary: false,
});
const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId);
const result = await DDR_IMPL.profileCalcs("ddr-sp", userId);
expect(result).toEqual({ flareSkill: null });
});
@@ -158,7 +158,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
});
}
const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId);
const result = await DDR_IMPL.profileCalcs("ddr-sp", userId);
const expectedSum = Array.from({ length: 30 }, (_, k) => 1031 - k).reduce(
(a, b) => a + b,
@@ -174,7 +174,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
await seedDdrPbRow(userId, { flareSkill: 200, flareCategory: "CLASSIC" });
await seedDdrPbRow(userId, { flareSkill: 100, flareCategory: "WHITE" });
const result = await DDR_IMPL.profileCalcs("ddr", "SP", userId);
const result = await DDR_IMPL.profileCalcs("ddr-sp", userId);
expect(result).toEqual({ flareSkill: 600 });
});
@@ -0,0 +1,164 @@
import { DDR_IMPL } from "#game-implementations/games/ddr";
import { dmf, mkFakePBDDRSP } from "#test-utils/misc";
import { TestingDDRSP, TestingDDRSPScore } from "#test-utils/test-data";
import {
type ChartDocumentData,
DDR_GRADES,
DDR_LAMPS,
type integer,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
import { describe, expect, it } from "vitest";
const baseMetrics: MongoProvidedMetrics["ddr-dp" | "ddr-sp"] = {
lamp: "CLEAR",
score: 750_000,
};
describe("DDR_IMPL", () => {
describe("scoreDeriver grade", () => {
const impl = DDR_IMPL;
const f = (score: number, expected: string) => {
expect(
impl.scoreDeriver(dmf(baseMetrics, { score }) as never, TestingDDRSP as never)
.grade,
).toBe(expected);
};
it("maps EX score to DDR letter grades", () => {
f(990500, "AAA");
f(950500, "AA+");
f(900500, "AA");
f(890500, "AA-");
f(850500, "A+");
f(800500, "A");
f(790500, "A-");
f(750500, "B+");
f(700500, "B");
f(690500, "B-");
f(650500, "C+");
f(600500, "C");
f(590500, "C-");
f(550500, "D+");
f(500, "D");
});
});
describe("classDerivers flare", () => {
const impl = DDR_IMPL;
const f = (ratings: number, expected: string) => {
expect(impl.classDerivers({ flareSkill: ratings }).flare).toBe(expected);
};
it("maps flare skill to flare rank", () => {
f(1, "NONE");
f(501, "NONE+");
f(1001, "NONE++");
f(1501, "NONE+++");
f(2001, "MERCURY");
f(3001, "MERCURY+");
f(4001, "MERCURY++");
f(5001, "MERCURY+++");
f(6001, "VENUS");
f(7001, "VENUS+");
f(8001, "VENUS++");
f(9001, "VENUS+++");
f(10001, "EARTH");
f(11501, "EARTH+");
f(13001, "EARTH++");
f(14501, "EARTH+++");
f(16001, "MARS");
f(18001, "MARS+");
f(20001, "MARS++");
f(22001, "MARS+++");
f(24001, "JUPITER");
f(26501, "JUPITER+");
f(29001, "JUPITER++");
f(31501, "JUPITER+++");
f(34001, "SATURN");
f(36751, "SATURN+");
f(39501, "SATURN++");
f(42251, "SATURN+++");
f(45001, "URANUS");
f(48751, "URANUS+");
f(52501, "URANUS++");
f(56251, "URANUS+++");
f(60001, "NEPTUNE");
f(63751, "NEPTUNE+");
f(67501, "NEPTUNE++");
f(71251, "NEPTUNE+++");
f(75001, "SUN");
f(78751, "SUN+");
f(82501, "SUN++");
f(86251, "SUN+++");
f(90001, "WORLD");
});
});
describe("scoreCalcs flareSkill", () => {
const impl = DDR_IMPL;
const f = (
scoreData: Partial<ScoreData<"ddr-dp" | "ddr-sp">>,
chartData: Partial<ChartDocumentData["ddr-dp" | "ddr-sp"]>,
expected: number,
_msg?: string,
) => {
const sd = dmf(TestingDDRSPScore.scoreData, scoreData);
const ch = dmf(TestingDDRSP, { data: chartData as never }) as never;
expect(impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).flareSkill).toBe(expected);
};
it("computes flare skill from grade, lamp, and flare", () => {
f({ grade: "AA", lamp: "CLEAR", optional: { flare: "II", enumIndexes: {} } }, {}, 257);
f({ grade: "E", lamp: "FAILED" }, {}, 0);
});
});
describe("goal formatters", () => {
const impl = DDR_IMPL;
it("criteria", () => {
expect(impl.goalCriteriaFormatters.score(123456)).toBe("Get a score of 123,456 on");
expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on");
});
it("progress", () => {
const f = (
k: keyof typeof impl.goalProgressFormatters,
modifant: Partial<ScoreData<"ddr-dp" | "ddr-sp">>,
goalValue: integer,
expected: string,
) => {
expect(
impl.goalProgressFormatters[k](
mkFakePBDDRSP({
scoreData: modifant,
} as never),
goalValue,
),
).toBe(expected);
};
f("score", { score: 123_456 }, 1_000_000, "123,456");
f("score", { score: 0 }, 1_000_000, "0");
f("grade", { score: 955000, grade: "AA+" }, DDR_GRADES.AAA, "AAA-35,000");
f("grade", { score: 995000, grade: "AAA" }, DDR_GRADES.AAA, "AAA+5,000");
f("lamp", { lamp: "CLEAR", optional: {} as never }, DDR_LAMPS.FULL_COMBO, "CLEAR");
f(
"lamp",
{ lamp: "FULL COMBO", optional: {} as never },
DDR_LAMPS.FULL_COMBO,
"FULL COMBO",
);
});
it("outOf", () => {
expect(impl.goalOutOfFormatters.score(123456)).toBe("123,456");
});
});
});
@@ -1,38 +1,40 @@
import type {
GameImplementation,
GPTGoalFormatters,
GPTGoalProgressFormatters,
GPTProfileCalcs,
GPTServerImplementation,
ScoreValidator,
} from "#game-implementations/types";
import DB from "#services/pg/db";
import { IsNullish } from "#utils/misc";
import { sql } from "kysely";
import { DDRFlare } from "rg-stats";
import {
type ChartDocument,
DDR_GBOUNDARIES,
FmtNum,
GamePTToV3,
GetGameConfig,
GetGrade,
GetSpecificGPTConfig,
type MONGO_ChartDocument,
type MONGO_ScoreDocument,
type ScoreDocument,
} from "tachi-common";
import { IsNullish } from "../../utils/misc";
import { CreatePBMergeFor } from "../utils/pb-merge";
import { SessionAvgBest10For } from "../utils/session-calc";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
const DDR_GOAL_FMT: GPTGoalFormatters<"ddr:DP" | "ddr:SP"> = {
/** `ddr:SP` / `ddr:DP` as v3 games. */
type DDRGames = "ddr-dp" | "ddr-sp";
const DDR_GOAL_FMT: GPTGoalFormatters<DDRGames> = {
score: GoalFmtScore,
};
const DDR_GOAL_OO_FMT: GPTGoalFormatters<"ddr:DP" | "ddr:SP"> = {
const DDR_GOAL_OO_FMT: GPTGoalFormatters<DDRGames> = {
score: GoalOutOfFmtScore,
};
const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters<"ddr:DP" | "ddr:SP"> = {
const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters<DDRGames> = {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
@@ -45,11 +47,8 @@ const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters<"ddr:DP" | "ddr:SP"> = {
),
};
export const DDR_SCORE_VALIDATORS: Array<ScoreValidator<"ddr:DP" | "ddr:SP">> = [
(
s: MONGO_ScoreDocument<"ddr:DP" | "ddr:SP">,
chart?: MONGO_ChartDocument<"ddr:DP" | "ddr:SP">,
) => {
export const DDR_SCORE_VALIDATORS: Array<ScoreValidator<DDRGames>> = [
(s: ScoreDocument<DDRGames>, chart?: ChartDocument<DDRGames>) => {
if (s.scoreData.lamp === "FAILED" || !chart || IsNullish(chart.data.stepCount)) {
return;
}
@@ -147,9 +146,7 @@ export const DDR_SCORE_VALIDATORS: Array<ScoreValidator<"ddr:DP" | "ddr:SP">> =
},
];
const DDR_PROFILE_CALCS: GPTProfileCalcs<"ddr:DP" | "ddr:SP"> = async (game, playtype, userID) => {
const v3Game = GamePTToV3(game, playtype);
const DDR_PROFILE_CALCS: GPTProfileCalcs<DDRGames> = async (game, userID) => {
const rows = await DB.selectFrom("pb")
.innerJoin("chart", "chart.id", "pb.chart_id")
.innerJoin("song", "song.id", "chart.song_id")
@@ -160,7 +157,7 @@ const DDR_PROFILE_CALCS: GPTProfileCalcs<"ddr:DP" | "ddr:SP"> = async (game, pla
sql<string | null>`song.data::jsonb->>'flareCategory'`.as("flare_category"),
])
.where("pb.user_id", "=", userID)
.where("chart.game", "=", v3Game)
.where("chart.game", "=", game)
.where("chart.is_primary", "=", true)
.where((eb) =>
eb(sql`jsonb_typeof(pb.calculated_data::jsonb -> 'flareSkill')`, "=", sql`'number'`),
@@ -284,7 +281,7 @@ function DeriveFlareClass(flarePoints: number) {
}
}
export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = {
export const DDR_IMPL: GameImplementation<DDRGames> = {
chartSpecificValidators: {},
classDerivers: (ratings) => {
const flarePoints = ratings.flareSkill;
@@ -300,11 +297,11 @@ export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = {
return { flareSkill: 0 };
}
const flareLevel = scoreData.optional.flare
? GetSpecificGPTConfig("ddr:SP").optionalMetrics.flare.values.indexOf(
scoreData.optional.flare,
)
: 0;
const flareConf = GetGameConfig(chart.game).optionalMetrics.flare;
const flareLevel =
scoreData.optional.flare && flareConf.type === "ENUM"
? flareConf.values.indexOf(scoreData.optional.flare)
: 0;
return { flareSkill: DDRFlare.calculate(chart.levelNum, flareLevel) };
},
@@ -351,5 +348,5 @@ export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = {
}),
profileCalcs: DDR_PROFILE_CALCS,
scoreValidators: DDR_SCORE_VALIDATORS,
derivationRelevantFields: ["levelNum"],
chartDataRelevantFields: ["levelNum"],
};
@@ -1,220 +0,0 @@
import { log } from "#lib/log/log.js";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import MONGODB_KILL from "#services/mongo/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import ResetDBState from "#test-utils/resets";
import { TestingGitadoraChart } from "#test-utils/test-data";
import {
GetGPTString,
GITADORA_GRADES,
GITADORA_LAMPS,
type GPTStrings,
type PBScoreDocument,
type ProvidedMetrics,
type ScoreData,
type ScoreDocument,
} from "tachi-common";
import t from "tap";
import { GITADORA_DORA_IMPL, GITADORA_GITA_IMPL } from "./gitadora";
const baseMetrics: ProvidedMetrics[GPTStrings["gitadora"]] = {
lamp: "CLEAR",
percent: 45,
};
const scoreData: ScoreData<GPTStrings["gitadora"]> = {
lamp: "CLEAR",
percent: 96,
grade: "SS",
enumIndexes: {
grade: GITADORA_GRADES.SS,
lamp: GITADORA_LAMPS.CLEAR,
},
judgements: {},
optional: { enumIndexes: {} },
};
for (const [playtype, impl] of [
["Dora", GITADORA_DORA_IMPL],
["Gita", GITADORA_GITA_IMPL],
] as const) {
const mockScore = mkMockScore("gitadora", playtype, TestingGitadoraChart, scoreData);
const mockPB = mkMockPB("gitadora", playtype, TestingGitadoraChart, scoreData);
t.test(`Gitadora ${playtype} Implementation`, (t) => {
t.test("Grade Deriver", (t) => {
const f = (percent: number, expected: any) =>
t.equal(
impl.scoreDeriver(
dmf(baseMetrics, { percent }) as any,
TestingGitadoraChart as any,
).grade,
expected,
`A percent of ${percent}% should result in grade=${expected}.`,
);
f(0, "C");
f(62, "C");
f(63, "B");
f(73, "A");
f(80, "S");
f(94, "S");
f(95, "SS");
f(100, "MAX");
// almosts
f(62.99, "C");
f(72.99, "B");
f(79.99, "A");
f(94.99, "S");
f(99.99, "SS");
t.end();
});
t.test("Skill Calc", (t) => {
const f = (
modifant: Partial<ScoreData<GPTStrings["gitadora"]>>,
expected: any,
msg: string,
) => {
const sd = dmf(scoreData, modifant);
const ch = TestingGitadoraChart as any;
return t.equal(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).skill,
expected,
msg,
);
};
f({ percent: 76.57 }, 72.74, "Basic Skill Check");
t.end();
});
t.todo("Profile Calc");
t.test("Colour Class", (t) => {
const f = (v: number | null, expected: any) =>
t.equal(
impl.classDerivers({ naiveSkill: v }).colour,
expected,
`A skill level of ${v} should result in ${expected}.`,
);
f(null, null);
f(1, "WHITE");
f(999, "WHITE");
f(1000, "ORANGE");
f(2000, "YELLOW");
f(3000, "GREEN");
f(4000, "BLUE");
f(5000, "PURPLE");
f(6000, "RED");
f(1500, "ORANGE_GRD");
f(2500, "YELLOW_GRD");
f(3500, "GREEN_GRD");
f(4500, "BLUE_GRD");
f(5500, "PURPLE_GRD");
f(6500, "RED_GRD");
f(7000, "BRONZE");
f(7500, "SILVER");
f(8000, "GOLD");
f(8500, "RAINBOW");
t.end();
});
t.test("Goal Formatters", (t) => {
t.test("Criteria", (t) => {
t.equal(impl.goalCriteriaFormatters.percent(28.194), "Get 28.19% on");
t.equal(impl.goalCriteriaFormatters.percent(28.195), "Get 28.20% on");
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof impl.goalProgressFormatters,
modifant: Partial<ScoreData<GPTStrings["gitadora"]>>,
goalValue: any,
expected: any,
) =>
t.equal(
impl.goalProgressFormatters[k](
dmf(mockPB, {
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("percent", { percent: 12.32 }, 30, "12.32%");
f("grade", { grade: "SS", percent: 98.19 }, GITADORA_GRADES.MAX, "MAX-1.81%");
f("lamp", { lamp: "CLEAR" }, GITADORA_LAMPS.CLEAR, "CLEAR");
t.end();
});
t.test("Out Of", (t) => {
t.equal(impl.goalOutOfFormatters.percent(28.194), "28.19%");
t.equal(impl.goalOutOfFormatters.percent(28.194), "28.19%");
t.end();
});
t.end();
});
t.test("PB Merging", (t) => {
t.beforeEach(ResetDBState);
t.test("Should join best lamp", async (t) => {
await MONGODB_KILL.scores.insert(mockScore);
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestLamp",
scoreData: {
percent: 0,
lamp: "FULL COMBO",
enumIndexes: { lamp: GITADORA_LAMPS.FULL_COMBO },
},
}),
);
t.hasStrict(
await CreatePBDoc(
GetGPTString("gitadora", playtype),
1,
TestingGitadoraChart as any,
log,
),
{
composedFrom: [
{ name: "Best Percent" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
percent: mockScore.scoreData.percent,
lamp: "FULL COMBO",
enumIndexes: { lamp: GITADORA_LAMPS.FULL_COMBO },
},
},
);
t.end();
});
t.end();
});
t.end();
});
}
@@ -0,0 +1,251 @@
import { GITADORA_DORA_IMPL, GITADORA_GITA_IMPL } from "#game-implementations/games/gitadora";
import { log } from "#lib/log/log";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingGitadoraChart, TestingGitadoraSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
GITADORA_GRADES,
GITADORA_LAMPS,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
const baseMetrics: MongoProvidedMetrics["gitadora-dora" | "gitadora-gita"] = {
lamp: "CLEAR",
percent: 45,
};
const scoreData: ScoreData<"gitadora-dora" | "gitadora-gita"> = {
lamp: "CLEAR",
percent: 96,
grade: "SS",
enumIndexes: {
grade: GITADORA_GRADES.SS,
lamp: GITADORA_LAMPS.CLEAR,
},
judgements: {},
optional: { enumIndexes: {} },
};
async function seedGitadoraChart(game: "gitadora-dora" | "gitadora-gita") {
const chart = dmf(TestingGitadoraChart, { game } as never);
await DB.insertInto("song")
.values({
id: TestingGitadoraSong.id,
legacy_id: 1,
game_group: "gitadora",
title: TestingGitadoraSong.title,
artist: TestingGitadoraSong.artist,
search_terms: TestingGitadoraSong.searchTerms,
alt_titles: TestingGitadoraSong.altTitles,
data: TestingGitadoraSong.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chart.chartID,
legacy_id: chart.chartID,
game,
song_id: TestingGitadoraSong.id,
difficulty: chart.difficulty,
level: chart.level,
level_num: chart.levelNum,
is_primary: chart.isPrimary,
versions: chart.versions,
data: chart.data,
})
.execute();
return chart;
}
async function insertGitadoraScore(opts: {
chartId: string;
game: "gitadora-dora" | "gitadora-gita";
scoreId: string;
sd: ScoreData<"gitadora-dora" | "gitadora-gita">;
timeMs: number;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg(opts.game, opts.sd);
const ts = UnixMillisecondsToISO8601(opts.timeMs);
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: opts.chartId,
game: opts.game,
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: ts,
time_added: ts,
highlight: false,
comment: null,
})
.execute();
}
describe.each([
["gitadora-dora", GITADORA_DORA_IMPL] as const,
["gitadora-gita", GITADORA_GITA_IMPL] as const,
])("%s", (game, impl) => {
const chart = dmf(TestingGitadoraChart, { game } as never);
describe("scoreDeriver (grade)", () => {
const g = (percent: number, expected: string) =>
expect(
impl.scoreDeriver(dmf(baseMetrics, { percent }) as never, chart as never).grade,
).toBe(expected);
it("maps percent to grade", () => {
g(0, "C");
g(62, "C");
g(63, "B");
g(73, "A");
g(80, "S");
g(94, "S");
g(95, "SS");
g(100, "MAX");
g(62.99, "C");
g(72.99, "B");
g(79.99, "A");
g(94.99, "S");
g(99.99, "SS");
});
});
it("scoreCalcs skill", () => {
const sd = dmf(scoreData, { percent: 76.57 });
expect(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, chart as never), chart as never).skill,
).toBe(72.74);
});
describe("classDerivers (naiveSkill → colour)", () => {
const c = (v: number | null, expected: ReturnType<typeof impl.classDerivers>["colour"]) =>
expect(impl.classDerivers({ naiveSkill: v }).colour).toBe(expected);
it("maps skill tiers", () => {
c(null, null);
c(1, "WHITE");
c(999, "WHITE");
c(1000, "ORANGE");
c(2000, "YELLOW");
c(3000, "GREEN");
c(4000, "BLUE");
c(5000, "PURPLE");
c(6000, "RED");
c(1500, "ORANGE_GRD");
c(2500, "YELLOW_GRD");
c(3500, "GREEN_GRD");
c(4500, "BLUE_GRD");
c(5500, "PURPLE_GRD");
c(6500, "RED_GRD");
c(7000, "BRONZE");
c(7500, "SILVER");
c(8000, "GOLD");
c(8500, "RAINBOW");
});
});
describe("goal formatters", () => {
const mockPB = mkMockPB(game, chart, scoreData);
it("criteria", () => {
expect(impl.goalCriteriaFormatters.percent(28.194)).toBe("Get 28.19% on");
expect(impl.goalCriteriaFormatters.percent(28.195)).toBe("Get 28.20% on");
});
it("progress", () => {
const f = (
k: keyof typeof impl.goalProgressFormatters,
modifant: Partial<ScoreData<typeof game>>,
goalValue: number,
expected: string,
) =>
expect(
impl.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
f("percent", { percent: 12.32 }, 30, "12.32%");
f("grade", { grade: "SS", percent: 98.19 }, GITADORA_GRADES.MAX, "MAX-1.81%");
f("lamp", { lamp: "CLEAR" }, GITADORA_LAMPS.CLEAR, "CLEAR");
});
it("outOf", () => {
expect(impl.goalOutOfFormatters.percent(28.194)).toBe("28.19%");
});
});
describe("PB merging (CreatePBDoc)", () => {
let chartRow: typeof chart;
beforeEach(async () => {
chartRow = await seedGitadoraChart(game);
});
it("joins best lamp", async () => {
const { id: userId } = await seedUser({ username: `gitadora_pb_${game}` });
const main = mkMockScore(game, chartRow, scoreData);
const lampSd: ScoreData<typeof game> = {
...scoreData,
percent: 0,
lamp: "FULL COMBO",
enumIndexes: {
...scoreData.enumIndexes,
lamp: GITADORA_LAMPS.FULL_COMBO,
},
};
await insertGitadoraScore({
userId,
game,
chartId: chartRow.chartID,
scoreId: main.scoreID,
sd: scoreData,
timeMs: 1000,
});
await insertGitadoraScore({
userId,
game,
chartId: chartRow.chartID,
scoreId: "bestLamp",
sd: lampSd,
timeMs: 2000,
});
const pb = await CreatePBDoc(game, userId, chartRow, log);
expect(pb).toMatchObject({
composedFrom: [
{ name: "Best Percent" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
percent: scoreData.percent,
lamp: "FULL COMBO",
enumIndexes: { lamp: GITADORA_LAMPS.FULL_COMBO },
},
});
});
});
});
@@ -1,5 +1,4 @@
import type { GPTServerImplementation } from "#game-implementations/types";
import { type GameImplementation } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileSumBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
@@ -9,7 +8,7 @@ import { GetGrade, GITADORA_GBOUNDARIES } from "tachi-common";
import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common";
const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita"> = {
const GITADORA_IMPL: GameImplementation<"gitadora-dora" | "gitadora-gita"> = {
chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(GITADORA_GBOUNDARIES, scoreData.percent),
@@ -28,8 +27,8 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita">
sessionCalcs: (arr) => ({
skill: SessionAvgBest10For("skill")(arr),
}),
profileCalcs: async (game, playtype, userID) => ({
naiveSkill: await ProfileSumBestN("skill", 50)(game, playtype, userID),
profileCalcs: async (game, userID) => ({
naiveSkill: await ProfileSumBestN("skill", 50)(game, userID),
}),
classDerivers: (ratings) => {
const sk = ratings.naiveSkill;
@@ -104,9 +103,9 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita">
],
defaultMergeRefName: "Best Percent",
scoreValidators: [],
derivationRelevantFields: ["levelNum"],
chartDataRelevantFields: ["levelNum"],
};
export const GITADORA_GITA_IMPL: GPTServerImplementation<"gitadora:Gita"> = GITADORA_IMPL;
export const GITADORA_GITA_IMPL: GameImplementation<"gitadora-gita"> = GITADORA_IMPL;
export const GITADORA_DORA_IMPL: GPTServerImplementation<"gitadora:Dora"> = GITADORA_IMPL;
export const GITADORA_DORA_IMPL: GameImplementation<"gitadora-dora"> = GITADORA_IMPL;
@@ -0,0 +1,172 @@
import { log } from "#lib/log/log";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { mkFakeScoreIIDXSP } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { Testing511Song, Testing511SPA } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { IIDX_LAMPS, type ScoreData } from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
const chart = Testing511SPA;
async function seedIidx511Chart() {
await DB.insertInto("song")
.values({
id: Testing511Song.id,
legacy_id: 1,
game_group: "iidx",
title: Testing511Song.title,
artist: Testing511Song.artist,
search_terms: Testing511Song.searchTerms,
alt_titles: Testing511Song.altTitles,
data: Testing511Song.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chart.chartID,
legacy_id: chart.chartID,
game: "iidx-sp",
song_id: Testing511Song.id,
difficulty: chart.difficulty,
level: chart.level,
level_num: chart.levelNum,
is_primary: chart.isPrimary,
versions: chart.versions,
data: chart.data,
})
.execute();
}
async function insertIidxScore(opts: {
scoreId: string;
sd: ScoreData<"iidx-sp">;
timeMs: number;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", opts.sd);
const ts = UnixMillisecondsToISO8601(opts.timeMs);
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: chart.chartID,
game: "iidx-sp",
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: ts,
time_added: ts,
highlight: false,
comment: null,
})
.execute();
}
describe("IIDX_IMPL CreatePBDoc integration", () => {
beforeEach(seedIidx511Chart);
for (const game of ["iidx-sp", "iidx-dp"] as const) {
describe(`CreatePBDoc (${game})`, () => {
it("joins best lamp", async () => {
const { id: userId } = await seedUser({ username: `iidx_pb_lamp_${game}` });
const main = mkFakeScoreIIDXSP({ userID: userId });
const lampSd: ScoreData<"iidx-sp"> = {
...main.scoreData,
score: 0,
lamp: "FULL COMBO",
enumIndexes: {
...main.scoreData.enumIndexes,
lamp: IIDX_LAMPS.FULL_COMBO,
},
};
await insertIidxScore({
userId,
scoreId: main.scoreID,
sd: main.scoreData,
timeMs: 1000,
});
await insertIidxScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 });
const pb = await CreatePBDoc(game, userId, chart, log);
expect(pb).toMatchObject({
composedFrom: [
{ name: "Best Score" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
score: 786,
lamp: "FULL COMBO",
enumIndexes: {
lamp: IIDX_LAMPS.FULL_COMBO,
},
},
});
});
it("joins lowest BP", async () => {
const { id: userId } = await seedUser({ username: `iidx_pb_bp_${game}` });
const a = mkFakeScoreIIDXSP({
userID: userId,
scoreID: "whateverBP",
scoreData: { optional: { bp: 100 } },
});
const b = mkFakeScoreIIDXSP({
userID: userId,
scoreID: "lowestBP",
scoreData: { optional: { bp: 1 } },
});
const c = mkFakeScoreIIDXSP({
userID: userId,
scoreID: "nullBP",
scoreData: { optional: { bp: null } },
});
await insertIidxScore({
userId,
scoreId: a.scoreID,
sd: a.scoreData,
timeMs: 1000,
});
await insertIidxScore({
userId,
scoreId: b.scoreID,
sd: b.scoreData,
timeMs: 2000,
});
await insertIidxScore({
userId,
scoreId: c.scoreID,
sd: c.scoreData,
timeMs: 3000,
});
const pb = await CreatePBDoc(game, userId, chart, log);
expect(pb).toMatchObject({
composedFrom: [
{ name: "Best Score" },
{ name: "Lowest BP", scoreID: "lowestBP" },
],
scoreData: {
score: 786,
optional: { bp: 1 },
},
});
});
});
}
});
@@ -0,0 +1,322 @@
import { IIDX_DP_IMPL, IIDX_SP_IMPL } from "#game-implementations/games/iidx";
import { dmf, mkFakePBIIDXSP } from "#test-utils/misc";
import { Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data";
import {
type ChartDocumentData,
IIDX_GRADES,
IIDX_LAMPS,
type integer,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
import { describe, expect, it } from "vitest";
const baseMetrics: MongoProvidedMetrics["iidx-dp" | "iidx-sp"] = {
lamp: "HARD CLEAR",
score: 1000,
};
const max = Testing511SPA.data.notecount * 2;
function percentToScore(percent: number) {
return (percent / 100) * max;
}
function scoreToPercent(score: number) {
return (100 * score) / max;
}
describe("IIDX_IMPL (unit)", () => {
describe("scoreDeriver", () => {
describe.each([IIDX_SP_IMPL, IIDX_DP_IMPL] as const)("SP/DP impl", (impl) => {
it("computes percent from EX score", () => {
const f = (
modifant: Partial<typeof baseMetrics>,
expected: number,
_msg?: string,
) => {
expect(
impl.scoreDeriver(
dmf(baseMetrics, modifant) as never,
Testing511SPA as never,
).percent,
).toBe(expected);
};
f(
{ score: 1000 },
63.61323155216285,
"An EXScore of 1000 should result in a percent of 1000 / (notecount*2)",
);
f({ score: 0 }, 0, "An EXScore of 0 should count as 0%.");
f(
{ score: Testing511SPA.data.notecount * 2 },
100,
"A perfect score should be worth 100%",
);
});
it("maps percent to letter grade", () => {
const f = (percent: number, expected: string) => {
expect(
impl.scoreDeriver(
dmf(baseMetrics, { score: percentToScore(percent) }) as never,
Testing511SPA as never,
).grade,
).toBe(expected);
};
f(0, "F");
f(22.23, "E");
f(33.34, "D");
f(44.45, "C");
f(55.56, "B");
f(66.67, "A");
f(77.78, "AA");
f(88.89, "AAA");
f(94.45, "MAX-");
f(100, "MAX");
f(0, "F");
f(11.11, "F");
f(22.22, "F");
f(33.33, "E");
f(44.44, "D");
f(55.55, "C");
f(66.66, "B");
f(77.77, "A");
f(88.88, "AA");
f(94.44, "AAA");
f(99.99, "MAX-");
});
});
});
describe("chartSpecificValidators.score", () => {
it.each([
["SP", IIDX_SP_IMPL],
["DP", IIDX_DP_IMPL],
] as const)("accepts valid EX scores (%s)", (_playtype, impl) => {
expect(impl.chartSpecificValidators.score(1000, Testing511SPA as never)).toBe(true);
expect(impl.chartSpecificValidators.score(0, Testing511SPA as never)).toBe(true);
expect(
impl.chartSpecificValidators.score(
Testing511SPA.data.notecount * 2,
Testing511SPA as never,
),
).toBe(true);
});
it.each([
["SP", IIDX_SP_IMPL],
["DP", IIDX_DP_IMPL],
] as const)("rejects invalid EX scores (%s)", (_playtype, impl) => {
expect(impl.chartSpecificValidators.score(-1, Testing511SPA as never)).toBe(
"EX Score cannot be negative.",
);
expect(
impl.chartSpecificValidators.score(
Testing511SPA.data.notecount * 2 + 1,
Testing511SPA as never,
),
).toBe(
`EX Score cannot be greater than ${Testing511SPA.data.notecount * 2} for this chart.`,
);
});
});
describe("scoreCalcs BPI", () => {
it.each([IIDX_SP_IMPL, IIDX_DP_IMPL] as const)("BPI cases", (impl) => {
const f = (
scoreData: Partial<ScoreData<"iidx-dp" | "iidx-sp">>,
chartData: Partial<ChartDocumentData["iidx-dp" | "iidx-sp"]>,
expected: number,
_msg?: string,
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA, { data: chartData as never });
expect(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch as never), ch as never).BPI,
).toBe(expected);
};
f(
{ score: 123 },
{ kaidenAverage: 123, worldRecord: 200 },
0,
"score == kaidenAverage",
);
f({ score: 200 }, { kaidenAverage: 123, worldRecord: 200 }, 100, "score == WR");
f(
{ score: 180 },
{ kaidenAverage: 123, worldRecord: 200 },
69.64067840359507,
"general calc",
);
f({ score: 100 }, { kaidenAverage: 123, worldRecord: 200 }, -15, "below kavg");
});
});
describe("scoreCalcs ktLampRating", () => {
it("IIDX SP", () => {
const f = (
scoreData: Partial<ScoreData<"iidx-sp">>,
chartData: Partial<ChartDocumentData["iidx-sp"]>,
expected: number,
_msg?: string,
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA, { data: chartData as never });
expect(
IIDX_SP_IMPL.scoreCalcs(sd, IIDX_SP_IMPL.scoreDeriver(sd, ch), ch).ktLampRating,
).toBe(expected);
};
f({ lamp: "FAILED" }, {}, 0);
f({ lamp: "ASSIST CLEAR" }, {}, 0);
f({ lamp: "EASY CLEAR" }, {}, 0);
f({ lamp: "CLEAR" }, {}, Testing511SPA.levelNum);
f({ lamp: "HARD CLEAR" }, {}, Testing511SPA.levelNum);
f({ lamp: "EX HARD CLEAR" }, {}, Testing511SPA.levelNum);
f({ lamp: "FULL COMBO" }, {}, Testing511SPA.levelNum);
function mkTier(v: number) {
return { value: v, text: "whatever", individualDifference: false };
}
f({ lamp: "CLEAR" }, { ncTier: mkTier(15) }, 15);
f({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15), hcTier: mkTier(16) }, 16);
f(
{ lamp: "EX HARD CLEAR" },
{ ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) },
17,
);
f({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15) }, 15);
f({ lamp: "EX HARD CLEAR" }, { ncTier: mkTier(15) }, 15);
f({ lamp: "EX HARD CLEAR" }, { ncTier: mkTier(15), hcTier: mkTier(16) }, 16);
});
it("IIDX DP", () => {
const f = (
scoreData: Partial<ScoreData<"iidx-dp">>,
chartData: Partial<ChartDocumentData["iidx-dp"]>,
expected: number,
_msg?: string,
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA, { data: chartData as never });
expect(
IIDX_DP_IMPL.scoreCalcs(
sd,
IIDX_DP_IMPL.scoreDeriver(sd, ch as never),
ch as never,
).ktLampRating,
).toBe(expected);
};
f({ lamp: "FAILED" }, {}, 0);
f({ lamp: "ASSIST CLEAR" }, {}, 0);
f({ lamp: "EASY CLEAR" }, {}, Testing511SPA.levelNum);
f({ lamp: "CLEAR" }, {}, Testing511SPA.levelNum);
f({ lamp: "HARD CLEAR" }, {}, Testing511SPA.levelNum);
f({ lamp: "EX HARD CLEAR" }, {}, Testing511SPA.levelNum);
f({ lamp: "FULL COMBO" }, {}, Testing511SPA.levelNum);
function mkTier(v: number) {
return { value: v, text: "whatever", individualDifference: false };
}
f({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) }, 15);
f({ lamp: "CLEAR" }, { dpTier: mkTier(15) }, 15);
f({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) }, 15);
f({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) }, 15);
});
});
describe("goal formatters", () => {
describe.each([IIDX_SP_IMPL, IIDX_DP_IMPL] as const)("impl", (impl) => {
it("criteria", () => {
expect(impl.goalCriteriaFormatters.percent(94.42123)).toBe("Get 94.42% on");
expect(impl.goalCriteriaFormatters.percent(94.426)).toBe("Get 94.43% on");
expect(impl.goalCriteriaFormatters.score(1234)).toBe("Get a score of 1234 on");
expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on");
});
it("progress", () => {
const f = (
k: keyof typeof impl.goalProgressFormatters,
modifant: Partial<ScoreData<"iidx-dp" | "iidx-sp">>,
goalValue: integer,
expected: string,
) => {
expect(
impl.goalProgressFormatters[k](
mkFakePBIIDXSP({ scoreData: modifant } as never) as never,
goalValue,
),
).toBe(expected);
};
f("score", { score: 1234 }, 1000, "1234");
f("score", { score: 0 }, 1000, "0");
f("percent", { percent: 92.17472 }, 100, "92.17%");
f("percent", { percent: 92.17572 }, 100, "92.18%");
f(
"grade",
{ score: 1333, percent: scoreToPercent(1333), grade: "AA" },
IIDX_GRADES.AAA,
"AAA-64",
);
f(
"grade",
{ score: 1233, percent: scoreToPercent(1233), grade: "AA" },
IIDX_GRADES.AAA,
"AAA-164",
);
f(
"grade",
{ score: 1400, percent: scoreToPercent(1400), grade: "AAA" },
IIDX_GRADES.AAA,
"AAA+3",
);
f(
"lamp",
{ lamp: "HARD CLEAR", optional: { bp: 2 } as never },
IIDX_LAMPS.HARD_CLEAR,
"HARD CLEAR (BP: 2)",
);
f(
"lamp",
{ lamp: "HARD CLEAR", optional: { bp: null } as never },
IIDX_LAMPS.HARD_CLEAR,
"HARD CLEAR",
);
f(
"grade",
{ score: 1899, percent: (100 * 1899) / 1918, grade: "MAX-" },
IIDX_GRADES.MAX,
"MAX-19",
);
f(
"grade",
{ score: 2831, percent: (100 * 2831) / 3546, grade: "AA" },
IIDX_GRADES.AA,
"AA+73",
);
});
it("outOf", () => {
expect(impl.goalOutOfFormatters.percent(94.42123)).toBe("94.42%");
expect(impl.goalOutOfFormatters.score(1234)).toBe("1234");
});
});
});
});
@@ -1,469 +0,0 @@
import { log } from "#lib/log/log.js";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import MONGODB_KILL from "#services/mongo/db";
import { dmf, mkFakePBIIDXSP, mkFakeScoreIIDXSP } from "#test-utils/misc";
import ResetDBState from "#test-utils/resets";
import { TestSnapshot } from "#test-utils/single-process-snapshot";
import { Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data";
import {
type ChartDocumentData,
GetGrade,
IIDX_GRADES,
IIDX_LAMPS,
IIDXLIKE_GBOUNDARIES,
type ProvidedMetrics,
type ScoreData,
} from "tachi-common";
import t from "tap";
import { IIDX_DP_IMPL, IIDX_SP_IMPL } from "./iidx";
const baseMetrics: ProvidedMetrics["iidx:DP" | "iidx:SP"] = {
lamp: "HARD CLEAR",
score: 1000,
};
const max = Testing511SPA.data.notecount * 2;
function percentToScore(percent: number) {
return (percent / 100) * max;
}
function scoreToPercent(score: number) {
return (100 * score) / max;
}
t.test("IIDX Implementation", (t) => {
t.test("Derivers", (t) => {
for (const impl of [IIDX_SP_IMPL, IIDX_DP_IMPL]) {
t.test("Percent", (t) => {
const f = (modifant: Partial<typeof baseMetrics>, expected: any, msg: string) =>
t.equal(
impl.scoreDeriver(dmf(baseMetrics, modifant) as any, Testing511SPA as any)
.percent,
expected,
msg,
);
f(
{ score: 1000 },
63.61323155216285,
"An EXScore of 1000 should result in a percent of 1000 / (notecount*2)",
);
f({ score: 0 }, 0, "An EXScore of 0 should count as 0%.");
f(
{ score: Testing511SPA.data.notecount * 2 },
100,
"A perfect score should be worth 100%",
);
t.end();
});
t.test("Grade", (t) => {
const f = (percent: number, expected: any) =>
t.equal(
impl.scoreDeriver(
dmf(baseMetrics, { score: percentToScore(percent) }) as any,
Testing511SPA as any,
).grade,
expected,
`A percent of ${percent}% should result in grade=${expected}.`,
);
f(0, "F");
f(22.23, "E");
f(33.34, "D");
f(44.45, "C");
f(55.56, "B");
f(66.67, "A");
f(77.78, "AA");
f(88.89, "AAA");
f(94.45, "MAX-");
f(100, "MAX");
// almosts
f(0, "F");
f(11.11, "F");
f(22.22, "F");
f(33.33, "E");
f(44.44, "D");
f(55.55, "C");
f(66.66, "B");
f(77.77, "A");
f(88.88, "AA");
f(94.44, "AAA");
f(99.99, "MAX-");
t.end();
});
}
t.end();
});
t.test("Validators", (t) => {
for (const [playtype, impl] of [
["SP", IIDX_SP_IMPL],
["DP", IIDX_DP_IMPL],
] as const) {
t.equal(impl.chartSpecificValidators.score(1000, Testing511SPA as any), true);
t.equal(impl.chartSpecificValidators.score(0, Testing511SPA as any), true);
t.equal(
impl.chartSpecificValidators.score(
Testing511SPA.data.notecount * 2,
Testing511SPA as any,
),
true,
);
TestSnapshot(
t,
impl.chartSpecificValidators.score(-1, Testing511SPA as any),
`EX Score Validator ${playtype}: negative`,
);
TestSnapshot(
t,
impl.chartSpecificValidators.score(
Testing511SPA.data.notecount * 2 + 1,
Testing511SPA as any,
),
`EX Score Validator ${playtype}: excessive`,
);
}
t.end();
});
t.test("CalculatedData", (t) => {
for (const impl of [IIDX_SP_IMPL, IIDX_DP_IMPL]) {
t.test("BPI", (t) => {
const f = (
scoreData: Partial<ScoreData<"iidx:DP" | "iidx:SP">>,
chartData: Partial<ChartDocumentData["iidx:DP" | "iidx:DP"]>,
expected: any,
msg: string,
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA, { data: chartData as any }) as any;
return t.equal(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).BPI,
expected,
msg,
);
};
f(
{ score: 123 },
{ kaidenAverage: 123, worldRecord: 200 },
0,
"score == kaidenAverage should be 0",
);
f(
{ score: 200 },
{ kaidenAverage: 123, worldRecord: 200 },
100,
"score == WR should be 100",
);
f(
{ score: 180 },
{ kaidenAverage: 123, worldRecord: 200 },
69.64067840359507,
"general calc check",
);
f(
{ score: 100 },
{ kaidenAverage: 123, worldRecord: 200 },
-15,
"score < kavg should be negative",
);
t.end();
});
}
t.test("ktLampRating IIDX SP", (t) => {
const f = (
scoreData: Partial<ScoreData<"iidx:SP">>,
chartData: Partial<ChartDocumentData["iidx:SP"]>,
expected: any,
msg: string,
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA, { data: chartData as any });
return t.equal(
IIDX_SP_IMPL.scoreCalcs(sd, IIDX_SP_IMPL.scoreDeriver(sd, ch), ch).ktLampRating,
expected,
msg,
);
};
f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0");
f({ lamp: "ASSIST CLEAR" }, {}, 0, "ac should be worth 0");
f({ lamp: "EASY CLEAR" }, {}, 0, "ec should be worth 0");
f({ lamp: "CLEAR" }, {}, Testing511SPA.levelNum, "nc should be worth chart level");
f({ lamp: "HARD CLEAR" }, {}, Testing511SPA.levelNum, "hc should be worth chart level");
f(
{ lamp: "EX HARD CLEAR" },
{},
Testing511SPA.levelNum,
"exhc should be worth chart level",
);
f({ lamp: "FULL COMBO" }, {}, Testing511SPA.levelNum, "fc should be worth chart level");
function mkTier(v: number) {
return { value: v, text: "whatever", individualDifference: false };
}
f({ lamp: "CLEAR" }, { ncTier: mkTier(15) }, 15, "nc should be worth ncTier");
f(
{ lamp: "HARD CLEAR" },
{ ncTier: mkTier(15), hcTier: mkTier(16) },
16,
"hc should be worth hcTier",
);
f(
{ lamp: "EX HARD CLEAR" },
{ ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) },
17,
"exhc should be worth exhcTier",
);
f(
{ lamp: "HARD CLEAR" },
{ ncTier: mkTier(15) },
15,
"hc should be worth ncTier if no hcTier defined",
);
f(
{ lamp: "EX HARD CLEAR" },
{ ncTier: mkTier(15) },
15,
"exhc should be worth ncTier if no exhcTier/hcTier defined",
);
f(
{ lamp: "EX HARD CLEAR" },
{ ncTier: mkTier(15), hcTier: mkTier(16) },
16,
"exhc should be worth hcTier if no exhcTier defined",
);
t.end();
});
t.test("ktLampRating IIDX SP", (t) => {
const f = (
scoreData: Partial<ScoreData<"iidx:DP">>,
chartData: Partial<ChartDocumentData["iidx:DP"]>,
expected: any,
msg: string,
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA as any, { data: chartData as any });
return t.equal(
IIDX_DP_IMPL.scoreCalcs(sd, IIDX_DP_IMPL.scoreDeriver(sd, ch), ch).ktLampRating,
expected,
msg,
);
};
f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0");
f({ lamp: "ASSIST CLEAR" }, {}, 0, "ac should be worth 0");
f({ lamp: "EASY CLEAR" }, {}, Testing511SPA.levelNum, "ec should be worth chart level");
f({ lamp: "CLEAR" }, {}, Testing511SPA.levelNum, "nc should be worth chart level");
f({ lamp: "HARD CLEAR" }, {}, Testing511SPA.levelNum, "hc should be worth chart level");
f(
{ lamp: "EX HARD CLEAR" },
{},
Testing511SPA.levelNum,
"exhc should be worth chart level",
);
f({ lamp: "FULL COMBO" }, {}, Testing511SPA.levelNum, "fc should be worth chart level");
function mkTier(v: number) {
return { value: v, text: "whatever", individualDifference: false };
}
f({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) }, 15, "ec should be worth dpTier");
f({ lamp: "CLEAR" }, { dpTier: mkTier(15) }, 15, "nc should be worth dpTier");
f({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) }, 15, "hc should be worth dpTier");
f({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) }, 15, "exhc should be worth dpTier");
t.end();
});
t.end();
});
t.test("Goal Formatters", (t) => {
for (const impl of [IIDX_SP_IMPL, IIDX_DP_IMPL]) {
t.test("Criteria", (t) => {
t.equal(impl.goalCriteriaFormatters.percent(94.42123), "Get 94.42% on");
t.equal(impl.goalCriteriaFormatters.percent(94.426), "Get 94.43% on");
t.equal(impl.goalCriteriaFormatters.score(1234), "Get a score of 1234 on");
t.equal(impl.goalCriteriaFormatters.score(0), "Get a score of 0 on");
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof impl.goalProgressFormatters,
modifant: Partial<ScoreData>,
goalValue: any,
expected: any,
) =>
t.equal(
impl.goalProgressFormatters[k](
mkFakePBIIDXSP({
// @ts-expect-error lol deepmerge types
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("score", { score: 1234 }, 1000, "1234");
f("score", { score: 0 }, 1000, "0");
f("percent", { percent: 92.17472 }, 100, "92.17%");
f("percent", { percent: 92.17572 }, 100, "92.18%");
f(
"grade",
{ score: 1333, percent: scoreToPercent(1333), grade: "AA" },
IIDX_GRADES.AAA,
"AAA-64",
);
f(
"grade",
{ score: 1233, percent: scoreToPercent(1233), grade: "AA" },
IIDX_GRADES.AAA,
"AAA-164",
);
f(
"grade",
{ score: 1400, percent: scoreToPercent(1400), grade: "AAA" },
IIDX_GRADES.AAA,
"AAA+3",
);
f(
"lamp",
{ lamp: "HARD CLEAR", optional: { bp: 2 } as any },
IIDX_LAMPS.HARD_CLEAR,
"HARD CLEAR (BP: 2)",
);
f(
"lamp",
{ lamp: "HARD CLEAR", optional: { bp: null } as any },
IIDX_LAMPS.HARD_CLEAR,
"HARD CLEAR",
);
f(
"grade",
{ score: 1899, percent: (100 * 1899) / 1918, grade: "MAX-" },
IIDX_GRADES.MAX,
"MAX-19",
);
f(
"grade",
{ score: 2831, percent: (100 * 2831) / 3546, grade: "AA" },
IIDX_GRADES.AA,
"AA+73",
);
t.end();
});
t.test("OutOf", (t) => {
t.equal(impl.goalOutOfFormatters.percent(94.42123), "94.42%");
t.equal(impl.goalOutOfFormatters.score(1234), "1234");
t.end();
});
}
t.end();
});
t.test("PB Mergers", (t) => {
t.beforeEach(ResetDBState);
for (const gptStr of ["iidx:SP", "iidx:DP"] as const) {
t.test("Should join best lamp", async (t) => {
await MONGODB_KILL.scores.insert(
mkFakeScoreIIDXSP({
scoreID: "bestLamp",
scoreData: {
score: 0,
lamp: "FULL COMBO",
enumIndexes: { lamp: IIDX_LAMPS.FULL_COMBO },
},
}),
);
t.hasStrict(await CreatePBDoc(gptStr, 1, Testing511SPA, log), {
composedFrom: [
{ name: "Best Score" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
score: 786,
lamp: "FULL COMBO",
enumIndexes: {
lamp: IIDX_LAMPS.FULL_COMBO,
},
},
});
t.end();
});
t.test("Should join best BP", async (t) => {
await MONGODB_KILL.scores.insert([
mkFakeScoreIIDXSP({
scoreID: "whateverBP",
scoreData: { optional: { bp: 100 } },
}),
mkFakeScoreIIDXSP({
scoreID: "lowestBP",
scoreData: { optional: { bp: 1 } },
}),
mkFakeScoreIIDXSP({
scoreID: "nullBP",
scoreData: { optional: { bp: null } },
}),
]);
t.hasStrict(await CreatePBDoc(gptStr, 1, Testing511SPA, log), {
composedFrom: [
{ name: "Best Score" },
{ name: "Lowest BP", scoreID: "lowestBP" },
],
scoreData: {
score: 786,
optional: { bp: 1 },
},
});
t.end();
});
}
t.end();
});
t.end();
});
@@ -1,19 +1,17 @@
import type {
GameImplementation,
GPTGoalFormatters,
GPTGoalProgressFormatters,
GPTProfileCalcs,
GPTServerImplementation,
GPTSessionCalcs,
PBMergeFunction,
ScoreValidator,
} from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish } from "#utils/misc";
import { PoyashiBPI } from "rg-stats";
import { type GPTStrings, IIDXLIKE_GBOUNDARIES } from "tachi-common";
import { IIDXLIKE_GBOUNDARIES } from "tachi-common";
import {
GoalFmtPercent,
@@ -25,25 +23,24 @@ import {
IIDXLIKE_VALIDATORS,
} from "./_common";
const IIDX_SESSION_CALCS: GPTSessionCalcs<"iidx:DP" | "iidx:SP"> = (arr) => ({
/** `iidx:SP` / `iidx:DP` as v3 games. */
type IIDXGames = "iidx-dp" | "iidx-sp";
const IIDX_SESSION_CALCS: GPTSessionCalcs<IIDXGames> = (arr) => ({
BPI: SessionAvgBest10For("BPI")(arr),
ktLampRating: SessionAvgBest10For("ktLampRating")(arr),
});
const IIDX_PROFILE_CALCS: GPTProfileCalcs<"iidx:DP" | "iidx:SP"> = async (
game,
playtype,
userID,
) => {
const IIDX_PROFILE_CALCS: GPTProfileCalcs<IIDXGames> = async (game, userID) => {
const [BPI, ktLampRating] = await Promise.all([
ProfileAvgBestN("BPI", 20, true)(game, playtype, userID),
ProfileAvgBestN("ktLampRating", 20)(game, playtype, userID),
ProfileAvgBestN("BPI", 20, true)(game, userID),
ProfileAvgBestN("ktLampRating", 20)(game, userID),
]);
return { BPI, ktLampRating };
};
const IIDX_MERGERS: Array<PBMergeFunction<GPTStrings["iidx"]>> = [
const IIDX_MERGERS: Array<PBMergeFunction<IIDXGames>> = [
CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, lamp) => {
base.scoreData.lamp = lamp.scoreData.lamp;
@@ -63,20 +60,20 @@ const IIDX_MERGERS: Array<PBMergeFunction<GPTStrings["iidx"]>> = [
}),
];
const IIDX_GOAL_FMT: GPTGoalFormatters<"iidx:DP" | "iidx:SP"> = {
const IIDX_GOAL_FMT: GPTGoalFormatters<IIDXGames> = {
percent: GoalFmtPercent,
// don't want commas
score: (v) => `Get a score of ${v} on`,
};
const IIDX_GOAL_OO_FMT: GPTGoalFormatters<"iidx:DP" | "iidx:SP"> = {
const IIDX_GOAL_OO_FMT: GPTGoalFormatters<IIDXGames> = {
percent: GoalOutOfFmtPercent,
// don't insert commas or anything.
score: (m) => m.toString(),
};
const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = {
const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<IIDXGames> = {
percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`,
// 4519 -> "4519". Don't add commas or anything.
@@ -108,7 +105,7 @@ const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = {
),
};
export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = {
export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
scoreDeriver: IIDXLIKE_SCORE_DERIVER,
chartSpecificValidators: IIDXLIKE_VALIDATORS,
pbRankingValues: IIDXLIKE_PB_RANKING_VALUES,
@@ -162,7 +159,7 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = {
pbMergeFunctions: IIDX_MERGERS,
defaultMergeRefName: "Best Score",
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
derivationRelevantFields: [
chartDataRelevantFields: [
"levelNum",
"data.notecount",
"data.kaidenAverage",
@@ -174,7 +171,7 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = {
],
};
export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = {
export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
scoreDeriver: IIDXLIKE_SCORE_DERIVER,
chartSpecificValidators: IIDXLIKE_VALIDATORS,
pbRankingValues: IIDXLIKE_PB_RANKING_VALUES,
@@ -219,7 +216,7 @@ export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = {
pbMergeFunctions: IIDX_MERGERS,
defaultMergeRefName: "Best Score",
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
derivationRelevantFields: [
chartDataRelevantFields: [
"levelNum",
"data.notecount",
"data.kaidenAverage",
@@ -1,4 +1,4 @@
import type { GPTServerImplementation } from "#game-implementations/types";
import type { GameImplementation } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileSumBestN } from "#game-implementations/utils/profile-calc";
@@ -9,7 +9,7 @@ import { GetGrade, ITG_GBOUNDARIES } from "tachi-common";
import { GoalFmtPercent, GradeGoalFormatter } from "./_common";
export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = {
chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({
// *important*: don't check survivedPercent === 100 — floating point can
@@ -49,10 +49,10 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
sessionCalcs: (arr) => ({
blockRating: SessionAvgBestNFor("blockRating", 5)(arr),
}),
profileCalcs: async (game, playtype, userID) => {
profileCalcs: async (game, userID) => {
const [highestBlock, fastest32] = await Promise.all([
ProfileSumBestN("blockRating", 1, true)(game, playtype, userID),
ProfileSumBestN("fastest32", 1, true)(game, playtype, userID),
ProfileSumBestN("blockRating", 1, true)(game, userID),
ProfileSumBestN("fastest32", 1, true)(game, userID),
]);
return { highestBlock, fastest32 };
@@ -125,7 +125,7 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
// this name sucks, what should we do instead? TODO.
defaultMergeRefName: "Best Result",
derivationRelevantFields: ["data.rankedLevel", "data.notesPerMeasure", "data.npsPerMeasure"],
chartDataRelevantFields: ["data.rankedLevel", "data.notesPerMeasure", "data.npsPerMeasure"],
scoreValidators: [
(s) => {
@@ -1,274 +0,0 @@
import type { ProvidedMetrics, ScoreData } from "tachi-common";
import { log } from "#lib/log/log.js";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import MONGODB_KILL from "#services/mongo/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import ResetDBState from "#test-utils/resets";
import { TestSnapshot } from "#test-utils/single-process-snapshot";
import { TestingJubeatChart } from "#test-utils/test-data";
import { JUBEAT_SINGLE_CONF } from "tachi-common/config/game-support/jubeat";
import t from "tap";
import { JUBEAT_IMPL } from "./jubeat";
const baseMetrics: ProvidedMetrics["jubeat:Single"] = {
lamp: "CLEAR",
musicRate: 84.21,
score: 970_000,
};
const GRADES = JUBEAT_SINGLE_CONF.derivedMetrics.grade.values;
const LAMPS = JUBEAT_SINGLE_CONF.providedMetrics.lamp.values;
const scoreData: ScoreData<"jubeat:Single"> = {
lamp: "CLEAR",
musicRate: 84.21,
score: 970_000,
grade: "SS",
judgements: {},
optional: { enumIndexes: {} },
enumIndexes: {
grade: GRADES.indexOf("SS"),
lamp: LAMPS.indexOf("CLEAR"),
},
};
const mockScore = mkMockScore("jubeat", "Single", TestingJubeatChart, scoreData);
const mockPB = mkMockPB("jubeat", "Single", TestingJubeatChart, scoreData);
t.test("Jubeat Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
JUBEAT_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingJubeatChart)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
f(0, "E");
f(500_000, "D");
f(700_000, "C");
f(800_000, "B");
f(850_000, "A");
f(900_000, "S");
f(950_000, "SS");
f(980_000, "SSS");
f(1_000_000, "EXC");
t.end();
});
t.test("Music Rate Validator", (t) => {
t.equal(
JUBEAT_IMPL.chartSpecificValidators.musicRate(80, TestingJubeatChart),
true,
"normal music rate",
);
t.equal(
JUBEAT_IMPL.chartSpecificValidators.musicRate(0, TestingJubeatChart),
true,
"0 music rate",
);
t.equal(
JUBEAT_IMPL.chartSpecificValidators.musicRate(100, TestingJubeatChart),
true,
"perfect music rate",
);
t.equal(
JUBEAT_IMPL.chartSpecificValidators.musicRate(
120,
dmf(TestingJubeatChart, {
difficulty: "HARD ADV",
}),
),
true,
"perfect music rate (hard)",
);
TestSnapshot(
t,
JUBEAT_IMPL.chartSpecificValidators.musicRate(-1, TestingJubeatChart),
`Jubeat Music Rate Validator: negative`,
);
TestSnapshot(
t,
JUBEAT_IMPL.chartSpecificValidators.musicRate(
100.1,
dmf(TestingJubeatChart, {
difficulty: "ADV",
}),
),
`Jubeat Music Rate Validator: too big`,
);
TestSnapshot(
t,
JUBEAT_IMPL.chartSpecificValidators.musicRate(
120.1,
dmf(TestingJubeatChart, {
difficulty: "HARD ADV",
}),
),
`Jubeat Music Rate Validator: too big (hard)`,
);
t.end();
});
t.test("Jubility Calc", (t) => {
t.equal(
JUBEAT_IMPL.scoreCalcs(
scoreData,
JUBEAT_IMPL.scoreDeriver(scoreData, TestingJubeatChart),
TestingJubeatChart,
).jubility,
63.7,
"Basic Jubility Test",
);
t.end();
});
t.todo("Session Calcs");
t.todo("Profile Calcs");
t.test("Colour Deriver", (t) => {
const f = (v: number | null, expected: any) =>
t.equal(
JUBEAT_IMPL.classDerivers({ jubility: v }).colour,
expected,
`A jubility of ${v} should result in ${expected}.`,
);
f(null, null);
f(0, "BLACK");
f(9500, "GOLD");
f(8500, "ORANGE");
f(7000, "PINK");
f(5500, "PURPLE");
f(4000, "VIOLET");
f(2500, "BLUE");
f(1500, "LIGHT_BLUE");
f(750, "GREEN");
f(250, "YELLOW_GREEN");
t.end();
});
t.test("Goal Formatters", (t) => {
t.test("Criteria", (t) => {
t.equal(
JUBEAT_IMPL.goalCriteriaFormatters.score(1_008_182),
"Get a score of 1,008,182 on",
);
t.equal(
JUBEAT_IMPL.goalCriteriaFormatters.musicRate(93.1),
"Get a music rate of 93.1% on",
);
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof JUBEAT_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"jubeat:Single">>,
goalValue: any,
expected: any,
) =>
t.equal(
JUBEAT_IMPL.goalProgressFormatters[k](
dmf(mockPB, {
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("grade", { grade: "S", score: 927_342 }, GRADES.indexOf("S"), "SS-23K");
f("score", { score: 982_123 }, 1_000_000, "982,123");
f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR");
f("musicRate", { musicRate: 93.2 }, 94.4, "93.2%");
t.end();
});
t.test("Out Of", (t) => {
t.equal(JUBEAT_IMPL.goalOutOfFormatters.score(983_132), "983,132");
t.equal(JUBEAT_IMPL.goalOutOfFormatters.musicRate(99.1123), "99.1%");
t.end();
});
t.end();
});
t.test("PB Merging", (t) => {
t.beforeEach(ResetDBState);
t.test("Should join best lamp", async (t) => {
await MONGODB_KILL.scores.insert(mockScore);
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestLamp",
scoreData: {
musicRate: 0,
score: 0,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
}),
);
t.hasStrict(await CreatePBDoc("jubeat:Single", 1, TestingJubeatChart, log), {
composedFrom: [
{ name: "Best Music Rate" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
musicRate: mockScore.scoreData.musicRate,
score: mockScore.scoreData.score,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
});
t.end();
});
t.test("Should join best score", async (t) => {
await MONGODB_KILL.scores.insert(mockScore);
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestScore",
scoreData: {
musicRate: 0,
score: 1_000_000,
lamp: "FAILED",
enumIndexes: { lamp: LAMPS.indexOf("FAILED") },
},
}),
);
t.hasStrict(await CreatePBDoc("jubeat:Single", 1, TestingJubeatChart, log), {
composedFrom: [
{ name: "Best Music Rate" },
{ name: "Best Score", scoreID: "bestScore" },
],
scoreData: {
musicRate: mockScore.scoreData.musicRate,
lamp: mockScore.scoreData.lamp,
score: 1_000_000,
},
});
t.end();
});
t.end();
});
t.end();
});
@@ -12,17 +12,12 @@ const JUBEAT_HOT_DISPLAY_VERSION = "ave";
let jubeatSeedCounter = 0;
async function seedJubeatSong(args: {
displayVersion: string;
legacySongId: number;
}): Promise<{ songId: string }> {
async function seedJubeatSong(args: { displayVersion: string; songID: string }): Promise<void> {
const n = ++jubeatSeedCounter;
const songId = `jubeat-prof-song-${n}`;
await DB.insertInto("song")
.values({
id: songId,
legacy_id: args.legacySongId,
id: args.songID,
legacy_id: n,
game_group: "jubeat",
title: "T",
artist: "A",
@@ -32,13 +27,11 @@ async function seedJubeatSong(args: {
fts_document: "",
})
.execute();
return { songId };
}
async function seedJubeatChartPbOnSong(
userId: number,
songId: string,
songID: string,
args: {
calculatedDataOverride?: Record<string, unknown>;
difficulty: string;
@@ -55,7 +48,7 @@ async function seedJubeatChartPbOnSong(
id: chartId,
legacy_id: chartId,
game: "jubeat",
song_id: songId,
song_id: songID,
level: "10",
level_num: 10,
is_primary: isPrimary,
@@ -96,43 +89,43 @@ async function seedJubeatSongChartPb(
displayVersion: string;
isPrimary?: boolean;
jubility: number;
legacySongId: number;
songID: string;
},
): Promise<void> {
const { songId } = await seedJubeatSong({
legacySongId: args.legacySongId,
await seedJubeatSong({
songID: args.songID,
displayVersion: args.displayVersion,
});
await seedJubeatChartPbOnSong(userId, songId, args);
await seedJubeatChartPbOnSong(userId, args.songID, args);
}
describe("GetBestJubilityOnSongs (Postgres)", () => {
it("returns [] when songIDs is empty", async () => {
const { id: userId } = await seedUser();
const rows = await GetBestJubilityOnSongs([], userId, "jubeat", "Single", 30);
const rows = await GetBestJubilityOnSongs([], userId, "jubeat", 30);
expect(rows).toEqual([]);
});
it("keeps the highest jubility per (song, bucket); HARD BSC and BSC share the BSC bucket", async () => {
const { id: userId } = await seedUser();
const legacySongId = 400_001;
const songID = "jubeat-prof-song-400001";
const { songId } = await seedJubeatSong({
legacySongId,
await seedJubeatSong({
songID,
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
});
await seedJubeatChartPbOnSong(userId, songId, {
await seedJubeatChartPbOnSong(userId, songID, {
difficulty: "HARD BSC",
jubility: 100,
});
await seedJubeatChartPbOnSong(userId, songId, {
await seedJubeatChartPbOnSong(userId, songID, {
difficulty: "BSC",
jubility: 250,
});
const rows = await GetBestJubilityOnSongs([legacySongId], userId, "jubeat", "Single", 30);
const rows = await GetBestJubilityOnSongs([songID], userId, "jubeat", 30);
expect(rows).toHaveLength(1);
expect(rows[0]!.calculatedData.jubility).toBe(250);
@@ -140,22 +133,22 @@ describe("GetBestJubilityOnSongs (Postgres)", () => {
it("counts separate buckets on the same song as separate rows (BSC + ADV)", async () => {
const { id: userId } = await seedUser();
const legacySongId = 400_002;
const songID = "jubeat-prof-song-400002";
const { songId } = await seedJubeatSong({
legacySongId,
await seedJubeatSong({
songID,
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
});
await seedJubeatChartPbOnSong(userId, songId, {
await seedJubeatChartPbOnSong(userId, songID, {
difficulty: "BSC",
jubility: 10,
});
await seedJubeatChartPbOnSong(userId, songId, {
await seedJubeatChartPbOnSong(userId, songID, {
difficulty: "ADV",
jubility: 500,
});
const rows = await GetBestJubilityOnSongs([legacySongId], userId, "jubeat", "Single", 30);
const rows = await GetBestJubilityOnSongs([songID], userId, "jubeat", 30);
expect(rows).toHaveLength(2);
const jubs = rows.map((r) => r.calculatedData.jubility).sort((a, b) => b! - a!);
@@ -165,12 +158,12 @@ describe("GetBestJubilityOnSongs (Postgres)", () => {
it("applies a global jubility limit after bucketing", async () => {
const { id: userId } = await seedUser();
const limit = 5;
const songIds = Array.from({ length: 7 }, (_, i) => 400_100 + i);
const songIDs = Array.from({ length: 7 }, (_, i) => `jubeat-prof-song-400100${i}`);
await Promise.all(
songIds.map((legacySongId, i) =>
songIDs.map((songID, i) =>
seedJubeatSongChartPb(userId, {
legacySongId,
songID,
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
difficulty: "EXT",
jubility: 100 + i,
@@ -178,7 +171,7 @@ describe("GetBestJubilityOnSongs (Postgres)", () => {
),
);
const rows = await GetBestJubilityOnSongs(songIds, userId, "jubeat", "Single", limit);
const rows = await GetBestJubilityOnSongs(songIDs, userId, "jubeat", limit);
expect(rows).toHaveLength(limit);
const jubs = rows.map((r) => r.calculatedData.jubility!).sort((a, b) => b - a);
@@ -187,16 +180,16 @@ describe("GetBestJubilityOnSongs (Postgres)", () => {
it("drops charts whose difficulty does not map to a jubility bucket", async () => {
const { id: userId } = await seedUser();
const legacySongId = 400_003;
const songID = "jubeat-prof-song-400003";
await seedJubeatSongChartPb(userId, {
legacySongId,
songID,
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
difficulty: "NOT_A_REAL_BUCKET",
jubility: 9999,
});
const rows = await GetBestJubilityOnSongs([legacySongId], userId, "jubeat", "Single", 30);
const rows = await GetBestJubilityOnSongs([songID], userId, "jubeat", 30);
expect(rows).toEqual([]);
});
@@ -216,13 +209,13 @@ describe("GetPBsForJubility (Postgres)", () => {
const { id: userId } = await seedUser();
await seedJubeatSongChartPb(userId, {
legacySongId: 400_200,
songID: "jubeat-prof-song-400200",
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
difficulty: "EXT",
jubility: 50,
});
await seedJubeatSongChartPb(userId, {
legacySongId: 400_201,
songID: "jubeat-prof-song-400201",
displayVersion: "old",
difficulty: "EXT",
jubility: 80,
@@ -231,25 +224,24 @@ describe("GetPBsForJubility (Postgres)", () => {
const { bestHotScores, bestScores } = await GetPBsForJubility(userId);
expect(bestHotScores).toHaveLength(1);
expect(bestHotScores[0]!.songID).toBe(400_200);
expect(bestHotScores[0]!.songID).toBe("jubeat-prof-song-400200");
expect(bestHotScores[0]!.calculatedData.jubility).toBe(50);
expect(bestScores).toHaveLength(1);
expect(bestScores[0]!.songID).toBe(400_201);
expect(bestScores[0]!.songID).toBe("jubeat-prof-song-400201");
expect(bestScores[0]!.calculatedData.jubility).toBe(80);
});
it("treats missing displayVersion as cold (IS DISTINCT FROM ave)", async () => {
const { id: userId } = await seedUser();
const n = ++jubeatSeedCounter;
const songId = `jubeat-prof-song-${n}`;
const songID = `jubeat-prof-song-${n}`;
const chartId = `jubeat-prof-chart-${n}`;
const legacySongId = 400_202;
await DB.insertInto("song")
.values({
id: songId,
legacy_id: legacySongId,
id: songID,
legacy_id: n,
game_group: "jubeat",
title: "T",
artist: "A",
@@ -265,7 +257,7 @@ describe("GetPBsForJubility (Postgres)", () => {
id: chartId,
legacy_id: chartId,
game: "jubeat",
song_id: songId,
song_id: songID,
level: "10",
level_num: 10,
is_primary: true,
@@ -308,19 +300,19 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => {
const { id: userId } = await seedUser();
await seedJubeatSongChartPb(userId, {
legacySongId: 400_300,
songID: "jubeat-prof-song-400300",
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
difficulty: "EXT",
jubility: 100,
});
await seedJubeatSongChartPb(userId, {
legacySongId: 400_301,
songID: "jubeat-prof-song-400301",
displayVersion: "cold",
difficulty: "EXT",
jubility: 200,
});
const result = await JUBEAT_IMPL.profileCalcs("jubeat", "Single", userId);
const result = await JUBEAT_IMPL.profileCalcs("jubeat", userId);
expect(result.jubility).toBe(300);
expect(result.naiveJubility).toBe(300);
@@ -332,7 +324,7 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => {
await Promise.all(
Array.from({ length: 3 }, (_, i) =>
seedJubeatSongChartPb(userId, {
legacySongId: 400_400 + i,
songID: `jubeat-prof-song-400400${i}`,
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
difficulty: "EXT",
jubility: 10 * (i + 1),
@@ -340,7 +332,7 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => {
),
);
const result = await JUBEAT_IMPL.profileCalcs("jubeat", "Single", userId);
const result = await JUBEAT_IMPL.profileCalcs("jubeat", userId);
expect(result.naiveJubility).toBe(10 + 20 + 30);
});
@@ -349,14 +341,14 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => {
const { id: userId } = await seedUser();
await seedJubeatSongChartPb(userId, {
legacySongId: 400_500,
songID: "jubeat-prof-song-400500",
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
difficulty: "EXT",
isPrimary: false,
jubility: 5000,
});
const result = await JUBEAT_IMPL.profileCalcs("jubeat", "Single", userId);
const result = await JUBEAT_IMPL.profileCalcs("jubeat", userId);
expect(result.naiveJubility).toBeNull();
expect(result.jubility).toBe(5000);
@@ -364,12 +356,12 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => {
it("matches weighted jubility when hot pool contributes capped rows", async () => {
const { id: userId } = await seedUser();
const songIds = Array.from({ length: 31 }, (_, i) => 400_600 + i);
const songIDs = Array.from({ length: 31 }, (_, i) => `jubeat-prof-song-400600${i}`);
await Promise.all(
songIds.map((legacySongId, i) =>
songIDs.map((songID, i) =>
seedJubeatSongChartPb(userId, {
legacySongId,
songID,
displayVersion: JUBEAT_HOT_DISPLAY_VERSION,
difficulty: "EXT",
jubility: 1000 + i,
@@ -383,7 +375,7 @@ describe("JUBEAT_IMPL.profileCalcs (Postgres)", () => {
0,
);
const result = await JUBEAT_IMPL.profileCalcs("jubeat", "Single", userId);
const result = await JUBEAT_IMPL.profileCalcs("jubeat", userId);
expect(bestHotScores).toHaveLength(30);
expect(expectedHotSum).toBe(
@@ -0,0 +1,316 @@
import { JUBEAT_IMPL } from "#game-implementations/games/jubeat";
import { log } from "#lib/log/log";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingJubeatChart, TestingJubeatSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
GetGameConfig,
type integer,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
function enumMetricValues(m: {
type: string;
values?: ReadonlyArray<string>;
}): ReadonlyArray<string> {
if (m.type !== "ENUM" || m.values === undefined) {
throw new Error("expected ENUM metric with values");
}
return m.values;
}
const chart = TestingJubeatChart;
const JUBEAT_CONF = GetGameConfig("jubeat");
const GRADES = enumMetricValues(JUBEAT_CONF.derivedMetrics.grade);
const LAMPS = enumMetricValues(JUBEAT_CONF.providedMetrics.lamp);
const baseMetrics: MongoProvidedMetrics["jubeat"] = {
lamp: "CLEAR",
musicRate: 84.21,
score: 970_000,
};
const scoreData: ScoreData<"jubeat"> = {
lamp: "CLEAR",
musicRate: 84.21,
score: 970_000,
grade: "SS",
judgements: {},
optional: { enumIndexes: {} },
enumIndexes: {
grade: GRADES.indexOf("SS"),
lamp: LAMPS.indexOf("CLEAR"),
},
};
async function seedJubeatChart() {
await DB.insertInto("song")
.values({
id: TestingJubeatSong.id,
legacy_id: 1,
game_group: "jubeat",
title: TestingJubeatSong.title,
artist: TestingJubeatSong.artist,
search_terms: TestingJubeatSong.searchTerms,
alt_titles: TestingJubeatSong.altTitles,
data: TestingJubeatSong.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chart.chartID,
legacy_id: chart.chartID,
game: "jubeat",
song_id: TestingJubeatSong.id,
difficulty: chart.difficulty,
level: chart.level,
level_num: chart.levelNum,
is_primary: chart.isPrimary,
versions: chart.versions,
data: chart.data,
})
.execute();
}
async function insertJubeatScore(opts: {
scoreId: string;
sd: ScoreData<"jubeat">;
timeMs: number;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg("jubeat", opts.sd);
const ts = UnixMillisecondsToISO8601(opts.timeMs);
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: chart.chartID,
game: "jubeat",
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: ts,
time_added: ts,
highlight: false,
comment: null,
})
.execute();
}
describe("JUBEAT_IMPL", () => {
describe("scoreDeriver (grade)", () => {
const g = (score: number, expected: string) =>
expect(
JUBEAT_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as never, chart as never)
.grade,
).toBe(expected);
it("maps score thresholds to letter grades", () => {
g(0, "E");
g(500_000, "D");
g(700_000, "C");
g(800_000, "B");
g(850_000, "A");
g(900_000, "S");
g(950_000, "SS");
g(980_000, "SSS");
g(1_000_000, "EXC");
});
});
describe("chartSpecificValidators.musicRate", () => {
it("accepts in-range music rate", () => {
expect(JUBEAT_IMPL.chartSpecificValidators.musicRate(80, chart)).toBe(true);
expect(JUBEAT_IMPL.chartSpecificValidators.musicRate(0, chart)).toBe(true);
expect(JUBEAT_IMPL.chartSpecificValidators.musicRate(100, chart)).toBe(true);
expect(
JUBEAT_IMPL.chartSpecificValidators.musicRate(
120,
dmf(TestingJubeatChart, { difficulty: "HARD ADV" }) as never,
),
).toBe(true);
});
it("rejects out-of-range music rate", () => {
expect(JUBEAT_IMPL.chartSpecificValidators.musicRate(-1, chart)).toBe(
"Expected a number between 0 and 100.",
);
expect(
JUBEAT_IMPL.chartSpecificValidators.musicRate(
100.1,
dmf(TestingJubeatChart, { difficulty: "ADV" }) as never,
),
).toBe("Expected a number between 0 and 100.");
expect(
JUBEAT_IMPL.chartSpecificValidators.musicRate(
120.1,
dmf(TestingJubeatChart, { difficulty: "HARD ADV" }) as never,
),
).toBe("Expected a number between 0 and 120.");
});
});
it("scoreCalcs jubility", () => {
expect(
JUBEAT_IMPL.scoreCalcs(scoreData, JUBEAT_IMPL.scoreDeriver(scoreData, chart), chart)
.jubility,
).toBe(63.7);
});
it.skip("Session Calcs", () => {
// not ported from tap todo
});
it.skip("Profile Calcs", () => {
// not ported from tap todo
});
describe("classDerivers", () => {
const c = (
v: number | null,
expected: ReturnType<typeof JUBEAT_IMPL.classDerivers>["colour"],
) => expect(JUBEAT_IMPL.classDerivers({ jubility: v }).colour).toBe(expected);
it("maps jubility to colour", () => {
c(null, null);
c(0, "BLACK");
c(9500, "GOLD");
c(8500, "ORANGE");
c(7000, "PINK");
c(5500, "PURPLE");
c(4000, "VIOLET");
c(2500, "BLUE");
c(1500, "LIGHT_BLUE");
c(750, "GREEN");
c(250, "YELLOW_GREEN");
});
});
describe("goal formatters", () => {
const mockPB = mkMockPB("jubeat", chart, scoreData);
it("criteria", () => {
expect(JUBEAT_IMPL.goalCriteriaFormatters.score(1_008_182)).toBe(
"Get a score of 1,008,182 on",
);
expect(JUBEAT_IMPL.goalCriteriaFormatters.musicRate(93.1)).toBe(
"Get a music rate of 93.1% on",
);
});
it("progress", () => {
const f = (
k: keyof typeof JUBEAT_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"jubeat">>,
goalValue: integer,
expected: string,
) =>
expect(
JUBEAT_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
f("grade", { grade: "S", score: 927_342 }, GRADES.indexOf("S"), "SS-23K");
f("score", { score: 982_123 }, 1_000_000, "982,123");
f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR");
f("musicRate", { musicRate: 93.2 }, 94.4, "93.2%");
});
it("outOf", () => {
expect(JUBEAT_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
expect(JUBEAT_IMPL.goalOutOfFormatters.musicRate(99.1123)).toBe("99.1%");
});
});
describe("PB merging (CreatePBDoc)", () => {
beforeEach(seedJubeatChart);
it("joins best lamp", async () => {
const { id: userId } = await seedUser({ username: "jubeat_pb_lamp" });
const main = mkMockScore("jubeat", chart, scoreData);
const lampSd: ScoreData<"jubeat"> = {
...scoreData,
musicRate: 0,
score: 0,
lamp: "FULL COMBO",
enumIndexes: {
...scoreData.enumIndexes,
lamp: LAMPS.indexOf("FULL COMBO"),
},
};
await insertJubeatScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 });
await insertJubeatScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 });
const pb = await CreatePBDoc("jubeat", userId, chart, log);
expect(pb).toMatchObject({
composedFrom: [
{ name: "Best Music Rate" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
musicRate: scoreData.musicRate,
score: scoreData.score,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
});
});
it("joins best score", async () => {
const { id: userId } = await seedUser({ username: "jubeat_pb_score" });
const main = mkMockScore("jubeat", chart, scoreData);
const bestScoreSd: ScoreData<"jubeat"> = {
...scoreData,
musicRate: 0,
score: 1_000_000,
lamp: "FAILED",
enumIndexes: {
...scoreData.enumIndexes,
lamp: LAMPS.indexOf("FAILED"),
},
};
await insertJubeatScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 });
await insertJubeatScore({
userId,
scoreId: "bestScore",
sd: bestScoreSd,
timeMs: 2000,
});
const pb = await CreatePBDoc("jubeat", userId, chart, log);
expect(pb).toMatchObject({
composedFrom: [
{ name: "Best Music Rate" },
{ name: "Best Score", scoreID: "bestScore" },
],
scoreData: {
musicRate: scoreData.musicRate,
lamp: scoreData.lamp,
score: 1_000_000,
},
});
});
});
});
@@ -1,4 +1,4 @@
import type { GPTServerImplementation } from "#game-implementations/types";
import type { GameImplementation } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileSumBestN } from "#game-implementations/utils/profile-calc";
@@ -11,13 +11,11 @@ import { p } from "prudence";
import { Jubility } from "rg-stats";
import {
FmtNum,
type GameGroup,
GamePTToV3,
GetGrade,
type integer,
JUBEAT_GBOUNDARIES,
type MONGO_PBScoreDocument,
type Playtype,
type PBScoreDocument,
type V3Game,
type Versions,
} from "tachi-common";
@@ -28,37 +26,34 @@ import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
* difficulties together, then globally sorts by jubility and takes the top `limit` rows.
*/
export async function GetBestJubilityOnSongs(
songIDs: Array<integer>,
songIDs: Array<string>,
userID: integer,
game: GameGroup,
playtype: Playtype,
game: V3Game,
limit: integer,
): Promise<Array<MONGO_PBScoreDocument>> {
): Promise<Array<PBScoreDocument>> {
if (songIDs.length === 0) {
return [];
}
const v3Game = GamePTToV3(game, playtype);
const rows = await sql<PbDocumentJoinRow>`
WITH base AS (
SELECT
pb.row_id,
(pb.calculated_data::jsonb->>'jubility')::double precision AS jubility_val,
song.legacy_id AS song_legacy_id,
song.id AS song_id,
chart.difficulty
FROM pb
INNER JOIN chart ON chart.id = pb.chart_id
INNER JOIN song ON song.id = chart.song_id
WHERE pb.user_id = ${userID}
AND chart.game = ${v3Game}
AND song.legacy_id in (${sql.join(songIDs)})
AND chart.game = ${game}
AND song.id in (${sql.join(songIDs)})
),
bucketed AS (
SELECT
base.row_id,
base.jubility_val,
base.song_legacy_id,
base.song_id,
CASE
WHEN base.difficulty IN ('HARD BSC', 'BSC') THEN 'BSC'
WHEN base.difficulty IN ('HARD ADV', 'ADV') THEN 'ADV'
@@ -71,7 +66,7 @@ export async function GetBestJubilityOnSongs(
SELECT
bucketed.row_id,
ROW_NUMBER() OVER (
PARTITION BY bucketed.song_legacy_id, bucketed.jubility_bucket
PARTITION BY bucketed.song_id, bucketed.jubility_bucket
ORDER BY bucketed.jubility_val DESC NULLS LAST
) AS rn
FROM bucketed
@@ -93,7 +88,7 @@ export async function GetBestJubilityOnSongs(
pb.ranking_value_tb5,
pb.highlight,
pb.time_achieved,
song.legacy_id as song_legacy_id,
song.id as song_id,
chart.game as chart_game,
chart.is_primary as is_primary,
chart_leaderboard.rank AS leaderboard_rank,
@@ -110,42 +105,38 @@ export async function GetBestJubilityOnSongs(
return Promise.all(rows.rows.map((row) => ToPbScoreDocument(row)));
}
const CURRENT_JUBEAT_HOT_VERSION: Versions["jubeat:Single"] = "ave";
const CURRENT_JUBEAT_HOT_VERSION: Versions["jubeat"] = "ave";
export async function GetPBsForJubility(userID: integer) {
const hotSongRows = await DB.selectFrom("song")
.select("legacy_id")
.select("song.id")
.where("game_group", "=", "jubeat")
.where(
sql<boolean>`(song.data::jsonb->>'displayVersion') = ${sql.lit(CURRENT_JUBEAT_HOT_VERSION)}`,
)
.execute();
const hotSongIDs = hotSongRows.map((r) => r.legacy_id);
const hotSongIDs = hotSongRows.map((r) => r.id);
const coldSongRows = await DB.selectFrom("song")
.select("legacy_id")
.select("song.id")
.where("game_group", "=", "jubeat")
.where(
sql<boolean>`(song.data::jsonb->>'displayVersion') IS DISTINCT FROM ${sql.lit(CURRENT_JUBEAT_HOT_VERSION)}`,
)
.execute();
const coldSongIDs = coldSongRows.map((r) => r.legacy_id);
const coldSongIDs = coldSongRows.map((r) => r.id);
const [bestHotScores, bestScores] = await Promise.all([
GetBestJubilityOnSongs(hotSongIDs, userID, "jubeat", "Single", 30),
GetBestJubilityOnSongs(coldSongIDs, userID, "jubeat", "Single", 30),
GetBestJubilityOnSongs(hotSongIDs, userID, "jubeat", 30),
GetBestJubilityOnSongs(coldSongIDs, userID, "jubeat", 30),
]);
return { bestHotScores, bestScores };
}
async function CalculateJubility(
_game: GameGroup,
_playtype: Playtype,
userID: integer,
): Promise<number> {
async function CalculateJubility(userID: integer): Promise<number> {
const { bestHotScores, bestScores } = await GetPBsForJubility(userID);
let jubility = 0;
@@ -156,7 +147,7 @@ async function CalculateJubility(
return jubility;
}
export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
export const JUBEAT_IMPL: GameImplementation<"jubeat"> = {
chartSpecificValidators: {
musicRate: (rate, chart) => {
switch (chart.difficulty) {
@@ -189,10 +180,10 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
sessionCalcs: (arr) => ({
jubility: SessionAvgBest10For("jubility")(arr),
}),
profileCalcs: async (game, playtype, userID) => {
profileCalcs: async (game, userID) => {
const [jubility, naiveJubility] = await Promise.all([
CalculateJubility(game, playtype, userID),
ProfileSumBestN("jubility", 60)(game, playtype, userID),
CalculateJubility(userID),
ProfileSumBestN("jubility", 60)(game, userID),
]);
return { jubility, naiveJubility };
@@ -269,7 +260,7 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
),
],
defaultMergeRefName: "Best Music Rate",
derivationRelevantFields: ["levelNum"],
chartDataRelevantFields: ["levelNum"],
scoreValidators: [
(s) => {
if (s.scoreData.lamp === "EXCELLENT" && s.scoreData.score !== 1_000_000) {
@@ -1,206 +0,0 @@
import type { integer, ProvidedMetrics, ScoreData } from "tachi-common";
import { log } from "#lib/log/log.js";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import MONGODB_KILL from "#services/mongo/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import ResetDBState from "#test-utils/resets";
import { TestSnapshot } from "#test-utils/single-process-snapshot";
import { TestingMaimaiChart } from "#test-utils/test-data";
import { MAIMAI_SINGLE_CONF } from "tachi-common/config/game-support/maimai";
import t from "tap";
import { MAIMAI_IMPL } from "./maimai";
const baseMetrics: ProvidedMetrics["maimai:Single"] = {
lamp: "CLEAR",
percent: 97.01,
};
const GRADES = MAIMAI_SINGLE_CONF.derivedMetrics.grade.values;
const LAMPS = MAIMAI_SINGLE_CONF.providedMetrics.lamp.values;
const scoreData: ScoreData<"maimai:Single"> = {
lamp: "CLEAR",
percent: 97.01,
grade: "S",
judgements: {},
optional: { enumIndexes: {} },
enumIndexes: {
grade: GRADES.indexOf("S"),
lamp: LAMPS.indexOf("CLEAR"),
},
};
const mockScore = mkMockScore("maimai", "Single", TestingMaimaiChart, scoreData);
const mockPB = mkMockPB("maimai", "Single", TestingMaimaiChart, scoreData);
t.test("maimai Implementation", (t) => {
t.test("Percent Validator", (t) => {
t.equal(MAIMAI_IMPL.chartSpecificValidators.percent(100.78, TestingMaimaiChart), true);
t.equal(MAIMAI_IMPL.chartSpecificValidators.percent(0, TestingMaimaiChart), true);
TestSnapshot(
t,
MAIMAI_IMPL.chartSpecificValidators.percent(-1, TestingMaimaiChart),
`Maimai Percent Validator: negative`,
);
TestSnapshot(
t,
MAIMAI_IMPL.chartSpecificValidators.percent(101, TestingMaimaiChart),
`Maimai Percent Validator: excessive`,
);
t.end();
});
t.test("Grade Deriver", (t) => {
const f = (percent: number, expected: string) =>
t.equal(
MAIMAI_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as any, TestingMaimaiChart)
.grade,
expected,
`A percent of ${percent} should result in grade=${expected}.`,
);
f(0, "F");
f(10, "E");
f(20, "D");
f(40, "C");
f(60, "B");
f(80, "A");
f(90, "AA");
f(94, "AAA");
f(97, "S");
f(98, "S+");
f(99, "SS");
f(99.5, "SS+");
f(100, "SSS");
f(100.78, "SSS+");
t.end();
});
t.test("Score Calcs", (t) => {
t.equal(
MAIMAI_IMPL.scoreCalcs(
scoreData,
MAIMAI_IMPL.scoreDeriver(scoreData, TestingMaimaiChart),
TestingMaimaiChart,
).rate,
14.85,
"Basic rate check",
);
t.end();
});
t.todo("Session Calcs");
t.todo("Profile Calcs");
t.test("Colour Deriver", (t) => {
const f = (v: number | null, expected: string | null) =>
t.equal(
MAIMAI_IMPL.classDerivers({ naiveRate: v }).colour,
expected,
`A rate of ${v} should result in ${expected}.`,
);
f(null, null);
f(0, "WHITE");
f(15, "RAINBOW");
f(14.5, "GOLD");
f(14, "SILVER");
f(13, "BRONZE");
f(12, "PURPLE");
f(10, "RED");
f(7, "YELLOW");
f(4, "GREEN");
f(2, "BLUE");
t.end();
});
t.test("Goal Formatters", (t) => {
t.test("Criteria", (t) => {
t.equal(MAIMAI_IMPL.goalCriteriaFormatters.percent(93.14), "Get 93.14% on");
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof MAIMAI_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"maimai:Single">>,
goalValue: integer,
expected: string,
) =>
t.equal(
MAIMAI_IMPL.goalProgressFormatters[k](
dmf(mockPB, {
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.50%");
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SSS+"), "(S+)-0.50%");
f("grade", { grade: "SSS", percent: 100.2 }, GRADES.indexOf("SSS+"), "SSS+0.20%");
f("grade", { grade: "SSS+", percent: 100.78 }, GRADES.indexOf("SSS+"), "SSS+");
f("percent", { percent: 98.23 }, 1_000_000, "98.23%");
f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR");
t.end();
});
t.test("Out Of", (t) => {
t.equal(MAIMAI_IMPL.goalOutOfFormatters.percent(99.11), "99.11%");
t.end();
});
t.end();
});
t.test("PB Merging", (t) => {
t.beforeEach(ResetDBState);
t.test("Should join best lamp", async (t) => {
await MONGODB_KILL.scores.insert(mockScore);
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestLamp",
scoreData: {
percent: 0,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
}),
);
t.hasStrict(await CreatePBDoc("maimai:Single", 1, TestingMaimaiChart, log), {
composedFrom: [
{ name: "Best Percent" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
percent: mockScore.scoreData.percent,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
});
t.end();
});
t.end();
});
t.end();
});
@@ -0,0 +1,251 @@
import { MAIMAI_IMPL } from "#game-implementations/games/maimai";
import { log } from "#lib/log/log";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingMaimaiChart, TestingMaimaiSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
GetGameConfig,
type integer,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
function enumMetricValues(m: {
type: string;
values?: ReadonlyArray<string>;
}): ReadonlyArray<string> {
if (m.type !== "ENUM" || m.values === undefined) {
throw new Error("expected ENUM metric with values");
}
return m.values;
}
const chart = TestingMaimaiChart;
const MAIMAI_CONF = GetGameConfig("maimai");
const GRADES = enumMetricValues(MAIMAI_CONF.derivedMetrics.grade);
const LAMPS = enumMetricValues(MAIMAI_CONF.providedMetrics.lamp);
const baseMetrics: MongoProvidedMetrics["maimai"] = {
lamp: "CLEAR",
percent: 97.01,
};
const scoreData: ScoreData<"maimai"> = {
lamp: "CLEAR",
percent: 97.01,
grade: "S",
judgements: {},
optional: { enumIndexes: {} },
enumIndexes: {
grade: GRADES.indexOf("S"),
lamp: LAMPS.indexOf("CLEAR"),
},
};
async function seedMaimaiChart() {
await DB.insertInto("song")
.values({
id: TestingMaimaiSong.id,
legacy_id: 834,
game_group: "maimai",
title: TestingMaimaiSong.title,
artist: TestingMaimaiSong.artist,
search_terms: TestingMaimaiSong.searchTerms,
alt_titles: TestingMaimaiSong.altTitles,
data: TestingMaimaiSong.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chart.chartID,
legacy_id: chart.chartID,
game: "maimai",
song_id: TestingMaimaiSong.id,
difficulty: chart.difficulty,
level: chart.level,
level_num: chart.levelNum,
is_primary: chart.isPrimary,
versions: chart.versions,
data: chart.data,
})
.execute();
}
async function insertMaimaiScore(opts: {
scoreId: string;
sd: ScoreData<"maimai">;
timeMs: number;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg("maimai", opts.sd);
const ts = UnixMillisecondsToISO8601(opts.timeMs);
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: chart.chartID,
game: "maimai",
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: ts,
time_added: ts,
highlight: false,
comment: null,
})
.execute();
}
describe("MAIMAI_IMPL", () => {
describe("chartSpecificValidators.percent", () => {
it("accepts in-range percent", () => {
expect(MAIMAI_IMPL.chartSpecificValidators.percent(100.78, chart)).toBe(true);
expect(MAIMAI_IMPL.chartSpecificValidators.percent(0, chart)).toBe(true);
});
it("rejects out-of-range percent", () => {
expect(MAIMAI_IMPL.chartSpecificValidators.percent(-1, chart)).toBe(
"Percent cannot be negative.",
);
expect(MAIMAI_IMPL.chartSpecificValidators.percent(101, chart)).toBe(
`Percent cannot be greater than ${chart.data.maxPercent} for this chart.`,
);
});
});
describe("scoreDeriver (grade)", () => {
const g = (percent: number, expected: string) =>
expect(
MAIMAI_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as never, chart as never)
.grade,
).toBe(expected);
it("maps percent to grade", () => {
g(0, "F");
g(10, "E");
g(20, "D");
g(40, "C");
g(60, "B");
g(80, "A");
g(90, "AA");
g(94, "AAA");
g(97, "S");
g(98, "S+");
g(99, "SS");
g(99.5, "SS+");
g(100, "SSS");
g(100.78, "SSS+");
});
});
it("scoreCalcs rate", () => {
expect(
MAIMAI_IMPL.scoreCalcs(scoreData, MAIMAI_IMPL.scoreDeriver(scoreData, chart), chart)
.rate,
).toBe(14.85);
});
describe("classDerivers", () => {
const c = (
v: number | null,
expected: ReturnType<typeof MAIMAI_IMPL.classDerivers>["colour"],
) => expect(MAIMAI_IMPL.classDerivers({ naiveRate: v }).colour).toBe(expected);
it("maps rate to colour", () => {
c(null, null);
c(0, "WHITE");
c(15, "RAINBOW");
c(14.5, "GOLD");
c(14, "SILVER");
c(13, "BRONZE");
c(12, "PURPLE");
c(10, "RED");
c(7, "YELLOW");
c(4, "GREEN");
c(2, "BLUE");
});
});
describe("goal formatters", () => {
const mockPB = mkMockPB("maimai", chart, scoreData);
it("criteria", () => {
expect(MAIMAI_IMPL.goalCriteriaFormatters.percent(93.14)).toBe("Get 93.14% on");
});
it("progress", () => {
const f = (
k: keyof typeof MAIMAI_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"maimai">>,
goalValue: integer,
expected: string,
) =>
expect(
MAIMAI_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.50%");
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SSS+"), "(S+)-0.50%");
f("grade", { grade: "SSS", percent: 100.2 }, GRADES.indexOf("SSS+"), "SSS+0.20%");
f("grade", { grade: "SSS+", percent: 100.78 }, GRADES.indexOf("SSS+"), "SSS+");
f("percent", { percent: 98.23 }, 1_000_000, "98.23%");
f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR");
});
it("outOf", () => {
expect(MAIMAI_IMPL.goalOutOfFormatters.percent(99.11)).toBe("99.11%");
});
});
describe("PB merging (CreatePBDoc)", () => {
beforeEach(seedMaimaiChart);
it("joins best lamp", async () => {
const { id: userId } = await seedUser({ username: "maimai_pb" });
const main = mkMockScore("maimai", chart, scoreData);
const lampSd: ScoreData<"maimai"> = {
...scoreData,
percent: 0,
lamp: "FULL COMBO",
enumIndexes: {
...scoreData.enumIndexes,
lamp: LAMPS.indexOf("FULL COMBO"),
},
};
await insertMaimaiScore({ userId, scoreId: main.scoreID, sd: scoreData, timeMs: 1000 });
await insertMaimaiScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 });
const pb = await CreatePBDoc("maimai", userId, chart, log);
expect(pb).toMatchObject({
composedFrom: [
{ name: "Best Percent" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
percent: scoreData.percent,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
});
});
});
});
@@ -1,4 +1,4 @@
import type { GPTServerImplementation } from "#game-implementations/types";
import type { GameImplementation } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
@@ -9,7 +9,7 @@ import { GetGrade, MAIMAI_GBOUNDARIES } from "tachi-common";
import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common";
export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = {
export const MAIMAI_IMPL: GameImplementation<"maimai"> = {
chartSpecificValidators: {
percent: (percent, chart) => {
if (percent < 0) {
@@ -43,8 +43,8 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = {
sessionCalcs: (arr) => ({
rate: SessionAvgBest10For("rate")(arr),
}),
profileCalcs: async (game, playtype, userID) => ({
naiveRate: await ProfileAvgBestN("rate", 30)(game, playtype, userID),
profileCalcs: async (game, userID) => ({
naiveRate: await ProfileAvgBestN("rate", 30)(game, userID),
}),
classDerivers: (ratings) => {
const rate = ratings.naiveRate;
@@ -125,7 +125,7 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = {
),
],
defaultMergeRefName: "Best Percent",
derivationRelevantFields: ["levelNum", "data.maxPercent"],
chartDataRelevantFields: ["levelNum", "data.maxPercent"],
scoreValidators: [
(s) => {
if (s.scoreData.percent > 104) {
@@ -1,171 +0,0 @@
import type { ProvidedMetrics, ScoreData } from "tachi-common";
import { log } from "#lib/log/log.js";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import MONGODB_KILL from "#services/mongo/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import ResetDBState from "#test-utils/resets";
import { TestingMaimaiDXChart } from "#test-utils/test-data";
import { MAIMAI_DX_SINGLE_CONF } from "tachi-common/config/game-support/maimai-dx";
import t from "tap";
import { MAIMAIDX_IMPL } from "./maimaidx";
const baseMetrics: ProvidedMetrics["maimaidx:Single"] = {
lamp: "CLEAR",
percent: 97.012,
};
const GRADES = MAIMAI_DX_SINGLE_CONF.derivedMetrics.grade.values;
const LAMPS = MAIMAI_DX_SINGLE_CONF.providedMetrics.lamp.values;
const scoreData: ScoreData<"maimaidx:Single"> = {
lamp: "CLEAR",
percent: 97.012,
grade: "SS",
judgements: {},
optional: { enumIndexes: {} },
enumIndexes: {
grade: GRADES.indexOf("SS"),
lamp: LAMPS.indexOf("CLEAR"),
},
};
const mockScore = mkMockScore("maimaidx", "Single", TestingMaimaiDXChart, scoreData);
const mockPB = mkMockPB("maimaidx", "Single", TestingMaimaiDXChart, scoreData);
t.test("Maimai DX Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (percent: number, expected: any) =>
t.equal(
MAIMAIDX_IMPL.scoreDeriver(
dmf(baseMetrics, { percent }) as any,
TestingMaimaiDXChart,
).grade,
expected,
`A percent of ${percent} should result in grade=${expected}.`,
);
f(0, "D");
f(50, "C");
f(60, "B");
f(70, "BB");
f(75, "BBB");
f(80, "A");
f(90, "AA");
f(94, "AAA");
f(97, "S");
f(98, "S+");
f(99, "SS");
f(99.5, "SS+");
f(100, "SSS");
f(100.5, "SSS+");
t.end();
});
t.todo("Score Calcs");
t.todo("Session Calcs");
t.todo("Profile Calcs");
t.test("Colour Deriver", (t) => {
const f = (v: number | null, expected: any) =>
t.equal(
MAIMAIDX_IMPL.classDerivers({ naiveRate: v }).colour,
expected,
`A rate of ${v} should result in ${expected}.`,
);
f(null, null);
f(0, "WHITE");
f(15000, "RAINBOW");
f(14500, "PLATINUM");
f(14000, "GOLD");
f(13000, "SILVER");
f(12000, "BRONZE");
f(10000, "PURPLE");
f(7000, "RED");
f(4000, "YELLOW");
f(2000, "GREEN");
f(1000, "BLUE");
t.end();
});
t.test("Goal Formatters", (t) => {
t.test("Criteria", (t) => {
t.equal(MAIMAIDX_IMPL.goalCriteriaFormatters.percent(93.1415), "Get 93.1415% on");
t.end();
});
t.test("Progress", (t) => {
const f = (
k: keyof typeof MAIMAIDX_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"maimaidx:Single">>,
goalValue: any,
expected: any,
) =>
t.equal(
MAIMAIDX_IMPL.goalProgressFormatters[k](
dmf(mockPB, {
scoreData: modifant,
}),
goalValue,
),
expected,
);
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.5000%");
f("percent", { percent: 98.23 }, 1_000_000, "98.2300%");
f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR");
t.end();
});
t.test("Out Of", (t) => {
t.equal(MAIMAIDX_IMPL.goalOutOfFormatters.percent(99.1123), "99.1123%");
t.end();
});
t.end();
});
t.test("PB Merging", (t) => {
t.beforeEach(ResetDBState);
t.test("Should join best lamp", async (t) => {
await MONGODB_KILL.scores.insert(mockScore);
await MONGODB_KILL.scores.insert(
dmf(mockScore, {
scoreID: "bestLamp",
scoreData: {
percent: 0,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
}),
);
t.hasStrict(await CreatePBDoc("maimaidx:Single", 1, TestingMaimaiDXChart, log), {
composedFrom: [
{ name: "Best Percent" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
percent: mockScore.scoreData.percent,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
});
t.end();
});
t.end();
});
t.end();
});
@@ -0,0 +1,226 @@
import { MAIMAIDX_IMPL } from "#game-implementations/games/maimaidx";
import { log } from "#lib/log/log";
import { CreatePBDoc } from "#lib/score-import/framework/pb/create-pb-doc";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingMaimaiDXChart, TestingMaimaiDXSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { GetGameConfig, type MongoProvidedMetrics, type ScoreData } from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
function enumMetricValues(m: {
type: string;
values?: ReadonlyArray<string>;
}): ReadonlyArray<string> {
if (m.type !== "ENUM" || m.values === undefined) {
throw new Error("expected ENUM metric with values");
}
return m.values;
}
const chart = TestingMaimaiDXChart;
const MAIMAIDX_CONF = GetGameConfig("maimaidx");
const GRADES = enumMetricValues(MAIMAIDX_CONF.derivedMetrics.grade);
const LAMPS = enumMetricValues(MAIMAIDX_CONF.providedMetrics.lamp);
const baseMetrics: MongoProvidedMetrics["maimaidx"] = {
lamp: "CLEAR",
percent: 97.012,
};
const scoreData: ScoreData<"maimaidx"> = {
lamp: "CLEAR",
percent: 97.012,
grade: "SS",
judgements: {},
optional: { enumIndexes: {} },
enumIndexes: {
grade: GRADES.indexOf("SS"),
lamp: LAMPS.indexOf("CLEAR"),
},
};
async function seedMaimaiDxChart() {
await DB.insertInto("song")
.values({
id: TestingMaimaiDXSong.id,
legacy_id: 1,
game_group: "maimaidx",
title: TestingMaimaiDXSong.title,
artist: TestingMaimaiDXSong.artist,
search_terms: TestingMaimaiDXSong.searchTerms,
alt_titles: TestingMaimaiDXSong.altTitles,
data: TestingMaimaiDXSong.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chart.chartID,
legacy_id: chart.chartID,
game: "maimaidx",
song_id: TestingMaimaiDXSong.id,
difficulty: chart.difficulty,
level: chart.level,
level_num: chart.levelNum,
is_primary: chart.isPrimary,
versions: chart.versions,
data: chart.data,
})
.execute();
}
async function insertMaimaiDxScore(opts: {
scoreId: string;
sd: ScoreData<"maimaidx">;
timeMs: number;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg("maimaidx", opts.sd);
const ts = UnixMillisecondsToISO8601(opts.timeMs);
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: chart.chartID,
game: "maimaidx",
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: ts,
time_added: ts,
highlight: false,
comment: null,
})
.execute();
}
describe("MAIMAIDX_IMPL", () => {
describe("scoreDeriver (grade)", () => {
const g = (percent: number, expected: string) =>
expect(
MAIMAIDX_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as never, chart as never)
.grade,
).toBe(expected);
it("maps percent to grade", () => {
g(0, "D");
g(50, "C");
g(60, "B");
g(70, "BB");
g(75, "BBB");
g(80, "A");
g(90, "AA");
g(94, "AAA");
g(97, "S");
g(98, "S+");
g(99, "SS");
g(99.5, "SS+");
g(100, "SSS");
g(100.5, "SSS+");
});
});
describe("classDerivers", () => {
const c = (
v: number | null,
expected: ReturnType<typeof MAIMAIDX_IMPL.classDerivers>["colour"],
) => expect(MAIMAIDX_IMPL.classDerivers({ naiveRate: v }).colour).toBe(expected);
it("maps rate to colour", () => {
c(null, null);
c(0, "WHITE");
c(15000, "RAINBOW");
c(14500, "PLATINUM");
c(14000, "GOLD");
c(13000, "SILVER");
c(12000, "BRONZE");
c(10000, "PURPLE");
c(7000, "RED");
c(4000, "YELLOW");
c(2000, "GREEN");
c(1000, "BLUE");
});
});
describe("goal formatters", () => {
const mockPB = mkMockPB("maimaidx", chart, scoreData);
it("criteria", () => {
expect(MAIMAIDX_IMPL.goalCriteriaFormatters.percent(93.1415)).toBe("Get 93.1415% on");
});
it("progress", () => {
const f = (
k: keyof typeof MAIMAIDX_IMPL.goalProgressFormatters,
modifant: Partial<ScoreData<"maimaidx">>,
goalValue: number,
expected: string,
) =>
expect(
MAIMAIDX_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.5000%");
f("percent", { percent: 98.23 }, 1_000_000, "98.2300%");
f("lamp", { lamp: "CLEAR" }, LAMPS.indexOf("CLEAR"), "CLEAR");
});
it("outOf", () => {
expect(MAIMAIDX_IMPL.goalOutOfFormatters.percent(99.1123)).toBe("99.1123%");
});
});
describe("PB merging (CreatePBDoc)", () => {
beforeEach(seedMaimaiDxChart);
it("joins best lamp", async () => {
const { id: userId } = await seedUser({ username: "maimaidx_pb" });
const main = mkMockScore("maimaidx", chart, scoreData);
const lampSd: ScoreData<"maimaidx"> = {
...scoreData,
percent: 0,
lamp: "FULL COMBO",
enumIndexes: {
...scoreData.enumIndexes,
lamp: LAMPS.indexOf("FULL COMBO"),
},
};
await insertMaimaiDxScore({
userId,
scoreId: main.scoreID,
sd: scoreData,
timeMs: 1000,
});
await insertMaimaiDxScore({ userId, scoreId: "bestLamp", sd: lampSd, timeMs: 2000 });
const pb = await CreatePBDoc("maimaidx", userId, chart, log);
expect(pb).toMatchObject({
composedFrom: [
{ name: "Best Percent" },
{ name: "Best Lamp", scoreID: "bestLamp" },
],
scoreData: {
percent: scoreData.percent,
lamp: "FULL COMBO",
enumIndexes: { lamp: LAMPS.indexOf("FULL COMBO") },
},
});
});
});
});
@@ -1,4 +1,4 @@
import type { GPTServerImplementation } from "#game-implementations/types";
import type { GameImplementation } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileSumBestN } from "#game-implementations/utils/profile-calc";
@@ -9,7 +9,7 @@ import { GetGrade, MAIMAIDX_GBOUNDARIES } from "tachi-common";
import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common";
export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
export const MAIMAIDX_IMPL: GameImplementation<"maimaidx"> = {
chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(MAIMAIDX_GBOUNDARIES, scoreData.percent),
@@ -28,8 +28,8 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
sessionCalcs: (arr) => ({
rate: SessionAvgBest10For("rate")(arr),
}),
profileCalcs: async (game, playtype, userID) => ({
naiveRate: await ProfileSumBestN("rate", 50)(game, playtype, userID),
profileCalcs: async (game, userID) => ({
naiveRate: await ProfileSumBestN("rate", 50)(game, userID),
}),
classDerivers: (ratings) => {
const rate = ratings.naiveRate;
@@ -91,7 +91,7 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
),
],
defaultMergeRefName: "Best Percent",
derivationRelevantFields: ["levelNum"],
chartDataRelevantFields: ["levelNum"],
scoreValidators: [
(s) => {
if (s.scoreData.lamp === "ALL PERFECT+" && s.scoreData.percent !== 101) {

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