From c4ba9f836db5440a7385bf2801416ffc9081fca6 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 3 Aug 2026 14:05:00 -0700 Subject: [PATCH] fix(replay): re-apply server wire blanking of clanTag/friends when replaying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live clients never simulate with players' real clanTag/friends when the host sets disableClanTags or anonymizeNames: the server blanks them identically for every client (GameServer.start() wireGameStartInfo / startInfoFor) because both feed deterministic team assignment. The archived record intentionally keeps the real values for analytics, and replays rebuild GameStartInfo straight from record.info — so a team game with either setting replays with different team assignment inputs than the live game used, diverges at the first checkpoint, and fires desync errors for the whole replay. Add toWireGameStartInfo() in core, mirroring the server's blanking rules (disableClanTags -> clanTag null; anonymizeNames -> clanTag null and friends dropped), and apply it on the two replay paths: the client (Main.ts, record -> lobbyConfig.gameStartInfo) and the headless harness (tests/replay/ReplayGame.ts). Singleplayer records are exempt — those games simulate and archive without a server, so their real values ARE the simulation inputs. Co-Authored-By: Claude Fable 5 --- src/client/Main.ts | 9 +++- src/core/Util.ts | 34 +++++++++++- tests/ToWireGameStartInfo.test.ts | 88 +++++++++++++++++++++++++++++++ tests/replay/ReplayGame.ts | 11 ++-- 4 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 tests/ToWireGameStartInfo.test.ts diff --git a/src/client/Main.ts b/src/client/Main.ts index 1f5fb6001a..89e6477011 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -10,6 +10,7 @@ import { GameStartInfo, PublicGameInfo, } from "../core/Schemas"; +import { toWireGameStartInfo } from "../core/Util"; import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; @@ -855,7 +856,13 @@ class Client { playerClanTag: this.usernameInput?.getClanTag() ?? null, clanTagCheck: this.usernameInput?.getClanCheck(), playerRole, - gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, + gameStartInfo: + lobby.gameStartInfo ?? + // Replays simulate from the archived record; re-apply the server's + // wire blanking or team games desync (see toWireGameStartInfo). + (lobby.gameRecord + ? toWireGameStartInfo(lobby.gameRecord.info) + : undefined), gameRecord: lobby.gameRecord, }); diff --git a/src/core/Util.ts b/src/core/Util.ts index 79dc4fd269..789b1f64ee 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -1,12 +1,13 @@ import DOMPurify from "dompurify"; import { customAlphabet } from "nanoid"; -import { Cell, PlayerType, Unit } from "./game/Game"; +import { Cell, GameType, PlayerType, Unit } from "./game/Game"; import { GameMap, TileRef } from "./game/GameMap"; import { TileSet } from "./game/TileSet"; import { GameConfig, GameID, GameRecord, + GameStartInfo, PartialGameRecord, PlayerRecord, Tribe, @@ -252,6 +253,37 @@ export function onlyImages(html: string) { }); } +// Replays rebuild GameStartInfo from the archived record, which keeps +// players' real clanTag and friends (analytics reads them). Live clients +// never simulated with those: the server blanks clanTag when clan tags are +// disabled, and clanTag + friends when names are anonymized — identically +// for every client, because both feed deterministic team assignment +// (TeamAssignment.ts). Mirrors GameServer.start() (wireGameStartInfo) and +// startInfoFor(); replays must apply the same blanking before simulating, +// or team games with either setting diverge from the recorded hashes. +// Singleplayer records were simulated (and archived) with the real values — +// no server, no blanking — so they replay as-is. +export function toWireGameStartInfo(info: GameStartInfo): GameStartInfo { + const config = info.config; + if (config.gameType === GameType.Singleplayer) { + return info; + } + const blankClanTags = + (config.disableClanTags ?? false) || (config.anonymizeNames ?? false); + const blankFriends = config.anonymizeNames ?? false; + if (!blankClanTags && !blankFriends) { + return info; + } + return { + ...info, + players: info.players.map((p) => ({ + ...p, + clanTag: blankClanTags ? null : p.clanTag, + friends: blankFriends ? undefined : p.friends, + })), + }; +} + export function createPartialGameRecord( gameID: GameID, config: GameConfig, diff --git a/tests/ToWireGameStartInfo.test.ts b/tests/ToWireGameStartInfo.test.ts new file mode 100644 index 0000000000..71f702a0ea --- /dev/null +++ b/tests/ToWireGameStartInfo.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { GameType } from "../src/core/game/Game"; +import { GameStartInfo } from "../src/core/Schemas"; +import { toWireGameStartInfo } from "../src/core/Util"; + +function startInfo(config: Record): GameStartInfo { + return { + gameID: "test-game", + lobbyCreatedAt: 0, + config, + players: [ + { + clientID: "clientAAA", + username: "alice", + clanTag: "AA", + friends: ["clientBBB"], + teamIndex: 0, + }, + { + clientID: "clientBBB", + username: "bob", + clanTag: null, + friends: [], + teamIndex: 1, + }, + ], + } as GameStartInfo; +} + +describe("toWireGameStartInfo", () => { + it("nulls every clanTag when clan tags are disabled, keeping friends", () => { + const info = startInfo({ + gameType: GameType.Public, + disableClanTags: true, + }); + + const wire = toWireGameStartInfo(info); + + expect(wire.players.map((p) => p.clanTag)).toEqual([null, null]); + expect(wire.players[0].friends).toEqual(["clientBBB"]); + // Non-simulation identity fields are untouched. + expect(wire.players[0]).toMatchObject({ + clientID: "clientAAA", + username: "alice", + teamIndex: 0, + }); + }); + + it("nulls clanTags and drops friends when names are anonymized", () => { + const info = startInfo({ + gameType: GameType.Private, + anonymizeNames: true, + }); + + const wire = toWireGameStartInfo(info); + + expect(wire.players.map((p) => p.clanTag)).toEqual([null, null]); + expect(wire.players.map((p) => p.friends)).toEqual([undefined, undefined]); + }); + + it("returns the record info untouched when neither setting is on", () => { + const info = startInfo({ gameType: GameType.Public }); + + expect(toWireGameStartInfo(info)).toBe(info); + }); + + it("leaves singleplayer records alone — they were simulated unblanked", () => { + const info = startInfo({ + gameType: GameType.Singleplayer, + disableClanTags: true, + anonymizeNames: true, + }); + + expect(toWireGameStartInfo(info)).toBe(info); + }); + + it("does not mutate the input record", () => { + const info = startInfo({ + gameType: GameType.Public, + anonymizeNames: true, + }); + + toWireGameStartInfo(info); + + expect(info.players[0].clanTag).toBe("AA"); + expect(info.players[0].friends).toEqual(["clientBBB"]); + }); +}); diff --git a/tests/replay/ReplayGame.ts b/tests/replay/ReplayGame.ts index b63643626e..1c8c28ca90 100644 --- a/tests/replay/ReplayGame.ts +++ b/tests/replay/ReplayGame.ts @@ -39,7 +39,11 @@ import { GameRecordSchema, GameStartInfo, } from "../../src/core/Schemas"; -import { decompressGameRecord, simpleHash } from "../../src/core/Util"; +import { + decompressGameRecord, + simpleHash, + toWireGameStartInfo, +} from "../../src/core/Util"; import { NodeGameMapLoader } from "../perf/fullgame/NodeGameMapLoader"; const PROJECT_ROOT = path.resolve( @@ -144,13 +148,14 @@ async function main(): Promise { return { ...p, teamIndex }; }); - const gameStart: GameStartInfo = { + // Same wire blanking the client replay path applies (see toWireGameStartInfo). + const gameStart: GameStartInfo = toWireGameStartInfo({ gameID: info.gameID, lobbyCreatedAt: info.lobbyCreatedAt, config: info.config, players, tribes: info.tribes, - }; + }); console.log( `Replaying ${info.gameID}: ${info.config.gameMap} ` +