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:
this branch:
## 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]);
+ });
+});