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} ` +