From 943740de3238c1ca9b8fda24609e74b65d7365a3 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:22:20 +0100 Subject: [PATCH] Game Stats - backward compatibility (#4623) ## Description: fixes a bug where old games under the old schema don't open correctly main.openfront.dev: image this branch: image ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/client/Api.ts | 6 +- src/core/Schemas.ts | 30 ++++++++- src/core/StatsSchemas.ts | 18 ++++++ tests/ArchivedRecordSchema.test.ts | 98 ++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 tests/ArchivedRecordSchema.test.ts diff --git a/src/client/Api.ts b/src/client/Api.ts index c352a1862..b685cb9f3 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -20,7 +20,7 @@ import { } from "../core/ApiSchemas"; import { AnalyticsRecord, - AnalyticsRecordSchema, + ArchivedAnalyticsRecordSchema, GameInfo, } from "../core/Schemas"; import { getAuthHeader, getPlayToken, logOut, userAuth } from "./Auth"; @@ -613,7 +613,9 @@ export async function fetchGameById( } const json = await res.json(); - const parsed = AnalyticsRecordSchema.safeParse(json); + // Lenient schema: archives written by older builds predate several + // schema changes (see ArchivedAnalyticsRecordSchema). + const parsed = ArchivedAnalyticsRecordSchema.safeParse(json); if (!parsed.success) { console.warn("fetchGameById: Zod validation failed", parsed.error); return false; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 076769423..8daecd410 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -21,7 +21,7 @@ import { Trios, UnitType, } from "./game/Game"; -import { PlayerStatsSchema } from "./StatsSchemas"; +import { ArchivedPlayerStatsSchema, PlayerStatsSchema } from "./StatsSchemas"; import { flattenedEmojiTable } from "./Util"; export type GameID = string; @@ -928,6 +928,34 @@ export const AnalyticsRecordSchema = PartialAnalyticsRecordSchema.extend({ export type AnalyticsRecord = z.infer; +// Lenient variant for *reading* archived records. Older builds wrote records +// under earlier schemas (username rules tightened since, clanTag and nations +// added later, conquests became an array) while the `version` literal never +// changed, so strict parsing rejects them wholesale. Records are trusted +// server output, not untrusted input — tolerate the historical shapes. +// Inferred types are identical to the strict schemas', so parsed results are +// still AnalyticsRecord. Not for replays: those require an exact gitCommit +// match anyway (see JoinLobbyModal.checkArchivedGame). +const ArchivedPlayerRecordSchema = PlayerRecordSchema.extend({ + // Validated at join time under the rules of its era; the loosest era was + // SafeString (max 1000, emoji allowed, no min), so only cap length. + username: z.string().max(1000), + clanTag: ClanTagSchema.catch(null).default(null), // predates clan tags + stats: ArchivedPlayerStatsSchema, // scalar conquests +}); + +export const ArchivedAnalyticsRecordSchema = AnalyticsRecordSchema.extend({ + info: GameEndInfoSchema.extend({ + config: GameConfigSchema.extend({ + // predates configurable nation count + nations: GameConfigSchema.shape.nations + .catch("default") + .default("default"), + }), + players: ArchivedPlayerRecordSchema.array(), + }), +}); + export const GameRecordSchema = AnalyticsRecordSchema.extend({ turns: TurnSchema.array(), }); diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts index dce68e5f3..1f5c9722b 100644 --- a/src/core/StatsSchemas.ts +++ b/src/core/StatsSchemas.ts @@ -127,3 +127,21 @@ export const PlayerStatsSchema = z }) .optional(); export type PlayerStats = z.infer; + +// Reading archived records: `conquests` was a single value +// (BigIntStringSchema) before it was split into per-player-type buckets, so +// wrap old scalars into a one-element array (index 0 = human) on read. +export const ArchivedPlayerStatsSchema = z.preprocess((val) => { + if ( + val !== null && + typeof val === "object" && + !Array.isArray(val) && + "conquests" in val + ) { + const { conquests } = val as { conquests: unknown }; + if (conquests !== undefined && !Array.isArray(conquests)) { + return { ...(val as Record), conquests: [conquests] }; + } + } + return val; +}, PlayerStatsSchema); diff --git a/tests/ArchivedRecordSchema.test.ts b/tests/ArchivedRecordSchema.test.ts new file mode 100644 index 000000000..ceb351459 --- /dev/null +++ b/tests/ArchivedRecordSchema.test.ts @@ -0,0 +1,98 @@ +import { + AnalyticsRecordSchema, + ArchivedAnalyticsRecordSchema, +} from "../src/core/Schemas"; + +// A record as an old build would have written it: no `nations` in the config, +// no `clanTag` on players, a username that fails today's tighter regex, and a +// scalar `conquests` stat (it became an array later). The `version` literal +// never changed across those schema changes, so only the lenient archived +// schema can read it back. +function oldRecord() { + return { + version: "v0.0.2", + gitCommit: "0123456789abcdef0123456789abcdef01234567", + subdomain: "eu1", + domain: "openfront.io", + info: { + gameID: "abCD1234", + lobbyCreatedAt: 1700000000000, + start: 1700000001000, + end: 1700000002000, + duration: 1000, + num_turns: 100, + lobbyFillTime: 5000, + winner: ["player", "abCD1234"], + config: { + // no `nations` — predates configurable nation count + gameMap: "Africa", + difficulty: "Medium", + donateGold: true, + donateTroops: true, + gameType: "Public", + gameMode: "Free For All", + gameMapSize: "Normal", + bots: 400, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + }, + players: [ + { + clientID: "abCD1234", + username: "[BR] køva!", // fails today's UsernameSchema regex + // no `clanTag` — predates clan tags + persistentID: null, + stats: { + conquests: "3", // scalar, pre-array + attacks: ["100"], + }, + }, + ], + }, + }; +} + +describe("ArchivedAnalyticsRecordSchema", () => { + test("strict schema rejects old record (why the archived one exists)", () => { + expect(AnalyticsRecordSchema.safeParse(oldRecord()).success).toBe(false); + }); + + test("parses old record", () => { + const result = ArchivedAnalyticsRecordSchema.safeParse(oldRecord()); + expect(result.success).toBe(true); + if (!result.success) return; + const player = result.data.info.players[0]; + expect(player.username).toBe("[BR] køva!"); + expect(player.clanTag).toBeNull(); + expect(player.stats?.conquests).toEqual([3n]); + expect(result.data.info.config.nations).toBe("default"); + }); + + test("parses current-format record unchanged", () => { + const base = oldRecord(); + const record = { + ...base, + info: { + ...base.info, + config: { ...base.info.config, nations: "disabled" }, + players: [ + { + ...base.info.players[0], + username: "NormalName", + clanTag: "ABC", + stats: { conquests: ["1", "2", "0"] }, + }, + ], + }, + }; + + const result = ArchivedAnalyticsRecordSchema.safeParse(record); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.info.config.nations).toBe("disabled"); + expect(result.data.info.players[0].clanTag).toBe("ABC"); + expect(result.data.info.players[0].stats?.conquests).toEqual([1n, 2n, 0n]); + }); +});