Game Stats - backward compatibility (#4623)

## Description:

fixes a bug where old games under the old schema don't open correctly

main.openfront.dev:
<img width="1043" height="405" alt="image"
src="https://github.com/user-attachments/assets/d94811b9-ecfe-4243-9afb-5b25df4366f8"
/>


this branch:
<img width="1034" height="942" alt="image"
src="https://github.com/user-attachments/assets/8dcf07e6-2934-4a85-85a7-3f51a493c504"
/>


## 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
This commit is contained in:
Ryan
2026-07-16 15:22:20 -07:00
committed by GitHub
parent d59b5cdfd7
commit 943740de32
4 changed files with 149 additions and 3 deletions
+4 -2
View File
@@ -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;
+29 -1
View File
@@ -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<typeof AnalyticsRecordSchema>;
// 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(),
});
+18
View File
@@ -127,3 +127,21 @@ export const PlayerStatsSchema = z
})
.optional();
export type PlayerStats = z.infer<typeof PlayerStatsSchema>;
// 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<string, unknown>), conquests: [conquests] };
}
}
return val;
}, PlayerStatsSchema);