Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});

Expand Down
34 changes: 33 additions & 1 deletion src/core/Util.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions tests/ToWireGameStartInfo.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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"]);
});
});
11 changes: 8 additions & 3 deletions tests/replay/ReplayGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -144,13 +148,14 @@ async function main(): Promise<void> {
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} ` +
Expand Down
Loading