Skip to content
Open
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
27 changes: 21 additions & 6 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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),
Expand Down
95 changes: 95 additions & 0 deletions tests/server/AdminClanTags.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading