feat: i'm worried about this one

This commit is contained in:
zk
2026-03-29 02:14:29 +01:00
parent 5ec033a93e
commit e29f94d7cd
6 changed files with 249 additions and 96 deletions
@@ -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}).`);
}
+101
View File
@@ -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);
});
});
+70 -26
View File
@@ -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<T>(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<MONGO_UserGameStats | null> {
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({
@@ -145,7 +145,7 @@ export async function FindBMSChartOnHash(hash: string) {
return chartJoinedToDocument(
row as { song_legacy_id: number } & ChartJoinedRow,
) as Promise<MONGO_ChartDocument<"bms:7K" | "bms:14K"> | null>;
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
}
/**
+41 -1
View File
@@ -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", () => {
+7 -38
View File
@@ -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