From 8f1bbdd20e14b43edf2ff1daea12fd5caa846a19 Mon Sep 17 00:00:00 2001
From: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
Date: Wed, 15 Jul 2026 23:06:36 +0100
Subject: [PATCH] let admins see clan tags in FFA (#4459)
Resolves #4446
## Description:
left is an admin, right is a normal player:
admin can see their own tag, and the UON tag, the non-admin can only see
their own tag.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
w.o.n
Co-authored-by: iamlewis
---
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 38a87fca4..69340a68a 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -6,7 +6,7 @@ import { z } from "zod";
import { anonAnimalName } from "../core/AnonAnimals";
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,
@@ -1020,16 +1020,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,
};
@@ -1063,7 +1075,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 000000000..b99ddc31a
--- /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");
+ });
+});