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
This commit is contained in:
evanpelle
2025-09-09 14:37:06 -07:00
committed by GitHub
parent defb6bb1d4
commit fd0fbfab9e
8 changed files with 83 additions and 47 deletions
+2 -3
View File
@@ -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);
}
+3 -3
View File
@@ -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<GameConfig>;
// 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;
}
+8 -5
View File
@@ -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);
+16 -2
View File
@@ -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<typeof PlayerRecordSchema>;
@@ -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<typeof AnalyticsRecordSchema>;
export const GameRecordSchema = AnalyticsRecordSchema.extend({
turns: TurnSchema.array(),
});
export const PartialGameRecordSchema = PartialAnalyticsRecordSchema.extend({
turns: TurnSchema.array(),
});
export type PartialGameRecord = z.infer<typeof PartialGameRecordSchema>;
export type GameRecord = z.infer<typeof GameRecordSchema>;
+5 -13
View File
@@ -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;
+26 -2
View File
@@ -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(),
};
}
+14 -13
View File
@@ -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,
),
),
);
}
+9 -6
View File
@@ -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,
});