mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 13:22:51 +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._
428 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|