Files
OpenFrontIO/src/client/components/LobbyPlayerView.ts
T
Zixer1 3b84a6f569 Feat/anonymize names (#4318)
**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._
2026-06-24 07:54:44 -07:00

428 lines
14 KiB
TypeScript

import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import {
ColoredTeams,
Duos,
GameMode,
HumansVsNations,
PlayerInfo,
PlayerType,
Quads,
Team,
Trios,
} from "../../core/game/Game";
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
import { UserSettings } from "../../core/game/UserSettings";
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
import { createRandomName, formatPlayerDisplayName } from "../../core/Util";
import { Theme, themeProvider } from "../theme/ThemeProvider";
import { getTranslatedPlayerTeamLabel, translateText } from "../Utils";
export interface TeamPreviewData {
team: Team;
players: ClientInfo[];
}
@customElement("lobby-player-view")
export class LobbyTeamView extends LitElement {
@property({ type: String }) gameMode: GameMode = GameMode.FFA;
@property({ type: Array }) clients: ClientInfo[] = [];
@state() private teamPreview: TeamPreviewData[] = [];
@state() private teamMaxSize: number = 0;
@property({ type: String }) lobbyCreatorClientID: string = "";
@property({ type: String }) currentClientID: string = "";
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
@property({ type: Function }) onToggleNameReveal?: (clientID: string) => void;
@property({ type: Array }) nameReveals: string[] = [];
@property({ type: Boolean }) anonymizeNames: boolean = false;
@property({ type: Number }) nationCount: number = 0;
@property({ type: Boolean }) isPublicGame: boolean = false;
private get theme(): Theme {
return themeProvider.current();
}
@state() private showTeamColors: boolean = false;
private userSettings: UserSettings = new UserSettings();
/**
* For public HumansVsNations games, nation count always matches human count
* (server enforces this in NationCreation). For private games, the host
* controls the nation count via the slider.
*/
private get effectiveNationCount(): number {
if (this.isPublicGame && this.teamCount === HumansVsNations) {
return this.clients.length;
}
return this.nationCount;
}
willUpdate(changedProperties: Map<string, any>) {
// Recompute team preview when relevant properties change
// clients is updated from WebSocket lobby_info events
if (
changedProperties.has("gameMode") ||
changedProperties.has("clients") ||
changedProperties.has("teamCount") ||
changedProperties.has("nationCount") ||
changedProperties.has("isPublicGame")
) {
const teamsList = this.getTeamList();
this.computeTeamPreview(teamsList);
this.showTeamColors = teamsList.length <= 7;
}
}
render() {
return html`
<div class="border-t border-white/10 pt-6">
<div class="flex justify-between items-center mb-4">
<div
class="text-xs font-bold text-white/40 uppercase tracking-widest"
>
${this.clients.length}
${this.clients.length === 1
? translateText("host_modal.player")
: translateText("host_modal.players")}
<span style="margin: 0 8px;">•</span>
${this.effectiveNationCount}
${this.effectiveNationCount === 1
? translateText("host_modal.nation_player")
: translateText("host_modal.nation_players")}
</div>
</div>
<div
class="players-list block rounded-lg border border-white/10 bg-white/5 p-2"
>
${this.gameMode === GameMode.Team
? this.renderTeamMode()
: this.renderFreeForAll()}
</div>
</div>
`;
}
createRenderRoot() {
return this;
}
private renderTeamMode() {
const active = this.teamPreview.filter(
(t) => t.players.length > 0 || t.team === ColoredTeams.Nations,
);
const empty = this.teamPreview.filter(
(t) => t.players.length === 0 && t.team !== ColoredTeams.Nations,
);
return html` <div
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch"
>
<div
class="w-full md:w-60 bg-gray-800 p-2 border border-gray-700 rounded-lg"
>
<div class="font-bold mb-1.5 text-gray-300 text-sm">
${translateText("host_modal.players")}
</div>
${repeat(
this.clients,
(c) => c.clientID ?? c.username,
(client) => {
const displayName = this.getClientDisplayName(client);
return html`<div
class="px-2 py-1 rounded-sm mb-1 text-xs text-white border
${this.isCurrentPlayer(client)
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
${displayName}
</div>`;
},
)}
</div>
<div class="flex-1 flex flex-col gap-3 md:gap-4 md:pr-1">
<div>
<div class="font-semibold text-gray-200 mb-1 text-sm">
${translateText("host_modal.assigned_teams")}
</div>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
${repeat(
active,
(p) => p.team,
(preview) => this.renderTeamCard(preview, false),
)}
</div>
</div>
<div>
${empty.length > 0
? html`<div class="font-semibold text-gray-200 mb-1 text-sm">
${translateText("host_modal.empty_teams")}
</div>`
: ""}
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
${repeat(
empty,
(p) => p.team,
(preview) => this.renderTeamCard(preview, true),
)}
</div>
</div>
</div>
</div>`;
}
// Host-only per-player toggle for who may see real names under anonymizeNames.
private renderRevealToggle(clientID: string) {
if (!this.onToggleNameReveal || !this.anonymizeNames) return html``;
const on = this.nameReveals.includes(clientID);
return html`<button
@click=${() => this.onToggleNameReveal?.(clientID)}
title=${translateText("host_modal.toggle_name_reveal")}
style="background:none;border:none;cursor:pointer;font-size:13px;line-height:1;margin-left:4px;opacity:${on
? "1"
: "0.35"};"
>
👁
</button>`;
}
private renderFreeForAll() {
return html`${repeat(
this.clients,
(c) => c.clientID ?? c.username,
(client) => {
const displayName = this.getClientDisplayName(client);
return html`<span
class="player-tag ${this.isCurrentPlayer(client)
? "current-player"
: ""}"
>
<span class="text-white">${displayName}</span>
${this.renderRevealToggle(client.clientID)}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
>`
: this.onKickPlayer
? html`<button
class="remove-player-btn"
@click=${() => this.onKickPlayer?.(client.clientID)}
aria-label=${translateText("host_modal.remove_player", {
username: displayName,
})}
>
×
</button>`
: html``}
</span>`;
},
)} `;
}
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
const displayCount =
preview.team === ColoredTeams.Nations
? this.effectiveNationCount
: preview.players.length;
const maxTeamSize =
preview.team === ColoredTeams.Nations
? this.effectiveNationCount
: this.teamMaxSize;
const teamLabel = getTranslatedPlayerTeamLabel(preview.team);
return html`
<div
class="bg-gray-800 border rounded-xl flex flex-col
${this.teamContainsCurrentPlayer(preview)
? "border-sky-500/60"
: "border-gray-700"}"
>
<div
class="px-2 py-1 font-bold flex items-center justify-between text-white rounded-t-xl text-[13px] gap-2 bg-gray-700/70"
>
${this.showTeamColors
? html` <span
class="inline-block w-2.5 h-2.5 rounded-full border-2 border-white/90 shadow-inner bg-(--bg)"
style="--bg:${this.teamHeaderColor(preview.team)};"
></span>`
: null}
<span class="truncate">${teamLabel}</span>
<span class="text-white/90">${displayCount}/${maxTeamSize}</span>
</div>
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
${isEmpty
? html`<div class="text-[11px] italic text-gray-400">
${translateText("host_modal.empty_team")}
</div>`
: repeat(
preview.players,
(p) => p.clientID ?? p.username,
(p) => {
const displayName = this.getClientDisplayName(p);
return html` <div
class="px-2 py-1 rounded-sm text-xs flex items-center justify-between border
${this.isCurrentPlayer(p)
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
<span class="truncate text-white">${displayName}</span>
${this.renderRevealToggle(p.clientID)}
${p.clientID === this.lobbyCreatorClientID
? html`<span class="ml-2 text-[11px] text-green-300"
>(${translateText("host_modal.host_badge")})</span
>`
: this.onKickPlayer
? html`<button
class="remove-player-btn ml-2"
@click=${() => this.onKickPlayer?.(p.clientID)}
aria-label=${translateText(
"host_modal.remove_player",
{
username: displayName,
},
)}
>
×
</button>`
: html``}
</div>`;
},
)}
</div>
</div>
`;
}
private getTeamList(): Team[] {
if (this.gameMode !== GameMode.Team) return [];
const playerCount = this.clients.length + this.effectiveNationCount;
const config = this.teamCount;
if (config === HumansVsNations) {
return [ColoredTeams.Humans, ColoredTeams.Nations];
}
let numTeams: number;
if (typeof config === "number") {
numTeams = Math.max(2, config);
} else {
const divisor =
config === Duos ? 2 : config === Trios ? 3 : config === Quads ? 4 : 2;
numTeams = Math.max(2, Math.ceil(playerCount / divisor));
}
if (numTeams < 8) {
const ordered: Team[] = [
ColoredTeams.Red,
ColoredTeams.Blue,
ColoredTeams.Yellow,
ColoredTeams.Green,
ColoredTeams.Purple,
ColoredTeams.Orange,
ColoredTeams.Teal,
];
return ordered.slice(0, numTeams);
}
return Array.from({ length: numTeams }, (_, i) => `Team ${i + 1}`);
}
private teamHeaderColor(team: Team): string {
try {
return this.theme.teamColor(team).toHex();
} catch {
return "#3b3f46"; // Default gray for unknown teams
}
}
private computeTeamPreview(teams: Team[] = []) {
if (this.gameMode !== GameMode.Team) {
this.teamPreview = [];
this.teamMaxSize = 0;
return;
}
// HumansVsNations: show all clients under Humans initially
if (this.teamCount === HumansVsNations) {
this.teamMaxSize = this.clients.length;
this.teamPreview = [
{ team: ColoredTeams.Humans, players: [...this.clients] },
{ team: ColoredTeams.Nations, players: [] },
];
return;
}
const players = this.clients.map(
(c) =>
new PlayerInfo(
c.username,
PlayerType.Human,
c.clientID,
c.clientID,
false,
c.clanTag,
c.friends ?? [],
),
);
const assignment = assignTeamsLobbyPreview(
players,
teams,
this.effectiveNationCount,
);
const buckets = new Map<Team, ClientInfo[]>();
for (const t of teams) buckets.set(t, []);
for (const [p, team] of assignment.entries()) {
if (team === "kicked") continue;
const bucket = buckets.get(team);
if (!bucket) continue;
const client = this.clients.find((c) => c.clientID === p.clientID);
if (client) bucket.push(client);
}
// Compute per-team capacity safely and align with common team sizes
if (this.teamCount === Duos) {
this.teamMaxSize = 2;
} else if (this.teamCount === Trios) {
this.teamMaxSize = 3;
} else if (this.teamCount === Quads) {
this.teamMaxSize = 4;
} else {
// Fallback: divide players across teams; guard against 0 and empty lobbies
this.teamMaxSize = Math.max(
1,
Math.ceil(
(this.clients.length + this.effectiveNationCount) / teams.length,
),
);
}
this.teamPreview = teams.map((t) => ({
team: t,
players: buckets.get(t) ?? [],
}));
}
private isCurrentPlayer(client: ClientInfo): boolean {
return !!this.currentClientID && client.clientID === this.currentClientID;
}
private teamContainsCurrentPlayer(preview: TeamPreviewData): boolean {
return preview.players.some((p) => this.isCurrentPlayer(p));
}
private getClientDisplayName(client: ClientInfo): string {
const full = formatPlayerDisplayName(client.username, client.clanTag);
if (!this.userSettings.anonymousNames()) {
return full;
}
if (this.currentClientID && client.clientID === this.currentClientID) {
return full;
}
// Keep clan tag visible while anonymizing only the username.
const anonymizedUsername =
createRandomName(client.username, PlayerType.Human) ?? client.username;
return formatPlayerDisplayName(anonymizedUsername, client.clanTag);
}
}