mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 18:44:37 +00:00
3b84a6f569
**Add approved & assigned issue number here:** Resolves #4296 ## Description: Adds an "Anonymous players" option to private lobbies (host toggle, off by default). When it is on, the server sends each client anonymized usernames for everyone except themselves. The lobby creator and admins still see real names so they can moderate. Names are hidden on every player-facing surface: the game start message, lobby info, /api/game/:id, and the link preview. It is enforced server-side, so a client extension cannot read real names off the wire. Initially added as part of our overhaul of OpenFront masters, but this feature can very well be useful for content creators, and other tournament hosts. Anonymized names reuse the existing tribe word lists (no emoji), so they pass UsernameSchema, and they are seeded per user, so a player looks different to different users but stays consistent from the lobby into the game. The saved game record keeps real names (anonymization is a per-send transform, gameStartInfo is never mutated), so replays and stats are unaffected. Nothing changes for normal games. New option selection: <img width="990" height="918" alt="image" src="https://github.com/user-attachments/assets/31df0b0b-7757-4b2b-9bff-84310faee8d9" /> The host, when enabling the option, gets a little eye icon next to the players(including himself to enable/disable the anon names for himself, and/or other player) By default(the names everyone will see are random and unique): <img width="979" height="188" alt="image" src="https://github.com/user-attachments/assets/f0caa4a4-9f14-41d3-89c6-9a38e8c2e6f0" /> Toggling the eye ON for yourself (the host, or any given player, will allow them to see the real names of everyone, in the lobby and in game): <img width="969" height="138" alt="image" src="https://github.com/user-attachments/assets/89abf0e0-1433-43ea-9870-49d96ca46d30" /> ## 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: zixer._
230 lines
7.1 KiB
TypeScript
230 lines
7.1 KiB
TypeScript
import { GameType } from "../../src/core/game/Game";
|
|
import { UsernameSchema } from "../../src/core/Schemas";
|
|
import { Client } from "../../src/server/Client";
|
|
import { GameServer } from "../../src/server/GameServer";
|
|
|
|
function makeMockWs() {
|
|
return {
|
|
on: () => {},
|
|
removeAllListeners: () => {},
|
|
send: vi.fn(),
|
|
close: vi.fn(),
|
|
readyState: 1,
|
|
};
|
|
}
|
|
|
|
function makeClient(
|
|
clientID: string,
|
|
persistentID: string,
|
|
username: string,
|
|
clanTag: string | null,
|
|
role: string | null = null,
|
|
publicId: string | undefined = undefined,
|
|
friends: string[] = [],
|
|
): Client {
|
|
return new Client(
|
|
clientID,
|
|
persistentID,
|
|
null,
|
|
role,
|
|
undefined,
|
|
"127.0.0.1",
|
|
username,
|
|
clanTag,
|
|
makeMockWs() as any,
|
|
undefined,
|
|
publicId,
|
|
friends,
|
|
);
|
|
}
|
|
|
|
// creator = lobby host, admin = admin role, alice + bob = regular players.
|
|
function makeGame(
|
|
anonymizeNames: boolean,
|
|
disableClanTags = false,
|
|
nameReveals: string[] = [],
|
|
) {
|
|
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,
|
|
anonymizeNames,
|
|
disableClanTags,
|
|
nameReveals,
|
|
} as any,
|
|
"creator-pid",
|
|
);
|
|
[
|
|
makeClient("creator", "creator-pid", "CreatorReal", "HOST"),
|
|
makeClient("admin", "admin-pid", "AdminReal", "ADM", "admin"),
|
|
makeClient("alice", "alice-pid", "AliceReal", "AAA", null, "alice-pub", [
|
|
"bob-pub",
|
|
]),
|
|
makeClient("bob", "bob-pid", "BobReal", "BBB", null, "bob-pub"),
|
|
].forEach((c) => game.joinClient(c));
|
|
return game;
|
|
}
|
|
|
|
const REAL_NAMES = ["CreatorReal", "AdminReal", "AliceReal", "BobReal"];
|
|
const byId = (info: any, id: string) =>
|
|
info.clients.find((c: any) => c.clientID === id);
|
|
|
|
describe("anonymizeNames: gameInfo (lobby / HTTP / preview)", () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("off: everyone sees real names and clan tags", () => {
|
|
const info = makeGame(false).gameInfo("alice");
|
|
expect(byId(info, "bob").username).toBe("BobReal");
|
|
expect(byId(info, "creator").clanTag).toBe("HOST");
|
|
});
|
|
|
|
it("on: a regular player sees themselves but not others", () => {
|
|
const info = makeGame(true).gameInfo("alice");
|
|
expect(byId(info, "alice").username).toBe("AliceReal"); // self
|
|
const bob = byId(info, "bob");
|
|
expect(bob.username).not.toBe("BobReal");
|
|
expect(REAL_NAMES).not.toContain(bob.username);
|
|
expect(bob.clanTag).toBeNull();
|
|
expect(bob.friends).toBeUndefined();
|
|
expect(UsernameSchema.safeParse(bob.username).success).toBe(true);
|
|
});
|
|
|
|
it("on: nobody is exempt by default, not even the host", () => {
|
|
const info = makeGame(true).gameInfo("creator");
|
|
expect(byId(info, "creator").username).toBe("CreatorReal"); // own name
|
|
const bob = byId(info, "bob");
|
|
expect(bob.username).not.toBe("BobReal"); // host does NOT see others
|
|
expect(REAL_NAMES).not.toContain(bob.username);
|
|
});
|
|
|
|
it("on: a granted viewer (nameReveals) sees everyone's real names", () => {
|
|
const info = makeGame(true, false, ["alice"]).gameInfo("alice");
|
|
for (const id of ["creator", "admin", "bob"]) {
|
|
expect(REAL_NAMES).toContain(byId(info, id).username);
|
|
}
|
|
});
|
|
|
|
it("on: a non-granted viewer still sees only themselves", () => {
|
|
const info = makeGame(true, false, ["alice"]).gameInfo("bob");
|
|
expect(byId(info, "bob").username).toBe("BobReal"); // self
|
|
expect(REAL_NAMES).not.toContain(byId(info, "alice").username);
|
|
});
|
|
|
|
it("on: no viewer (HTTP / preview) anonymizes everyone", () => {
|
|
const info = makeGame(true).gameInfo();
|
|
for (const id of ["creator", "admin", "alice", "bob"]) {
|
|
expect(REAL_NAMES).not.toContain(byId(info, id).username);
|
|
expect(byId(info, id).clanTag).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("on: a viewer's view of a player is stable across calls", () => {
|
|
const game = makeGame(true);
|
|
expect(byId(game.gameInfo("alice"), "bob").username).toBe(
|
|
byId(game.gameInfo("alice"), "bob").username,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("anonymizeNames: config updates propagate", () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("turning it off un-anonymizes (not stuck on)", () => {
|
|
const game = makeGame(true);
|
|
game.updateGameConfig({ anonymizeNames: false });
|
|
expect(byId(game.gameInfo("alice"), "bob").username).toBe("BobReal");
|
|
});
|
|
|
|
it("clearing nameReveals revokes the grant", () => {
|
|
const game = makeGame(true, false, ["alice"]);
|
|
expect(byId(game.gameInfo("alice"), "bob").username).toBe("BobReal"); // granted
|
|
game.updateGameConfig({ nameReveals: [] });
|
|
expect(byId(game.gameInfo("alice"), "bob").username).not.toBe("BobReal"); // revoked
|
|
});
|
|
});
|
|
|
|
describe("anonymizeNames: startInfoFor (in-game start payload)", () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function withStartInfo(anonymizeNames: boolean) {
|
|
const game = makeGame(anonymizeNames);
|
|
const players = [
|
|
{
|
|
clientID: "creator",
|
|
username: "CreatorReal",
|
|
clanTag: "HOST",
|
|
isLobbyCreator: true,
|
|
friends: [],
|
|
},
|
|
{
|
|
clientID: "alice",
|
|
username: "AliceReal",
|
|
clanTag: "AAA",
|
|
cosmetics: { flag: "fr" },
|
|
friends: ["bob"],
|
|
},
|
|
{ clientID: "bob", username: "BobReal", clanTag: "BBB", friends: [] },
|
|
];
|
|
const startInfo = { gameID: "g1", lobbyCreatedAt: 0, config: {}, players };
|
|
(game as any).gameStartInfo = startInfo;
|
|
(game as any).wireGameStartInfo = JSON.parse(JSON.stringify(startInfo));
|
|
return game;
|
|
}
|
|
|
|
const player = (info: any, id: string) =>
|
|
info.players.find((x: any) => x.clientID === id);
|
|
|
|
it("anonymizes others, keeps self, strips clan/cosmetics/friends", () => {
|
|
const info = (withStartInfo(true) as any).startInfoFor("bob");
|
|
expect(player(info, "bob").username).toBe("BobReal"); // self
|
|
const alice = player(info, "alice");
|
|
expect(alice.username).not.toBe("AliceReal");
|
|
expect(UsernameSchema.safeParse(alice.username).success).toBe(true);
|
|
expect(alice.clanTag).toBeNull();
|
|
expect(alice.cosmetics).toBeUndefined();
|
|
expect(alice.friends).toBeUndefined();
|
|
});
|
|
|
|
it("shows the same anonymized name in-game as in the lobby", () => {
|
|
const game = withStartInfo(true);
|
|
const inGame = player((game as any).startInfoFor("bob"), "alice").username;
|
|
expect(inGame).toBe(byId(game.gameInfo("bob"), "alice").username);
|
|
});
|
|
|
|
it("never mutates gameStartInfo (the archived record stays real)", () => {
|
|
const game = withStartInfo(true);
|
|
(game as any).startInfoFor("bob");
|
|
const rec = player((game as any).gameStartInfo, "alice");
|
|
expect(rec.username).toBe("AliceReal");
|
|
expect(rec.clanTag).toBe("AAA");
|
|
expect(rec.cosmetics).toEqual({ flag: "fr" });
|
|
});
|
|
|
|
it("off: returns the shared wire start info unchanged", () => {
|
|
const game = withStartInfo(false);
|
|
expect((game as any).startInfoFor("bob")).toBe(
|
|
(game as any).wireGameStartInfo,
|
|
);
|
|
});
|
|
});
|