From e29f94d7cd6403d3136ca5dedca051d064e9fda9 Mon Sep 17 00:00:00 2001 From: zk Date: Sun, 29 Mar 2026 02:14:29 +0100 Subject: [PATCH] feat: i'm worried about this one --- .../lib/game-settings/create-game-settings.ts | 59 +++++----- typescript/server/src/utils/class.test.ts | 101 ++++++++++++++++++ typescript/server/src/utils/class.ts | 96 ++++++++++++----- typescript/server/src/utils/queries/charts.ts | 2 +- typescript/server/src/utils/user.test.ts | 42 +++++++- typescript/server/src/utils/user.ts | 45 ++------ 6 files changed, 249 insertions(+), 96 deletions(-) create mode 100644 typescript/server/src/utils/class.test.ts diff --git a/typescript/server/src/lib/game-settings/create-game-settings.ts b/typescript/server/src/lib/game-settings/create-game-settings.ts index c45f2efda..276c65ac4 100644 --- a/typescript/server/src/lib/game-settings/create-game-settings.ts +++ b/typescript/server/src/lib/game-settings/create-game-settings.ts @@ -1,17 +1,20 @@ import type { GameGroup, integer, Playtype } from "tachi-common"; +import { GamePTToV3 } from "tachi-common"; import { log } from "#lib/log/log"; -import MONGODB_KILL from "#services/mongo/db"; +import DB from "#services/pg/db"; /** * Create GameSettings for a UGPT (which contains their preferences). */ export async function CreateGameSettings(userID: integer, game: GameGroup, playtype: Playtype) { - const exists = await MONGODB_KILL["game-settings"].findOne({ - userID, - game, - playtype, - }); + const v3Game = GamePTToV3(game, playtype); + + const exists = await DB.selectFrom("game_settings") + .select("user_id") + .where("user_id", "=", userID) + .where("game", "=", v3Game) + .executeTakeFirst(); if (exists) { log.error( @@ -23,31 +26,27 @@ export async function CreateGameSettings(userID: integer, game: GameGroup, playt ); } - let gameSpecific = {}; + const gameSpecific = + game === "iidx" + ? { + display2DXTra: false, + bpiTarget: 0, + } + : {}; - if (game === "iidx") { - gameSpecific = { - display2DXTra: false, - bpiTarget: 0, - }; - } - - await MONGODB_KILL["game-settings"].insert({ - userID, - game, - playtype, - preferences: { - preferredProfileAlg: null, - preferredSessionAlg: null, - preferredScoreAlg: null, - preferredDefaultEnum: null, - defaultTable: null, - preferredRanking: null, - stats: [], - gameSpecific, - }, - rivals: [], - }); + await DB.insertInto("game_settings") + .values({ + data: JSON.stringify(gameSpecific), + game: v3Game, + pf_default_table: null, + pf_preferred_default_enum: null, + pf_preferred_profile_alg: null, + pf_preferred_ranking: null, + pf_preferred_score_alg: null, + pf_preferred_session_alg: null, + user_id: userID, + }) + .execute(); log.info(`Created game settings for ${userID} (${game} ${playtype}).`); } diff --git a/typescript/server/src/utils/class.test.ts b/typescript/server/src/utils/class.test.ts new file mode 100644 index 000000000..442f2591c --- /dev/null +++ b/typescript/server/src/utils/class.test.ts @@ -0,0 +1,101 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { describe, expect, it } from "vitest"; + +import { UpdateClassIfGreater } from "./class"; + +function asClassesJson(v: unknown): { dan?: string } { + if (typeof v === "string") { + return JSON.parse(v) as { dan?: string }; + } + + return v as { dan?: string }; +} + +describe("UpdateClassIfGreater (Postgres)", () => { + it("returns false when new class is not greater", async () => { + const { id } = await seedUser({ username: `cls_down_${Date.now()}` }); + + await DB.insertInto("game_profile") + .values({ + classes: JSON.stringify({ dan: "DAN_1" }), + game: "iidx-sp", + ratings: JSON.stringify({}), + user_id: id, + }) + .execute(); + + const result = await UpdateClassIfGreater(id, "iidx", "SP", "dan", "KYU_7"); + expect(result).toBe(false); + + const row = await DB.selectFrom("game_profile") + .select("classes") + .where("user_id", "=", id) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + + expect(asClassesJson(row.classes).dan).toBe("DAN_1"); + }); + + it("returns true and updates when new class is greater", async () => { + const { id } = await seedUser({ username: `cls_up_${Date.now()}` }); + + await DB.insertInto("game_profile") + .values({ + classes: JSON.stringify({ dan: "KYU_7" }), + game: "iidx-sp", + ratings: JSON.stringify({}), + user_id: id, + }) + .execute(); + + const result = await UpdateClassIfGreater(id, "iidx", "SP", "dan", "DAN_1"); + expect(result).toBe(true); + + const row = await DB.selectFrom("game_profile") + .select("classes") + .where("user_id", "=", id) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + + expect(asClassesJson(row.classes).dan).toBe("DAN_1"); + + const ach = await DB.selectFrom("class_achievement") + .select(["class_prev_value", "class_value"]) + .where("user_id", "=", id) + .where("game", "=", "iidx-sp") + .orderBy("timestamp", "desc") + .executeTakeFirstOrThrow(); + + expect(ach.class_value).toBe("DAN_1"); + expect(ach.class_prev_value).toBe("KYU_7"); + }); + + it("creates game_profile and game_settings when none exist (first class)", async () => { + const { id } = await seedUser({ username: `cls_new_${Date.now()}` }); + + const result = await UpdateClassIfGreater(id, "iidx", "SP", "dan", "DAN_1"); + expect(result).toBe(null); + + const profile = await DB.selectFrom("game_profile") + .selectAll() + .where("user_id", "=", id) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + + expect(asClassesJson(profile.classes).dan).toBe("DAN_1"); + + const settings = await DB.selectFrom("game_settings") + .selectAll() + .where("user_id", "=", id) + .where("game", "=", "iidx-sp") + .executeTakeFirstOrThrow(); + + const data = + typeof settings.data === "string" + ? (JSON.parse(settings.data) as { bpiTarget?: number; display2DXTra?: boolean }) + : (settings.data as { bpiTarget?: number; display2DXTra?: boolean }); + expect(data.display2DXTra).toBe(false); + expect(data.bpiTarget).toBe(0); + }); +}); diff --git a/typescript/server/src/utils/class.ts b/typescript/server/src/utils/class.ts index a410b8bf4..4d47c086b 100644 --- a/typescript/server/src/utils/class.ts +++ b/typescript/server/src/utils/class.ts @@ -1,7 +1,7 @@ import { CreateGameSettings } from "#lib/game-settings/create-game-settings"; import { log } from "#lib/log/log"; import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; -import MONGODB_KILL from "#services/mongo/db"; +import DB from "#services/pg/db"; import { type Classes, type GameGroup, @@ -11,8 +11,42 @@ import { type integer, type MONGO_UserGameStats, type Playtype, + GamePTToV3, } from "tachi-common"; +function parseProfileJson(v: unknown): T { + if (typeof v === "string") { + return JSON.parse(v) as T; + } + + return v as T; +} + +async function loadUserGameStats( + userID: integer, + game: GameGroup, + playtype: Playtype, +): Promise { + const v3Game = GamePTToV3(game, playtype); + const row = await DB.selectFrom("game_profile") + .select(["ratings", "classes"]) + .where("user_id", "=", userID) + .where("game", "=", v3Game) + .executeTakeFirst(); + + if (!row) { + return null; + } + + return { + userID, + game, + playtype, + ratings: parseProfileJson(row.ratings), + classes: parseProfileJson(row.classes), + }; +} + /** * Returns the provided class if it is greater than the one in userGameStats * @returns The provided class if it is greater, NULL if there is nothing @@ -100,8 +134,9 @@ export async function UpdateClassIfGreater( classVal: string, ) { const gptString = GetGPTString(game, playtype); + const v3Game = GamePTToV3(game, playtype); - const userGameStats = await MONGODB_KILL["game-stats"].findOne({ userID, game, playtype }); + const userGameStats = await loadUserGameStats(userID, game, playtype); const isGreater = ReturnClassIfGreater(gptString, classSet, classVal, userGameStats); if (isGreater === false) { @@ -109,37 +144,46 @@ export async function UpdateClassIfGreater( } if (userGameStats) { - await MONGODB_KILL["game-stats"].update( - { userID, game, playtype }, - { $set: { [`classes.${classSet}`]: classVal } }, - ); + const nextClasses = { + ...userGameStats.classes, + [classSet]: classVal, + }; + + await DB.updateTable("game_profile") + .set({ classes: JSON.stringify(nextClasses) }) + .where("user_id", "=", userID) + .where("game", "=", v3Game) + .execute(); } else { - // insert new game stats for this user - this is an awkward place - // to call this - maybe we should call it elsewhere. - await MONGODB_KILL["game-stats"].insert({ - userID, - game, - playtype, - ratings: {}, - classes: { - [classSet]: classVal, - }, - }); + await DB.insertInto("game_profile") + .values({ + classes: JSON.stringify({ [classSet]: classVal }), + game: v3Game, + ratings: JSON.stringify({}), + user_id: userID, + }) + .execute(); log.info(`Created new player gamestats for ${userID} (${game} ${playtype})`); await CreateGameSettings(userID, game, playtype); } - await MONGODB_KILL["class-achievements"].insert({ - game, - playtype, - userID, - classOldValue: isGreater === null ? null : userGameStats!.classes[classSet]!, - classSet, - classValue: classVal, - timeAchieved: Date.now(), - }); + const prevForAchievement = + isGreater === null + ? "" + : String(userGameStats?.classes[classSet as keyof typeof userGameStats.classes] ?? ""); + + await DB.insertInto("class_achievement") + .values({ + class_prev_value: prevForAchievement, + class_set: classSet, + class_value: classVal, + game: v3Game, + timestamp: new Date().toISOString(), + user_id: userID, + }) + .execute(); if (isGreater === null) { void EmitWebhookEvent({ diff --git a/typescript/server/src/utils/queries/charts.ts b/typescript/server/src/utils/queries/charts.ts index 9c5c82497..79cfe9d15 100644 --- a/typescript/server/src/utils/queries/charts.ts +++ b/typescript/server/src/utils/queries/charts.ts @@ -145,7 +145,7 @@ export async function FindBMSChartOnHash(hash: string) { return chartJoinedToDocument( row as { song_legacy_id: number } & ChartJoinedRow, - ) as Promise | null>; + ) as MONGO_ChartDocument<"bms:7K" | "bms:14K">; } /** diff --git a/typescript/server/src/utils/user.test.ts b/typescript/server/src/utils/user.test.ts index c377eea9f..5167fdd1f 100644 --- a/typescript/server/src/utils/user.test.ts +++ b/typescript/server/src/utils/user.test.ts @@ -2,7 +2,12 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { describe, expect, it } from "vitest"; -import { GetAllRankings, GetUsersRankingAndOutOf } from "./user"; +import { + GetAllRankings, + GetAllUserRivals, + GetUsersRanking, + GetUsersRankingAndOutOf, +} from "./user"; // ─── helpers ───────────────────────────────────────────────────────────────── @@ -155,6 +160,41 @@ describe("GetUsersRankingAndOutOf", () => { }); }); +describe("GetUsersRanking", () => { + it("matches GetUsersRankingAndOutOf().ranking", async () => { + const { id } = await seedUser({ username: `gr_${Date.now()}` }); + await seedGameStats(id, 12); + + const stats = makeStats(id, 12); + const r = await GetUsersRanking(stats); + const full = await GetUsersRankingAndOutOf(stats); + + expect(r).toBe(full.ranking); + }); +}); + +describe("GetAllUserRivals", () => { + it("returns distinct rival ids from game_rival", async () => { + const t = Date.now(); + const { id: me } = await seedUser({ username: `me_${t}` }); + const { id: rivalA } = await seedUser({ username: `rv_a_${t}` }); + const { id: rivalB } = await seedUser({ username: `rv_b_${t}` }); + + await DB.insertInto("game_rival") + .values({ game: "iidx-sp", rival: rivalA, user_id: me }) + .execute(); + await DB.insertInto("game_rival") + .values({ game: "sdvx", rival: rivalB, user_id: me }) + .execute(); + await DB.insertInto("game_rival") + .values({ game: "sdvx", rival: rivalA, user_id: me }) + .execute(); + + const rivals = await GetAllUserRivals(me); + expect(rivals.sort((a, b) => a - b)).toEqual([rivalA, rivalB].sort((a, b) => a - b)); + }); +}); + // ─── GetAllRankings ─────────────────────────────────────────────────────────── describe("GetAllRankings", () => { diff --git a/typescript/server/src/utils/user.ts b/typescript/server/src/utils/user.ts index 8bfb62d9c..72d727791 100644 --- a/typescript/server/src/utils/user.ts +++ b/typescript/server/src/utils/user.ts @@ -2,7 +2,6 @@ import { ONE_DAY } from "#lib/constants/time"; import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; import { SELECT_USER_SETTINGS, ToUserSettingsDocument } from "#lib/db-formats/user-settings"; import { log } from "#lib/log/log"; -import MONGODB_KILL from "#services/mongo/db"; import DB from "#services/pg/db"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Kysely, sql, type Transaction } from "kysely"; @@ -161,37 +160,8 @@ export function FormatUserDoc(userdoc: MONGO_UserDocument) { } export async function GetUsersRanking(stats: MONGO_UserGameStats) { - const gptConfig = GetGamePTConfig(stats.game, stats.playtype); - - const aggRes: [{ _id: null; ranking: integer }] = await MONGODB_KILL["game-stats"].aggregate([ - { - $match: { - game: stats.game, - playtype: stats.playtype, - }, - }, - { - $group: { - _id: null, - ranking: { - $sum: { - $cond: { - if: { - $gt: [ - `$ratings.${gptConfig.defaultProfileRatingAlg}`, - stats.ratings[gptConfig.defaultProfileRatingAlg], - ], - }, - then: 1, - else: 0, - }, - }, - }, - }, - }, - ]); - - return aggRes[0].ranking + 1; + const { ranking } = await GetUsersRankingAndOutOf(stats); + return ranking; } export function GetUGPTPlaycount(userID: integer, game: GameGroup, playtype: Playtype) { @@ -309,13 +279,12 @@ export async function GetUserPlayedGPTs(userID: integer) { } export async function GetAllUserRivals(userID: integer) { - const rivals = ( - await MONGODB_KILL["game-settings"].find({ userID }, { projection: { rivals: 1 } }) - ) - .map((e) => e.rivals) - .flat(); + const rows = await DB.selectFrom("game_rival") + .select("rival") + .where("user_id", "=", userID) + .execute(); - return rivals; + return [...new Set(rows.map((r) => r.rival))]; } const USERNAME_CHANGE_COOLDOWN = ONE_DAY * 180; // 6 months