From fd0fbfab9efeb856282af010deae93113a403500 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 9 Sep 2025 14:37:06 -0700 Subject: [PATCH] Fix archive (#2035) ## Description: Describe the PR. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/ClientGameRunner.ts | 5 ++--- src/client/LocalPersistantStats.ts | 6 +++--- src/client/LocalServer.ts | 13 ++++++++----- src/core/Schemas.ts | 18 ++++++++++++++++-- src/core/Util.ts | 18 +++++------------- src/server/Archive.ts | 28 ++++++++++++++++++++++++++-- src/server/GameServer.ts | 27 ++++++++++++++------------- src/server/Worker.ts | 15 +++++++++------ 8 files changed, 83 insertions(+), 47 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 68ebb2077..e4eb7e162 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -8,7 +8,7 @@ import { PlayerRecord, ServerMessage, } from "../core/Schemas"; -import { createGameRecord } from "../core/Util"; +import { createPartialGameRecord } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; import { PlayerActions, UnitType } from "../core/game/Game"; @@ -221,7 +221,7 @@ export class ClientGameRunner { if (this.lobby.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } - const record = createGameRecord( + const record = createPartialGameRecord( this.lobby.gameStartInfo.gameID, this.lobby.gameStartInfo.config, players, @@ -230,7 +230,6 @@ export class ClientGameRunner { startTime(), Date.now(), update.winner, - this.lobby.serverConfig, ); endGame(record); } diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index c6dd1df5a..f1e78b924 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -1,11 +1,11 @@ -import { GameConfig, GameID, GameRecord } from "../core/Schemas"; +import { GameConfig, GameID, PartialGameRecord } from "../core/Schemas"; import { replacer } from "../core/Util"; export interface LocalStatsData { [key: GameID]: { lobby: Partial; // Only once the game is over - gameRecord?: GameRecord; + gameRecord?: PartialGameRecord; }; } @@ -41,7 +41,7 @@ export function startTime() { return _startTime; } -export function endGame(gameRecord: GameRecord) { +export function endGame(gameRecord: PartialGameRecord) { if (localStorage === undefined) { return; } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 5f73f9c05..b1aa7a100 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -4,14 +4,18 @@ import { AllPlayersStats, ClientMessage, ClientSendWinnerMessage, - GameRecordSchema, Intent, + PartialGameRecordSchema, PlayerRecord, ServerMessage, ServerStartGameMessage, Turn, } from "../core/Schemas"; -import { createGameRecord, decompressGameRecord, replacer } from "../core/Util"; +import { + createPartialGameRecord, + decompressGameRecord, + replacer, +} from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; import { getPersistentID } from "./Main"; @@ -188,7 +192,7 @@ export class LocalServer { if (this.lobbyConfig.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } - const record = createGameRecord( + const record = createPartialGameRecord( this.lobbyConfig.gameStartInfo.gameID, this.lobbyConfig.gameStartInfo.config, players, @@ -196,10 +200,9 @@ export class LocalServer { this.startedAt, Date.now(), this.winner?.winner, - this.lobbyConfig.serverConfig, ); - const result = GameRecordSchema.safeParse(record); + const result = PartialGameRecordSchema.safeParse(record); if (!result.success) { const error = z.prettifyError(result.error); console.error("Error parsing game record", error); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c1f1ff79f..abbaa1792 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -492,7 +492,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ // export const PlayerRecordSchema = PlayerSchema.extend({ - persistentID: PersistentIdSchema, // WARNING: PII + persistentID: PersistentIdSchema.nullable(), // WARNING: PII stats: PlayerStatsSchema, }); export type PlayerRecord = z.infer; @@ -512,16 +512,30 @@ const GitCommitSchema = z .regex(/^[0-9a-fA-F]{40}$/) .or(z.literal("DEV")); -export const AnalyticsRecordSchema = z.object({ +export const PartialAnalyticsRecordSchema = z.object({ info: GameEndInfoSchema, version: z.literal("v0.0.2"), +}); +export type ClientAnalyticsRecord = z.infer< + typeof PartialAnalyticsRecordSchema +>; + +export const AnalyticsRecordSchema = PartialAnalyticsRecordSchema.extend({ gitCommit: GitCommitSchema, subdomain: z.string(), domain: z.string(), }); + export type AnalyticsRecord = z.infer; export const GameRecordSchema = AnalyticsRecordSchema.extend({ turns: TurnSchema.array(), }); + +export const PartialGameRecordSchema = PartialAnalyticsRecordSchema.extend({ + turns: TurnSchema.array(), +}); + +export type PartialGameRecord = z.infer; + export type GameRecord = z.infer; diff --git a/src/core/Util.ts b/src/core/Util.ts index 0ded7f5de..8d30c1a23 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -6,12 +6,12 @@ import { GameConfig, GameID, GameRecord, + PartialGameRecord, PlayerRecord, Turn, Winner, } from "./Schemas"; -import { ServerConfig } from "./configuration/Config"; import { BOT_NAME_PREFIXES, BOT_NAME_SUFFIXES, @@ -150,7 +150,7 @@ export function onlyImages(html: string) { }); } -export function createGameRecord( +export function createPartialGameRecord( gameID: GameID, config: GameConfig, // username does not need to be set. @@ -159,18 +159,13 @@ export function createGameRecord( start: number, end: number, winner: Winner, - serverConfig: ServerConfig, -): GameRecord { +): PartialGameRecord { const duration = Math.floor((end - start) / 1000); - const version = "v0.0.2"; - const gitCommit = serverConfig.gitCommit(); - const subdomain = serverConfig.subdomain(); - const domain = serverConfig.domain(); const num_turns = allTurns.length; const turns = allTurns.filter( (t) => t.intents.length !== 0 || t.hash !== undefined, ); - const record: GameRecord = { + const record: PartialGameRecord = { info: { gameID, config, @@ -181,10 +176,7 @@ export function createGameRecord( num_turns, winner, }, - version, - gitCommit, - subdomain, - domain, + version: "v0.0.2", turns, }; return record; diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 7a29f0f3c..4361014fe 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,5 +1,12 @@ +import z from "zod"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { GameID, GameRecord, GameRecordSchema, ID } from "../core/Schemas"; +import { + GameID, + GameRecord, + GameRecordSchema, + ID, + PartialGameRecord, +} from "../core/Schemas"; import { logger } from "./Logger"; const config = getServerConfigFromServer(); @@ -8,7 +15,13 @@ const log = logger.child({ component: "Archive" }); export async function archive(gameRecord: GameRecord) { try { - gameRecord.gitCommit = config.gitCommit(); + const parsed = GameRecordSchema.safeParse(gameRecord); + if (!parsed.success) { + log.error(`invalid game record: ${z.prettifyError(parsed.error)}`, { + gameID: gameRecord.info.gameID, + }); + return; + } const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`; const response = await fetch(url, { method: "POST", @@ -62,3 +75,14 @@ export async function readGameRecord( return null; } } + +export function finalizeGameRecord( + clientRecord: PartialGameRecord, +): GameRecord { + return { + ...clientRecord, + gitCommit: config.gitCommit(), + subdomain: config.subdomain(), + domain: config.domain(), + }; +} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index c4d917e14..edd1abdcd 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -2,6 +2,8 @@ import ipAnonymize from "ip-anonymize"; import { Logger } from "winston"; import WebSocket from "ws"; import { z } from "zod"; +import { GameEnv, ServerConfig } from "../core/configuration/Config"; +import { GameType } from "../core/game/Game"; import { ClientID, ClientMessageSchema, @@ -19,10 +21,8 @@ import { ServerTurnMessage, Turn, } from "../core/Schemas"; -import { createGameRecord } from "../core/Util"; -import { GameEnv, ServerConfig } from "../core/configuration/Config"; -import { GameType } from "../core/game/Game"; -import { archive } from "./Archive"; +import { createPartialGameRecord } from "../core/Util"; +import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; export enum GamePhase { Lobby = "LOBBY", @@ -680,15 +680,16 @@ export class GameServer { }, ); archive( - createGameRecord( - this.id, - this.gameStartInfo.config, - playerRecords, - this.turns, - this._startTime ?? 0, - Date.now(), - this.winner?.winner, - this.config, + finalizeGameRecord( + createPartialGameRecord( + this.id, + this.gameStartInfo.config, + playerRecords, + this.turns, + this._startTime ?? 0, + Date.now(), + this.winner?.winner, + ), ), ); } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 6bcf46d99..54bf28cd3 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -12,13 +12,12 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, - GameRecord, - GameRecordSchema, ID, + PartialGameRecordSchema, ServerErrorMessage, } from "../core/Schemas"; import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; -import { archive, readGameRecord } from "./Archive"; +import { archive, finalizeGameRecord, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { getUserMe, verifyClientToken } from "./jwt"; @@ -252,13 +251,13 @@ export async function startWorker() { try { const record = req.body; - const result = GameRecordSchema.safeParse(record); + const result = PartialGameRecordSchema.safeParse(record); if (!result.success) { const error = z.prettifyError(result.error); log.info(error); return res.status(400).json({ error }); } - const gameRecord: GameRecord = result.data; + const gameRecord = result.data; if (gameRecord.info.config.gameType !== GameType.Singleplayer) { log.warn( @@ -277,7 +276,11 @@ export async function startWorker() { return res.status(400).json({ error: "Invalid request" }); } - archive(gameRecord); + log.info("archiving singleplayer game", { + gameID: gameRecord.info.gameID, + }); + + archive(finalizeGameRecord(gameRecord)); res.json({ success: true, });