From 02ab98faf57bf4b4ee6f9c4bbd5f625e5e81e9d0 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:52:44 +0100 Subject: [PATCH] admin+ only --- src/server/GameServer.ts | 27 +++++++-- tests/server/AdminClanTags.test.ts | 95 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 tests/server/AdminClanTags.test.ts diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 6a63e10804..805bbd038a 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -4,7 +4,7 @@ import WebSocket from "ws"; import { z } from "zod"; import { isAdminRole } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; -import { GameType } from "../core/game/Game"; +import { GameMode, GameType } from "../core/game/Game"; import { ClientID, ClientMessageSchema, @@ -878,16 +878,28 @@ export class GameServer { // clients desync. Only the username of players this viewer can't see is // anonymized, and their cosmetics hidden, neither of which the simulation // reads. - private startInfoFor(viewer: ClientID): GameStartInfo { - if (!this.gameConfig.anonymizeNames) return this.wireGameStartInfo; + // + // Exception: admins in FFA get the real clan tags (the display pipeline then + // shows them everywhere) so they can spot teaming live. Safe ONLY in FFA — + // that mode never runs assignTeams, so clanTag never reaches the simulation, + // and the desync hash (Player.hash) excludes names. Gated on FFA, NOT + // disableClanTags: a Team game with tags disabled DOES assign teams by + // clanTag, so a per-viewer reveal there would desync. + private startInfoFor(viewer: ClientID, isAdmin: boolean): GameStartInfo { + const revealClanTags = isAdmin && this.gameConfig.gameMode === GameMode.FFA; + if (!this.gameConfig.anonymizeNames) { + return revealClanTags ? this.gameStartInfo : this.wireGameStartInfo; + } return { ...this.wireGameStartInfo, - players: this.wireGameStartInfo.players.map((p) => { + players: this.wireGameStartInfo.players.map((p, i) => { const real = this.seesReal(viewer, p.clientID); return { ...p, username: real ? p.username : this.anonName(viewer, p.clientID), - clanTag: null, + clanTag: revealClanTags + ? this.gameStartInfo.players[i].clanTag + : null, friends: undefined, cosmetics: real ? p.cosmetics : undefined, }; @@ -921,7 +933,10 @@ export class GameServer { JSON.stringify({ type: "start", turns: this.turns.slice(lastTurn), - gameStartInfo: this.startInfoFor(client.clientID), + gameStartInfo: this.startInfoFor( + client.clientID, + isAdminRole(client.role), + ), lobbyCreatedAt: this.createdAt, myClientID: client.clientID, } satisfies ServerStartGameMessage), diff --git a/tests/server/AdminClanTags.test.ts b/tests/server/AdminClanTags.test.ts new file mode 100644 index 0000000000..b99ddc31a0 --- /dev/null +++ b/tests/server/AdminClanTags.test.ts @@ -0,0 +1,95 @@ +import { GameMode, GameType } from "../../src/core/game/Game"; +import { GameServer } from "../../src/server/GameServer"; + +// Admins see real clan tags in FFA so they can spot teaming live. The reveal is +// gated on FFA — that mode never runs assignTeams, so clanTag never feeds the +// simulation. A Team game with tags disabled DOES assign teams by clanTag, so a +// per-viewer reveal there would desync; those cases must stay stripped. + +// Build a GameServer with start info already populated, mirroring how start() +// leaves it: gameStartInfo keeps real clan tags; wireGameStartInfo is the +// stripped copy clients normally receive when disableClanTags is set. +function makeGame(gameMode: GameMode, anonymizeNames = false) { + const logger: any = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const game = new GameServer( + "g1", + logger, + Date.now(), + { + gameType: GameType.Private, + gameMode, + disableClanTags: true, + anonymizeNames, + } as any, + "creator-pid", + ); + const players = [ + { clientID: "creator", username: "CreatorReal", clanTag: "HOST" }, + { clientID: "alice", username: "AliceReal", clanTag: "AAA" }, + { clientID: "charlie", username: "CharlieReal", clanTag: null }, + ]; + const startInfo = { gameID: "g1", lobbyCreatedAt: 0, config: {}, players }; + (game as any).gameStartInfo = startInfo; + // Wire copy strips clan tags (what disableClanTags does in start()). + (game as any).wireGameStartInfo = { + ...startInfo, + players: players.map((p) => ({ ...p, clanTag: null })), + }; + return game; +} + +const startInfoFor = (game: GameServer, viewer: string, isAdmin: boolean) => + (game as any).startInfoFor(viewer, isAdmin); +const player = (info: any, id: string) => + info.players.find((p: any) => p.clientID === id); + +describe("startInfoFor: admin clan-tag reveal in FFA", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("FFA + admin: sees real clan tags", () => { + const info = startInfoFor(makeGame(GameMode.FFA), "admin", true); + expect(player(info, "creator").clanTag).toBe("HOST"); + expect(player(info, "alice").clanTag).toBe("AAA"); + expect(player(info, "charlie").clanTag).toBeNull(); // never had one + }); + + it("FFA + non-admin: clan tags stay stripped", () => { + const info = startInfoFor(makeGame(GameMode.FFA), "alice", false); + expect(player(info, "creator").clanTag).toBeNull(); + expect(player(info, "alice").clanTag).toBeNull(); + }); + + it("Team + tags disabled + admin: NOT revealed (desync guard)", () => { + // Team mode assigns teams by clanTag, so revealing it to only the admin + // would diverge that client's team assignment — must stay stripped. + const info = startInfoFor(makeGame(GameMode.Team), "admin", true); + expect(player(info, "creator").clanTag).toBeNull(); + expect(player(info, "alice").clanTag).toBeNull(); + }); + + it("never mutates gameStartInfo (the archived record stays real)", () => { + const game = makeGame(GameMode.FFA); + startInfoFor(game, "admin", true); + expect((game as any).gameStartInfo.players[0].clanTag).toBe("HOST"); + // The shared wire copy stays stripped for non-admins. + expect((game as any).wireGameStartInfo.players[0].clanTag).toBeNull(); + }); + + it("anonymized FFA + admin: reveals clan tags but still anonymizes others", () => { + const game = makeGame(GameMode.FFA, true); + const info = startInfoFor(game, "admin", true); + // Real tags are revealed... + expect(player(info, "alice").clanTag).toBe("AAA"); + // ...but other players' usernames are still anonymized. + expect(player(info, "alice").username).not.toBe("AliceReal"); + }); +});